<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[끄적끄적 테크 블로그]]></title><description><![CDATA[물류 회사에 다니고 있는 개발자 블로그입니다.개발을 너무 좋아해서 정신없이 작업하다가 중간에 끄적거리며 내용들을 몇개 적어봅니다 ㅎㅎ]]></description><link>https://blog.hyunjun.org</link><image><url>https://cdn.hashnode.com/uploads/logos/67e54b297b50e225ff9464d9/a3c69b2e-79ef-4a34-b4cb-a2917ed159ca.png</url><title>끄적끄적 테크 블로그</title><link>https://blog.hyunjun.org</link></image><generator>RSS for Node</generator><lastBuildDate>Wed, 15 Apr 2026 15:22:47 GMT</lastBuildDate><atom:link href="https://blog.hyunjun.org/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[카프카 입문 시리즈 2편: 토픽, 파티션, 오프셋]]></title><description><![CDATA[이 글은 Apache Kafka 입문 시리즈의 두 번째 글입니다. 1편에서 살펴본 구성 요소들 위에서, 메시지가 실제로 어떤 구조로 저장되고 관리되는지 알아보겠습니다.

1편을 마치며 세 가지 질문을 남겼습니다.

메시지는 브로커 안에서 어떤 구조로 저장될까?
토픽과 파티션은 정확히 무엇이고, 왜 필요할까?
컨슈머의 오프셋은 어떻게 동작할까?

이번 편에서 이 질문들에 하나씩 답하겠습니다.

Topic: 메시지의 논리적 분류
토픽(Topic)은...]]></description><link>https://blog.hyunjun.org/2</link><guid isPermaLink="true">https://blog.hyunjun.org/2</guid><category><![CDATA[Apache Kafka]]></category><category><![CDATA[data streaming]]></category><dc:creator><![CDATA[조현준]]></dc:creator><pubDate>Thu, 19 Mar 2026 09:47:48 GMT</pubDate><content:encoded><![CDATA[<blockquote>
<p>이 글은 Apache Kafka 입문 시리즈의 두 번째 글입니다. 1편에서 살펴본 구성 요소들 위에서, 메시지가 실제로 <strong>어떤 구조로 저장되고 관리되는지</strong> 알아보겠습니다.</p>
</blockquote>
<p>1편을 마치며 세 가지 질문을 남겼습니다.</p>
<ul>
<li>메시지는 브로커 안에서 <strong>어떤 구조</strong>로 저장될까?</li>
<li><strong>토픽과 파티션</strong>은 정확히 무엇이고, 왜 필요할까?</li>
<li>컨슈머의 <strong>오프셋</strong>은 어떻게 동작할까?</li>
</ul>
<p>이번 편에서 이 질문들에 하나씩 답하겠습니다.</p>
<hr />
<h2 id="heading-topic">Topic: 메시지의 논리적 분류</h2>
<p><strong>토픽(Topic)</strong>은 메시지를 분류하는 논리적 단위입니다. 1편에서 "메시지가 저장되는 카테고리"라고 소개한 바로 그것입니다.</p>
<p>쇼핑몰 시스템이라면 <code>orders</code>, <code>payments</code>, <code>notifications</code>처럼 용도에 따라 토픽을 나눕니다. 프로듀서는 토픽을 지정하여 메시지를 보내고, 컨슈머는 관심 있는 토픽을 구독하여 메시지를 읽습니다.</p>
<pre><code class="lang-mermaid">flowchart LR
    subgraph Producers
        P1[Order Service]
        P2[Payment Service]
    end

    subgraph Topics
        T1[orders]
        T2[payments]
    end

    subgraph Consumers
        C1[Analytics Service]
        C2[Notification Service]
    end

    P1 --&gt;|"publish"| T1
    P2 --&gt;|"publish"| T2
    T1 --&gt;|"subscribe"| C1
    T2 --&gt;|"subscribe"| C2
    T1 --&gt;|"subscribe"| C2
</code></pre>
<h3 id="heading-7yag7zs97j2aiouhnoq3uoulpa">토픽은 로그다</h3>
<p>토픽의 본질은 <strong>추가 전용 로그(append-only log)</strong>입니다. 새로운 메시지는 항상 로그의 <strong>끝에만 추가</strong>됩니다. 한번 기록된 메시지는 수정하거나 삭제할 수 없습니다. 이 불변성(immutability)은 카프카의 핵심 설계 원칙입니다.</p>
<pre><code class="lang-mermaid">flowchart LR
    subgraph Log["Append-Only Log"]
        M0["msg 0"]
        M1["msg 1"]
        M2["msg 2"]
        M3["msg 3"]
        M4["msg 4"]
    end

    M0 --&gt; M1 --&gt; M2 --&gt; M3 --&gt; M4

    P["Producer"] --&gt;|"append"| M4
</code></pre>
<p>그렇다면 오래된 메시지는 영원히 남아 있을까요? 아닙니다. 카프카는 <strong>보존 정책(retention policy)</strong>에 따라 오래된 메시지를 자동으로 정리합니다.</p>
<h3 id="heading-67o07kg0ioygleyxhq">보존 정책</h3>
<div class="hn-table">
<table>
<thead>
<tr>
<td>정책</td><td>설정</td><td>기본값</td><td>동작</td></tr>
</thead>
<tbody>
<tr>
<td><strong>시간 기반 삭제</strong></td><td><code>retention.ms</code></td><td>7일 (604,800,000ms)</td><td>지정된 시간이 지난 메시지를 삭제</td></tr>
<tr>
<td><strong>크기 기반 삭제</strong></td><td><code>retention.bytes</code></td><td>-1 (무제한)</td><td>파티션 크기가 한도를 초과하면 오래된 메시지부터 삭제</td></tr>
<tr>
<td><strong>로그 컴팩션</strong></td><td><code>cleanup.policy=compact</code></td><td>—</td><td>같은 키의 메시지 중 <strong>최종 값만</strong> 남기는 것을 목표로 정리</td></tr>
</tbody>
</table>
</div><p>기본 설정에서는 7일이 지난 메시지가 삭제됩니다. 로그 컴팩션은 조금 다른 개념인데, 키별로 가장 마지막 값만 남기는 것을 <strong>목표로</strong> 백그라운드에서 점진적으로 정리하는 방식입니다. 컴팩션이 아직 실행되지 않은 시점에는 같은 키의 메시지가 여러 개 존재할 수 있습니다. 예를 들어 사용자 프로필을 저장하는 토픽이라면, 같은 사용자 ID를 키로 가진 메시지 중 최종 상태만 보존합니다.</p>
<pre><code class="lang-mermaid">flowchart LR
    subgraph Before Compaction
        A1["key=A, val=1"]
        B1["key=B, val=2"]
        A2["key=A, val=3"]
        B2["key=B, val=4"]
        A3["key=A, val=5"]
    end

    A1 --&gt; B1 --&gt; A2 --&gt; B2 --&gt; A3

    subgraph After Compaction
        A3c["key=A, val=5"]
        B2c["key=B, val=4"]
    end

    A3c --&gt; B2c
</code></pre>
<hr />
<h2 id="heading-partition">Partition: 토픽을 나누는 단위</h2>
<p>토픽 하나에 모든 메시지를 순서대로 쌓으면 간단하겠지만, <strong>한 대의 브로커에 부하가 집중</strong>됩니다. 이 문제를 해결하기 위해 카프카는 토픽을 <strong>파티션(Partition)</strong>이라는 더 작은 단위로 나눕니다.</p>
<h3 id="heading-7jmcio2mjo2lsoyfmoydtcdtlytsmpttlzzqsia">왜 파티션이 필요한가?</h3>
<p>핵심은 <strong>병렬 처리</strong>입니다.</p>
<pre><code class="lang-mermaid">flowchart TB
    subgraph Topic: orders
        subgraph Broker 1
            P0["Partition 0"]
        end
        subgraph Broker 2
            P1["Partition 1"]
        end
        subgraph Broker 3
            P2["Partition 2"]
        end
    end

    Producer --&gt;|"write"| P0
    Producer --&gt;|"write"| P1
    Producer --&gt;|"write"| P2
</code></pre>
<p>파티션이 3개라면:</p>
<ul>
<li><strong>쓰기 분산</strong>: 프로듀서가 3개의 브로커에 동시에 메시지를 보낼 수 있습니다</li>
<li><strong>읽기 분산</strong>: 컨슈머 3대가 각각 하나의 파티션을 담당하여 동시에 읽을 수 있습니다</li>
<li><strong>저장 분산</strong>: 데이터가 3개의 브로커에 나뉘어 저장됩니다</li>
</ul>
<p>파티션 수가 곧 <strong>병렬 처리의 상한선</strong>입니다. 컨슈머 그룹 내에서 하나의 파티션은 하나의 컨슈머만 읽을 수 있으므로, 파티션이 3개면 동시에 읽는 컨슈머도 최대 3대입니다.</p>
<h3 id="heading-7yym7yuw7iwyiouctou2goydmcdsijzshjwg67o07j6l">파티션 내부의 순서 보장</h3>
<p>1편 Q&amp;A에서 잠깐 언급했던 내용입니다. 카프카는 <strong>파티션 단위로 순서를 보장</strong>합니다.</p>
<p>하나의 파티션 안에서 메시지는 들어온 순서 그대로 저장되고, 같은 순서로 읽힙니다. 하지만 서로 다른 파티션 간에는 순서가 보장되지 않습니다.</p>
<pre><code class="lang-mermaid">flowchart TB
    subgraph Partition 0
        A1["offset 0: Order Created"]
        A2["offset 1: Order Paid"]
        A3["offset 2: Order Shipped"]
    end
    A1 --&gt; A2 --&gt; A3

    subgraph Partition 1
        B1["offset 0: Order Created"]
        B2["offset 1: Order Cancelled"]
    end
    B1 --&gt; B2
</code></pre>
<p>같은 파티션 내에서는 <code>Created → Paid → Shipped</code>이 보장되지만, Partition 0의 <code>Shipped</code>과 Partition 1의 <code>Cancelled</code> 중 어느 것이 먼저 처리될지는 알 수 없습니다.</p>
<hr />
<h2 id="heading-66mu7iuc7kea64quioywtouwpcdtjizti7dshzjsnlzrozwg6rca64qu6rcapw">메시지는 어떤 파티션으로 가는가?</h2>
<p>프로듀서가 메시지를 보낼 때, 어떤 파티션에 넣을지를 결정하는 것이 <strong>파티셔닝 전략</strong>입니다.</p>
<h3 id="heading-7ykk6rcaioyeiouklcdqsr3smra6io2vtoylncdquldrsjgg67ae67cw">키가 있는 경우: 해시 기반 분배</h3>
<p>메시지에 <strong>키(key)</strong>가 있으면, 카프카는 키의 해시값으로 파티션을 결정합니다.</p>
<pre><code>partition = toPositive(murmur2(key)) % numPartitions
</code></pre><p><code>murmur2</code> 해시는 음수를 반환할 수 있으므로, <code>toPositive()</code>로 양수 변환 후 나머지 연산을 수행합니다.</p>
<p>같은 키는 항상 같은 파티션으로 갑니다. 따라서 <strong>같은 키를 가진 메시지끼리 순서가 보장됩니다.</strong></p>
<pre><code class="lang-mermaid">flowchart LR
    subgraph Producer
        M1["key=user-1"]
        M2["key=user-2"]
        M3["key=user-1"]
    end

    subgraph Topic
        P0["Partition 0"]
        P1["Partition 1"]
    end

    M1 --&gt;|"hash % 2 = 0"| P0
    M2 --&gt;|"hash % 2 = 1"| P1
    M3 --&gt;|"hash % 2 = 0"| P0
</code></pre>
<p>주문 시스템에서 사용자 ID를 키로 설정하면, 같은 사용자의 주문 이벤트는 항상 같은 파티션에 쌓이므로 이벤트 순서가 보장됩니다.</p>
<blockquote>
<p><strong>주의</strong>: 파티션 수가 변경되면 같은 키라도 다른 파티션으로 갈 수 있습니다. <code>murmur2(key) % 3</code>과 <code>murmur2(key) % 5</code>는 다른 결과를 냅니다. 키 기반 순서 보장이 중요하다면 파티션 수를 처음에 잘 정해야 합니다.</p>
</blockquote>
<h3 id="heading-7ykk6rcaioyxhuuklcdqsr3smra6ioykpo2lso2cpcdtjizti7dshztrhig">키가 없는 경우: 스티키 파티셔너</h3>
<p>키가 없는 메시지는 어떻게 분배될까요? 과거에는 <strong>라운드 로빈</strong> 방식으로 한 건씩 돌아가며 보냈지만, 이 방식은 작은 배치를 많이 만들어 비효율적이었습니다.</p>
<p>현재 카프카(3.3+)는 <strong>스티키 파티셔너(Sticky Partitioner)</strong>를 기본으로 사용합니다. 하나의 파티션에 배치가 가득 찰 때까지 메시지를 모은 뒤, 다음 파티션으로 전환합니다.</p>
<div class="hn-table">
<table>
<thead>
<tr>
<td>방식</td><td>동작</td><td>배치 크기</td><td>성능</td></tr>
</thead>
<tbody>
<tr>
<td><strong>라운드 로빈</strong> (과거)</td><td>메시지마다 다른 파티션</td><td>작음</td><td>네트워크 왕복 많음</td></tr>
<tr>
<td><strong>스티키</strong> (현재)</td><td>배치가 찰 때까지 같은 파티션</td><td>큼</td><td>네트워크 왕복 적음</td></tr>
</tbody>
</table>
</div><p>스티키 파티셔너 도입으로 p99 지연 시간이 <strong>1017ms에서 204ms로</strong> 감소한 벤치마크 결과도 있습니다 (KIP-480).</p>
<hr />
<h2 id="heading-segment">Segment: 파티션의 물리적 저장 구조</h2>
<p>지금까지 토픽과 파티션은 논리적인 개념이었습니다. 이제 파티션이 <strong>디스크에 실제로 어떻게 저장되는지</strong> 살펴보겠습니다.</p>
<h3 id="heading-7yym7yuw7iwyid0g65su66cj7yag66as">파티션 = 디렉토리</h3>
<p>각 파티션은 브로커의 디스크에 <strong>하나의 디렉토리</strong>로 존재합니다. 디렉토리 이름은 <code>{토픽명}-{파티션번호}</code> 형식입니다.</p>
<pre><code>/kafka-logs/
├── orders<span class="hljs-number">-0</span>/          ← Topic: orders, <span class="hljs-attr">Partition</span>: <span class="hljs-number">0</span>
│   ├── <span class="hljs-number">00000000000000000000.</span>log
│   ├── <span class="hljs-number">00000000000000000000.</span>index
│   ├── <span class="hljs-number">00000000000000000000.</span>timeindex
│   ├── <span class="hljs-number">00000000000000001007.</span>log
│   ├── <span class="hljs-number">00000000000000001007.</span>index
│   └── <span class="hljs-number">00000000000000001007.</span>timeindex
├── orders<span class="hljs-number">-1</span>/          ← Topic: orders, <span class="hljs-attr">Partition</span>: <span class="hljs-number">1</span>
└── orders<span class="hljs-number">-2</span>/          ← Topic: orders, <span class="hljs-attr">Partition</span>: <span class="hljs-number">2</span>
</code></pre><h3 id="heading-7is46re466i87yq4io2mjoydvcdqtazsoba">세그먼트 파일 구조</h3>
<p>파티션 안의 데이터는 <strong>세그먼트(Segment)</strong>라는 단위로 나뉩니다. 하나의 세그먼트는 세 개의 파일로 구성됩니다.</p>
<div class="hn-table">
<table>
<thead>
<tr>
<td>파일</td><td>확장자</td><td>역할</td></tr>
</thead>
<tbody>
<tr>
<td><strong>로그 파일</strong></td><td><code>.log</code></td><td>실제 메시지 데이터가 저장되는 파일</td></tr>
<tr>
<td><strong>오프셋 인덱스</strong></td><td><code>.index</code></td><td>오프셋 → 로그 파일 내 바이트 위치 매핑</td></tr>
<tr>
<td><strong>타임스탬프 인덱스</strong></td><td><code>.timeindex</code></td><td>타임스탬프 → 오프셋 매핑</td></tr>
</tbody>
</table>
</div><p>파일 이름은 해당 세그먼트의 <strong>첫 번째 메시지 오프셋</strong>을 20자리로 표현한 것입니다. <code>00000000000000001007.log</code>는 오프셋 1007부터 시작하는 세그먼트입니다.</p>
<h3 id="heading-7zmc7isxioyeuoq3uouovo2kuoyzgcdshljqt7jrqlztirgg66gk66eb">활성 세그먼트와 세그먼트 롤링</h3>
<p>파티션에서 <strong>현재 쓰기가 진행 중인 세그먼트</strong>를 활성 세그먼트(Active Segment)라고 합니다. 활성 세그먼트는 파티션당 항상 <strong>하나만</strong> 존재합니다.</p>
<pre><code class="lang-mermaid">flowchart LR
    subgraph Partition 0
        S1["Segment 0 (closed)"]
        S2["Segment 1007 (closed)"]
        S3["Segment 2014 (active)"]
    end

    S1 --&gt; S2 --&gt; S3

    P["Producer"] --&gt;|"append"| S3

    R["Retention Policy"] -.-&gt;|"delete/compact"| S1
    R -.-&gt;|"delete/compact"| S2
    R -.-&gt;|"NOT applied"| S3
</code></pre>
<p>활성 세그먼트가 일정 조건에 도달하면, 해당 세그먼트를 닫고 새로운 세그먼트를 생성합니다. 이것을 <strong>세그먼트 롤링(rolling)</strong>이라 합니다.</p>
<div class="hn-table">
<table>
<thead>
<tr>
<td>조건</td><td>설정</td><td>기본값</td></tr>
</thead>
<tbody>
<tr>
<td>크기 초과</td><td><code>segment.bytes</code></td><td>1GB</td></tr>
<tr>
<td>시간 초과</td><td><code>segment.ms</code></td><td>7일</td></tr>
</tbody>
</table>
</div><p>중요한 점은, <strong>보존 정책은 닫힌 세그먼트에만 적용</strong>된다는 것입니다. 활성 세그먼트는 삭제되거나 컴팩션되지 않습니다. 따라서 실제 데이터 보존 기간은 <code>retention.ms</code>보다 최대 한 세그먼트 기간만큼 길어질 수 있습니다.</p>
<h3 id="heading-7j24642x7iqk66gciou5ooultoqyjcdssl7qula">인덱스로 빠르게 찾기</h3>
<p>컨슈머가 "오프셋 1500번 메시지를 달라"고 요청하면, 카프카는 매번 로그 파일 전체를 스캔하지 않습니다.</p>
<ol>
<li><code>.index</code> 파일에서 오프셋 1500에 가장 가까운 인덱스 항목을 <strong>이진 탐색</strong>합니다</li>
<li>해당 바이트 위치로 <code>.log</code> 파일을 열어 약간만 <strong>순방향 스캔</strong>합니다</li>
</ol>
<p><code>.index</code> 파일은 모든 오프셋을 기록하지 않고, 기본적으로 <strong>4KB마다 하나의 항목</strong>을 기록하는 희소 인덱스(sparse index)입니다. 전체 인덱스를 유지하는 것보다 파일 크기와 메모리 사용을 크게 줄이면서도 충분히 빠른 검색이 가능합니다.</p>
<hr />
<h2 id="heading-offset">Offset: 메시지의 위치 추적</h2>
<p><strong>오프셋(Offset)</strong>은 파티션 내에서 각 메시지에 부여되는 <strong>순차적 번호</strong>입니다. 1편에서 "책갈피"에 비유했던 개념입니다.</p>
<h3 id="heading-7jik7zse7iwl7j2yioyiheulma">오프셋의 종류</h3>
<p>하나의 파티션에는 여러 종류의 오프셋이 존재합니다.</p>
<pre><code class="lang-mermaid">flowchart LR
    subgraph Partition
        M0["0"]
        M1["1"]
        M2["2"]
        M3["3"]
        M4["4"]
        M5["5"]
        M6["6"]
        M7["7"]
    end

    M0 --&gt; M1 --&gt; M2 --&gt; M3 --&gt; M4 --&gt; M5 --&gt; M6 --&gt; M7

    LSO["Log-Start Offset = 0"]
    CO["Committed Offset = 3"]
    CP["Consumer Position = 5"]
    HW["High Watermark = 6"]
    LEO["Log-End Offset = 8"]

    LSO -.-&gt; M0
    CO -.-&gt; M3
    CP -.-&gt; M5
    HW -.-&gt; M6
    LEO -.-&gt;|"next write"| M7
</code></pre>
<div class="hn-table">
<table>
<thead>
<tr>
<td>오프셋</td><td>의미</td></tr>
</thead>
<tbody>
<tr>
<td><strong>Log-Start Offset</strong></td><td>파티션에서 읽을 수 있는 <strong>가장 오래된</strong> 오프셋. 보존 정책에 의해 삭제되면 앞으로 이동합니다</td></tr>
<tr>
<td><strong>Committed Offset</strong></td><td>컨슈머가 "여기까지 처리했다"고 <strong>저장한</strong> 오프셋. 재시작 시 이 지점부터 다시 읽습니다</td></tr>
<tr>
<td><strong>Consumer Position</strong></td><td>컨슈머가 <strong>현재 읽고 있는</strong> 오프셋. <code>poll()</code> 호출마다 앞으로 이동합니다</td></tr>
<tr>
<td><strong>High Watermark (HW)</strong></td><td>모든 ISR 복제본에 <strong>복제 완료된</strong> 가장 높은 오프셋. 컨슈머는 여기까지만 읽을 수 있습니다</td></tr>
<tr>
<td><strong>Log-End Offset (LEO)</strong></td><td>파티션에 <strong>다음으로 기록될</strong> 오프셋. 리더에 가장 마지막으로 쓰인 메시지의 다음 위치입니다</td></tr>
</tbody>
</table>
</div><p>이 중 <strong>Committed Offset</strong>과 <strong>Consumer Position</strong>이 컨슈머 입장에서 가장 중요합니다.</p>
<h3 id="heading-committed-offset-consumer-position">Committed Offset과 Consumer Position의 차이</h3>
<p>두 개념의 차이가 중요한 이유는 <strong>장애 시 재처리 범위</strong>를 결정하기 때문입니다.</p>
<pre><code class="lang-mermaid">flowchart LR
    subgraph Partition
        M0["0"]
        M1["1"]
        M2["2"]
        M3["3"]
        M4["4"]
    end

    M0 --&gt; M1 --&gt; M2 --&gt; M3 --&gt; M4

    CO["Committed = 2"] -.-&gt; M2
    CP["Position = 4"] -.-&gt; M4

    subgraph Risk Zone
        M2
        M3
    end
</code></pre>
<ul>
<li><strong>Consumer Position</strong>: <code>poll()</code>로 메시지를 가져올 때마다 자동으로 앞으로 이동합니다</li>
<li><strong>Committed Offset</strong>: 명시적으로 커밋해야 이동합니다</li>
</ul>
<p>컨슈머가 오프셋 4까지 읽었지만 2까지만 커밋한 상태에서 장애가 발생하면, 재시작 시 <strong>오프셋 2부터</strong> 다시 읽기 시작합니다. 오프셋 2, 3의 메시지는 중복 처리될 수 있습니다. 이 간격이 바로 "위험 구간"입니다.</p>
<h3 id="heading-7jik7zse7iwl7j2aioywtouuloyxkcdsoidsnqxrkjjriptqsia">오프셋은 어디에 저장되는가?</h3>
<p>컨슈머가 커밋한 오프셋은 <code>__consumer_offsets</code>라는 카프카 <strong>내부 토픽</strong>에 저장됩니다.</p>
<div class="hn-table">
<table>
<thead>
<tr>
<td>설정</td><td>기본값</td></tr>
</thead>
<tbody>
<tr>
<td>파티션 수</td><td>50</td></tr>
<tr>
<td>복제 계수</td><td>3</td></tr>
<tr>
<td>정리 정책</td><td>로그 컴팩션</td></tr>
</tbody>
</table>
</div><p>이 토픽의 키는 <code>(컨슈머 그룹, 토픽, 파티션)</code> 조합이고, 값은 커밋된 오프셋입니다. 로그 컴팩션이 적용되어 각 키의 <strong>최신 값만</strong> 유지됩니다.</p>
<p>컨슈머 그룹의 <strong>코디네이터 브로커</strong>는 이 값을 메모리에 캐시하여 빠르게 응답합니다. 코디네이터는 <code>hash(group.id) % 50</code>으로 결정되는 <code>__consumer_offsets</code> 파티션의 리더 브로커입니다.</p>
<h3 id="heading-7jik7zse7iwlioy7pouwiydrsknsi50">오프셋 커밋 방식</h3>
<p>오프셋을 커밋하는 방법은 <strong>자동 커밋</strong>과 <strong>수동 커밋</strong> 두 가지입니다.</p>
<p><strong>자동 커밋</strong></p>
<div class="hn-table">
<table>
<thead>
<tr>
<td>설정</td><td>기본값</td></tr>
</thead>
<tbody>
<tr>
<td><code>enable.auto.commit</code></td><td><code>true</code></td></tr>
<tr>
<td><code>auto.commit.interval.ms</code></td><td>5000ms (5초)</td></tr>
</tbody>
</table>
</div><p>기본적으로 카프카 컨슈머는 5초마다 자동으로 오프셋을 커밋합니다. 편리하지만, 메시지를 처리하는 도중 장애가 나면 <strong>처리하지 못한 메시지의 오프셋까지 커밋</strong>되어 메시지를 놓칠 수 있습니다. 반대로 커밋 직전에 장애가 나면 이미 처리한 메시지를 <strong>중복 처리</strong>할 수 있습니다.</p>
<p><strong>수동 커밋</strong></p>
<pre><code class="lang-java"><span class="hljs-comment">// 동기 커밋 — 커밋 완료를 기다림</span>
consumer.commitSync();

<span class="hljs-comment">// 비동기 커밋 — 커밋 완료를 기다리지 않음</span>
consumer.commitAsync();
</code></pre>
<ul>
<li><code>commitSync()</code>: 커밋이 완료될 때까지 <strong>블로킹</strong>합니다. 실패하면 자동 재시도합니다</li>
<li><code>commitAsync()</code>: 커밋 요청을 보내고 <strong>즉시 반환</strong>합니다. 실패해도 재시도하지 않습니다 (순서 역전 방지)</li>
</ul>
<p>정확한 오프셋 관리가 필요한 시스템에서는 자동 커밋을 끄고 수동 커밋을 사용합니다.</p>
<h3 id="heading-7jik7zse7iwlioumroyfiydsojxssyu">오프셋 리셋 정책</h3>
<p>컨슈머가 처음 시작하거나, 커밋된 오프셋이 보존 정책에 의해 삭제된 경우에는 읽기 시작할 위치가 없습니다. 이때 <code>auto.offset.reset</code> 설정이 적용됩니다.</p>
<div class="hn-table">
<table>
<thead>
<tr>
<td>값</td><td>동작</td></tr>
</thead>
<tbody>
<tr>
<td><code>latest</code> (기본값)</td><td>파티션의 <strong>끝부터</strong> 읽기 시작 (새로 들어오는 메시지만)</td></tr>
<tr>
<td><code>earliest</code></td><td>파티션의 <strong>처음부터</strong> 읽기 시작 (모든 보존 메시지)</td></tr>
<tr>
<td><code>none</code></td><td>커밋된 오프셋이 없으면 <strong>예외 발생</strong></td></tr>
</tbody>
</table>
</div><p>이 설정은 유효한 커밋 오프셋이 <strong>없을 때만</strong> 적용됩니다. 한번 오프셋이 커밋되면 이후에는 이 설정과 무관하게 커밋 지점부터 읽습니다.</p>
<hr />
<h2 id="heading-7kce7lk0ioq1royhscdtlzzriijsl5ag67o06riw">전체 구조 한눈에 보기</h2>
<p>토픽, 파티션, 세그먼트, 오프셋의 관계를 하나의 그림으로 정리하면 다음과 같습니다.</p>
<pre><code class="lang-mermaid">flowchart TB
    subgraph Topic: orders
        subgraph Broker 1
            subgraph Partition 0
                S0_0["Segment 0 (closed)"]
                S0_1["Segment 1007 (active)"]
            end
        end
        subgraph Broker 2
            subgraph Partition 1
                S1_0["Segment 0 (closed)"]
                S1_1["Segment 985 (active)"]
            end
        end
        subgraph Broker 3
            subgraph Partition 2
                S2_0["Segment 0 (active)"]
            end
        end
    end

    P["Producer (key=user-1)"] --&gt;|"hash % 3 = 0"| S0_1
    C["Consumer"] --&gt;|"offset=1010"| S0_1
</code></pre>
<div class="hn-table">
<table>
<thead>
<tr>
<td>개념</td><td>역할</td><td>비유</td></tr>
</thead>
<tbody>
<tr>
<td><strong>Topic</strong></td><td>메시지의 논리적 분류</td><td>도서관의 서가</td></tr>
<tr>
<td><strong>Partition</strong></td><td>토픽의 물리적 분할 단위, 병렬 처리의 기본 단위</td><td>서가 안의 선반</td></tr>
<tr>
<td><strong>Segment</strong></td><td>파티션의 디스크 저장 단위</td><td>선반 위의 파일 바인더</td></tr>
<tr>
<td><strong>Offset</strong></td><td>파티션 내 메시지의 순차 번호</td><td>바인더 안의 페이지 번호</td></tr>
</tbody>
</table>
</div><hr />
<h2 id="heading-7kcv66as">정리</h2>
<p>이번 글에서는 카프카의 데이터 저장 구조를 살펴보았습니다.</p>
<ul>
<li><strong>토픽</strong>은 메시지의 논리적 분류이며, 본질은 추가 전용 로그입니다</li>
<li><strong>파티션</strong>은 토픽을 물리적으로 나누어 병렬 처리를 가능하게 합니다</li>
<li><strong>세그먼트</strong>는 파티션이 디스크에 저장되는 실제 파일 단위입니다</li>
<li><strong>오프셋</strong>은 파티션 내 메시지의 순차 번호이며, 컨슈머는 이를 커밋하여 읽기 진행 상태를 관리합니다</li>
</ul>
<p>하지만 아직 답하지 못한 질문들이 있습니다.</p>
<ul>
<li>카프카는 어떻게 <strong>초당 수백만 건의 메시지</strong>를 처리할 수 있을까?</li>
<li>순차적 디스크 I/O, Zero-Copy, 배치 처리 — 이 설계 결정들은 <strong>어떻게 연결</strong>될까?</li>
</ul>
<p>이 질문들에 대한 답은 다음 편인 <strong>"카프카의 핵심 기능과 설계 철학"</strong>에서 이어집니다.</p>
<hr />
<h2 id="heading-qampa">부록: Q&amp;A</h2>
<h3 id="heading-q1">Q1. 파티션 수는 어떻게 정하는가?</h3>
<details>
<summary>답변 보기</summary>

파티션 수를 결정하는 일반적인 공식은 다음과 같습니다.

<code>partitions = max(T/P, T/C)</code>

- T = 목표 처리량
- P = 단일 파티션의 프로듀서 처리량
- C = 단일 파티션의 컨슈머 처리량

예를 들어 목표 처리량이 100MB/s이고, 프로듀서는 파티션당 50MB/s, 컨슈머는 파티션당 25MB/s를 처리할 수 있다면:

<code>partitions = max(100/50, 100/25) = max(2, 4) = 4</code>

최소 4개의 파티션이 필요합니다. 실무에서의 가이드라인은 다음과 같습니다.

- 브로커당 <strong>100~200개</strong> 파티션이 보수적인 기준 (지연 민감한 워크로드 기준)
- 브로커당 <strong>최대 4,000개</strong>까지 가능 (Confluent 권장 상한)
- KRaft 기반 클러스터는 <strong>수백만 개</strong> 파티션까지 확장 가능 (ZooKeeper 기반은 약 20만 개가 실질적 한계)

중요한 점은 <strong>파티션 수를 늘리는 것은 쉽지만 줄이는 것은 불가능</strong>하다는 것입니다. 파티션 수를 줄이려면 토픽을 삭제하고 다시 만들어야 합니다. 또한 파티션 수가 변경되면 키 기반 분배가 깨집니다. 처음에 적절한 수를 정하되, 조금 넉넉하게 잡는 것이 좋습니다.

</details>

<h3 id="heading-q2">Q2. 파티션 수를 나중에 변경하면 어떻게 되는가?</h3>
<details>
<summary>답변 보기</summary>

파티션 수를 <strong>늘리는 것</strong>은 가능합니다. 하지만 두 가지 영향이 있습니다.

<strong>1. 키 기반 라우팅이 깨진다</strong>

파티션이 3개에서 5개로 늘어나면, <code>murmur2(key) % 3</code>과 <code>murmur2(key) % 5</code>의 결과가 달라집니다. 기존에 같은 파티션으로 가던 키가 다른 파티션으로 분산됩니다. 이는 <strong>해당 키의 메시지 순서가 보장되지 않는다</strong>는 의미입니다.

<strong>2. 기존 데이터는 이동하지 않는다</strong>

파티션을 늘려도 기존 파티션에 있는 데이터는 그대로 남습니다. 새로운 메시지만 새로운 분배 규칙을 따릅니다. 따라서 일정 기간 동안 같은 키의 데이터가 여러 파티션에 걸쳐 존재할 수 있습니다.

파티션 수를 <strong>줄이는 것</strong>은 지원되지 않습니다. 토픽을 삭제하고 새로 만드는 것이 유일한 방법입니다.

</details>

<h3 id="heading-q3-high-watermark">Q3. High Watermark는 왜 필요한가?</h3>
<details>
<summary>답변 보기</summary>

High Watermark(HW)는 <strong>모든 ISR(In-Sync Replica)에 복제가 완료된 오프셋</strong>입니다. 컨슈머는 HW까지만 읽을 수 있습니다.

이 제한이 없다면 어떤 일이 벌어질까요?

1. 리더 브로커에 오프셋 100번 메시지가 기록됩니다
2. 아직 팔로워에 복제되지 않았습니다
3. 컨슈머가 오프셋 100번을 읽습니다
4. 리더가 죽습니다
5. 새 리더(팔로워)에는 100번이 없습니다

컨슈머는 "존재하지 않는 메시지"를 읽은 것이 됩니다. 이를 <strong>팬텀 리드(phantom read)</strong>라고 합니다.

HW 덕분에 컨슈머는 <strong>모든 복제본에 안전하게 저장된 메시지만</strong> 읽을 수 있습니다. HW와 LEO 사이의 메시지는 리더에만 존재하며, 복제가 완료되어 HW가 올라가면 비로소 컨슈머가 읽을 수 있게 됩니다.

복제와 ISR에 대한 자세한 내용은 6편에서 다루겠습니다.

</details>

<h3 id="heading-q4">Q4. 로그 컴팩션에서 메시지를 삭제하려면?</h3>
<details>
<summary>답변 보기</summary>

로그 컴팩션이 적용된 토픽에서 특정 키의 데이터를 완전히 삭제하려면, <strong>톰스톤(tombstone) 레코드</strong>를 보냅니다. 톰스톤은 키는 있지만 값이 <code>null</code>인 메시지입니다.

<code>java
producer.send(new ProducerRecord&lt;&gt;("topic", "user-123", null));</code>

톰스톤의 동작 순서는 다음과 같습니다.

1. 톰스톤이 기록되면, 컴팩션 시 해당 키의 이전 메시지가 모두 삭제됩니다
2. 톰스톤 자체는 <code>delete.retention.ms</code> (기본 24시간) 동안 유지됩니다
3. 이 기간이 지나면 톰스톤도 삭제됩니다

톰스톤 보존 기간이 필요한 이유는, 이 시간 동안 뒤처진 컨슈머가 해당 키가 삭제되었다는 사실을 인식할 수 있도록 하기 위해서입니다.

</details>

<h3 id="heading-q5">Q5. 자동 커밋은 언제 위험한가?</h3>
<details>
<summary>답변 보기</summary>

자동 커밋은 다음과 같은 시나리오에서 문제를 일으킬 수 있습니다.

<strong>시나리오 1: 메시지 유실 (at-most-once)</strong>

1. <code>poll()</code>로 오프셋 10~20번 메시지를 가져옵니다
2. 5초 뒤 자동 커밋이 오프셋 21을 커밋합니다
3. 아직 15~20번을 처리하지 못한 상태에서 애플리케이션이 죽습니다
4. 재시작 시 오프셋 21부터 읽으므로, <strong>15~20번은 영영 처리되지 않습니다</strong>

<strong>시나리오 2: 메시지 중복 (at-least-once)</strong>

1. <code>poll()</code>로 오프셋 10~20번 메시지를 가져옵니다
2. 모두 처리했지만, 자동 커밋 주기(5초)가 아직 도래하지 않았습니다
3. 애플리케이션이 죽습니다
4. 재시작 시 마지막 커밋 지점(오프셋 10)부터 다시 읽으므로, <strong>10~20번이 중복 처리됩니다</strong>

메시지 유실이 치명적인 시스템(결제, 주문 등)에서는 수동 커밋을 사용하여 <strong>처리 완료 후 커밋</strong>하는 패턴이 안전합니다.

</details>

<h3 id="heading-q6-consumeroffsets">Q6. __consumer_offsets 토픽이 손상되면?</h3>
<details>
<summary>답변 보기</summary>

<code>__consumer_offsets</code>는 복제 계수 3으로 운영되므로, 브로커 1대 장애로는 손상되지 않습니다.

하지만 만약 이 토픽의 데이터가 유실되면:

- 모든 컨슈머 그룹의 커밋 오프셋이 사라집니다
- 컨슈머 재시작 시 <code>auto.offset.reset</code> 설정에 따라 동작합니다
- <code>latest</code>로 설정되어 있으면 <strong>유실 기간의 메시지를 건너뜁니다</strong>
- <code>earliest</code>로 설정되어 있으면 <strong>처음부터 다시 읽으므로 대량 중복이 발생합니다</strong>

이 때문에 프로덕션에서는 <code>__consumer_offsets</code>의 복제 계수를 3 이상으로 유지하고, 브로커 장애를 신속히 복구하는 것이 중요합니다.

</details>

<hr />
<p><em>이 글은 <a target="_blank" href="https://kafka.apache.org/41/documentation/">Apache Kafka 4.1 공식 문서</a>를 기반으로 작성되었습니다.</em></p>
]]></content:encoded></item><item><title><![CDATA[카프카 입문 시리즈 1편: 카프카의 구성 요소]]></title><description><![CDATA[이 글은 Apache Kafka를 처음 접하는 분들을 위한 입문 시리즈의 첫 번째 글입니다. 카프카를 구성하는 핵심 요소들을 하나씩 살펴보며, 전체 구조를 머릿속에 그려보겠습니다.

카프카란?
Apache Kafka는 이벤트 스트리밍 플랫폼입니다. 단순한 메시지 큐가 아니라, 이벤트를 **발행(publish)하고, 저장(store)하고, 처리(process]]></description><link>https://blog.hyunjun.org/1</link><guid isPermaLink="true">https://blog.hyunjun.org/1</guid><category><![CDATA[kafka]]></category><category><![CDATA[messaging]]></category><dc:creator><![CDATA[조현준]]></dc:creator><pubDate>Wed, 18 Mar 2026 15:40:19 GMT</pubDate><content:encoded><![CDATA[<blockquote>
<p>이 글은 Apache Kafka를 처음 접하는 분들을 위한 입문 시리즈의 첫 번째 글입니다. 카프카를 구성하는 핵심 요소들을 하나씩 살펴보며, 전체 구조를 머릿속에 그려보겠습니다.</p>
</blockquote>
<h2>카프카란?</h2>
<p>Apache Kafka는 <strong>이벤트 스트리밍 플랫폼</strong>입니다. 단순한 메시지 큐가 아니라, 이벤트를 **발행(publish)하고, 저장(store)하고, 처리(process)**할 수 있는 분산 시스템입니다.</p>
<p>"쇼핑몰에서 사용자가 주문 버튼을 눌렀다"는 이벤트가 발생하면, 이 이벤트를 결제 시스템, 재고 시스템, 알림 시스템이 각각 받아서 처리해야 합니다. 카프카는 이런 시스템 간의 데이터 흐름을 연결하는 <strong>중앙 허브</strong> 역할을 합니다.</p>
<pre><code class="language-mermaid">flowchart LR
    subgraph Producers
        A[Order Service]
        B[Payment Service]
        C[User Service]
    end

    K[Apache Kafka]

    subgraph Consumers
        D[Notification Service]
        E[Analytics Service]
        F[Inventory Service]
    end

    A --&gt; K
    B --&gt; K
    C --&gt; K
    K --&gt; D
    K --&gt; E
    K --&gt; F
</code></pre>
<p>이제 카프카를 구성하는 핵심 요소들을 하나씩 살펴보겠습니다.</p>
<hr />
<h2>Broker: 카프카의 서버</h2>
<p>**브로커(Broker)**는 카프카 클러스터에서 **저장소 계층(storage layer)**을 구성하는 서버입니다. 데이터를 받아서 저장하고, 요청이 오면 데이터를 내려주는 역할을 합니다.</p>
<p>브로커의 핵심 역할은 네 가지입니다.</p>
<ul>
<li><p><strong>메시지 수신</strong>: Producer가 보낸 메시지를 받아 디스크에 저장합니다</p>
</li>
<li><p><strong>메시지 제공</strong>: Consumer의 요청에 따라 저장된 메시지를 전달합니다</p>
</li>
<li><p><strong>데이터 복제</strong>: 다른 브로커와 데이터를 복제하여 장애에 대비합니다</p>
</li>
<li><p><strong>메타데이터 제공</strong>: 클라이언트에게 파티션 리더 위치 등 클러스터 상태 정보를 알려줍니다</p>
</li>
</ul>
<p>하나의 브로커가 하나의 카프카 서버라고 이해하면 됩니다. 실제 운영 환경에서는 여러 대의 브로커를 묶어서 사용하는데, 이것이 바로 **클러스터(Cluster)**입니다.</p>
<hr />
<h2>Cluster: 브로커의 집합</h2>
<p>**클러스터(Cluster)**는 여러 브로커가 모여 하나의 카프카 시스템을 구성한 것입니다.</p>
<pre><code class="language-mermaid">flowchart TB
    subgraph Kafka Cluster
        B1[Broker 1]
        B2[Broker 2]
        B3[Broker 3]
    end

    P[Producer] --&gt; B1
    P --&gt; B2
    B1 &lt;--&gt; B2
    B2 &lt;--&gt; B3
    B1 &lt;--&gt; B3
    B1 --&gt; C[Consumer]
    B3 --&gt; C
</code></pre>
<p>왜 브로커를 여러 대 사용할까요?</p>
<ul>
<li><p><strong>고가용성</strong>: 브로커 한 대가 죽어도 다른 브로커가 데이터를 제공합니다</p>
</li>
<li><p><strong>확장성</strong>: 데이터가 많아지면 브로커를 추가하여 부하를 분산합니다</p>
</li>
<li><p><strong>데이터 안전성</strong>: 데이터를 여러 브로커에 복제하여 유실을 방지합니다</p>
</li>
</ul>
<p>운영 환경에서는 일반적으로 <strong>복제 계수(replication factor)를 3</strong>으로 설정합니다. 데이터 사본을 3개 유지한다는 의미이므로, 이를 위해 <strong>최소 3대</strong>의 브로커가 필요합니다. 이렇게 하면 1대가 장애가 나더라도 나머지 2대가 정상 동작을 유지할 수 있습니다.</p>
<hr />
<h2>Producer: 메시지를 보내는 쪽</h2>
<p>**프로듀서(Producer)**는 카프카에 메시지를 발행하는 클라이언트 애플리케이션입니다.</p>
<p>프로듀서의 동작은 직관적입니다.</p>
<ol>
<li><p>보내고 싶은 **토픽(Topic)**을 지정합니다</p>
</li>
<li><p>메시지를 구성합니다 (키, 값, 타임스탬프)</p>
</li>
<li><p>카프카 브로커로 전송합니다</p>
</li>
</ol>
<pre><code class="language-mermaid">flowchart LR
    P[Producer] --&gt;|1. Send message| B[Broker]
    B --&gt;|2. Write to disk| D[(Log)]
    B --&gt;|3. Acknowledge| P
</code></pre>
<p>프로듀서는 브로커에 <strong>메타데이터를 조회</strong>하여 어떤 브로커가 어떤 파티션의 리더인지 파악하고, 해당 브로커에 직접 메시지를 전송합니다. 중간 라우터 같은 것이 따로 필요 없습니다.</p>
<p>여기서 "토픽"이라는 용어가 나왔습니다. 토픽은 메시지를 분류하는 논리적 단위인데, 자세한 내용은 2편에서 다루겠습니다. 지금은 "메시지가 저장되는 카테고리"라고 이해하면 충분합니다.</p>
<hr />
<h2>Consumer: 메시지를 받는 쪽</h2>
<p>**컨슈머(Consumer)**는 카프카에서 메시지를 읽어가는 클라이언트 애플리케이션입니다.</p>
<p>카프카에서 데이터의 흐름은 방향에 따라 다릅니다. 프로듀서는 브로커에게 메시지를 <strong>밀어넣고(Push)</strong>, 컨슈머는 브로커에서 메시지를 <strong>당겨옵니다(Pull)</strong>. 특히 컨슈머의 Pull 방식은 카프카의 중요한 설계 결정입니다.</p>
<h3>왜 Pull 방식인가?</h3>
<p>Push 방식은 브로커가 컨슈머에게 메시지를 보내는 것이고, Pull 방식은 컨슈머가 브로커에게 메시지를 달라고 요청하는 것입니다.</p>
<table>
<thead>
<tr>
<th>구분</th>
<th>Push 방식</th>
<th>Pull 방식</th>
</tr>
</thead>
<tbody><tr>
<td>속도 제어</td>
<td>브로커가 결정</td>
<td>컨슈머가 결정</td>
</tr>
<tr>
<td>느린 컨슈머</td>
<td>과부하 위험</td>
<td>자기 속도로 처리</td>
</tr>
<tr>
<td>배치 처리</td>
<td>어려움</td>
<td>쌓인 메시지를 한번에 가져오기 가능</td>
</tr>
</tbody></table>
<p>Pull 방식 덕분에 컨슈머는 자신의 처리 능력에 맞춰 메시지를 가져올 수 있습니다. 빠른 컨슈머는 빠르게, 느린 컨슈머는 느리게 — 각자의 속도로 동작합니다.</p>
<h3>오프셋(Offset)</h3>
<p>컨슈머는 **오프셋(offset)**이라는 숫자로 "다음에 읽을 메시지의 위치"를 관리합니다. 책갈피와 같은 개념이라고 보면 됩니다.</p>
<pre><code class="language-mermaid">flowchart LR
    subgraph Partition
        M0["msg 0"]
        M1["msg 1"]
        M2["msg 2"]
        M3["msg 3"]
        M4["msg 4"]
    end

    M0 --&gt; M1 --&gt; M2 --&gt; M3 --&gt; M4

    CO["Consumer Offset = 3"]
    CO -.-&gt;|"next read"| M3
</code></pre>
<p>오프셋 덕분에 컨슈머가 중간에 죽었다가 다시 살아나더라도, 마지막으로 읽었던 지점부터 이어서 읽을 수 있습니다. 과거의 오프셋으로 되감아 메시지를 재처리하는 것도 가능합니다. 오프셋에 대해서도 2편에서 더 자세히 다루겠습니다.</p>
<hr />
<h2>Controller: 클러스터의 관리자</h2>
<p>**컨트롤러(Controller)**는 카프카 클러스터 전체를 관리하는 역할을 합니다. 비유하자면 브로커들을 지휘하는 <strong>관제탑</strong>입니다.</p>
<p>컨트롤러가 하는 일은 다음과 같습니다.</p>
<ul>
<li><p><strong>파티션 리더 선출</strong>: 어떤 브로커가 어떤 파티션의 리더가 될지 결정합니다</p>
</li>
<li><p><strong>브로커 감시</strong>: 브로커가 정상 동작하는지 모니터링하고, 장애가 발생하면 대응합니다</p>
</li>
<li><p><strong>메타데이터 관리</strong>: 토픽, 파티션, 브로커 정보 등 클러스터의 모든 상태 정보를 관리합니다</p>
</li>
<li><p><strong>설정 관리</strong>: 토픽 설정 변경 등 관리 작업을 처리합니다</p>
</li>
</ul>
<p>카프카 클러스터에는 항상 **하나의 활성 컨트롤러(Active Controller)**가 존재합니다. 이 컨트롤러가 죽으면, 대기 중인 다른 컨트롤러가 즉시 역할을 이어받습니다.</p>
<hr />
<h2>KRaft: 카프카의 두뇌</h2>
<p>여기서 중요한 질문이 나옵니다. <strong>"컨트롤러는 클러스터 상태를 어디에 저장할까?"</strong></p>
<h3>ZooKeeper 시절 (과거)</h3>
<p>과거에는 <strong>ZooKeeper</strong>라는 별도의 분산 시스템이 이 역할을 담당했습니다. 카프카를 운영하려면 ZooKeeper 클러스터를 별도로 구축하고 관리해야 했습니다.</p>
<pre><code class="language-mermaid">flowchart TB
    subgraph Before["Before: Kafka + ZooKeeper"]
        direction TB
        subgraph ZK["ZooKeeper Cluster"]
            Z1[ZK Node 1]
            Z2[ZK Node 2]
            Z3[ZK Node 3]
        end
        subgraph BK["Kafka Cluster"]
            KB1[Broker 1]
            KB2[Broker 2]
            KB3[Broker 3]
        end
        KB1 &lt;--&gt; Z1
        KB2 &lt;--&gt; Z2
        KB3 &lt;--&gt; Z3
    end
</code></pre>
<p>이 구조에는 문제가 있었습니다.</p>
<ul>
<li><p><strong>운영 복잡성</strong>: 카프카와 ZooKeeper, 두 개의 분산 시스템을 동시에 관리해야 합니다</p>
</li>
<li><p><strong>확장 한계</strong>: ZooKeeper에 저장할 수 있는 메타데이터 양에 제한이 있어, 파티션 수에 한계가 있었습니다</p>
</li>
<li><p><strong>장애 복구 지연</strong>: 컨트롤러 장애 시 ZooKeeper에서 상태를 다시 읽어와야 해서 복구에 시간이 걸렸습니다</p>
</li>
</ul>
<h3>KRaft 시대 (현재)</h3>
<p>**KRaft(Kafka Raft)**는 ZooKeeper 의존성을 완전히 제거하고, 카프카 자체적으로 메타데이터를 관리하는 방식입니다. Kafka 4.0부터 KRaft가 유일한 메타데이터 관리 방식이 되었습니다.</p>
<pre><code class="language-mermaid">flowchart TB
    subgraph After["After: Kafka with KRaft"]
        direction TB
        subgraph Controllers["Controller Quorum"]
            C1[Controller 1]
            C2[Controller 2]
            C3[Controller 3]
        end
        subgraph Brokers
            KB1[Broker 1]
            KB2[Broker 2]
            KB3[Broker 3]
        end
        C1 &lt;--&gt; C2
        C2 &lt;--&gt; C3
        C1 &lt;--&gt; C3
        C1 --&gt; KB1
        C2 --&gt; KB2
        C3 --&gt; KB3
    end
</code></pre>
<p>KRaft의 핵심 개념을 살펴보겠습니다.</p>
<h3>컨트롤러 쿼럼 (Controller Quorum)</h3>
<p>KRaft에서는 여러 컨트롤러 노드가 **쿼럼(quorum)**을 구성합니다. 쿼럼이란 "의사결정을 위해 필요한 최소 인원"이라는 뜻입니다.</p>
<ul>
<li><p>3대 구성 → 1대 장애 허용</p>
</li>
<li><p>5대 구성 → 2대 장애 허용</p>
</li>
<li><p>공식: <strong>N대 중 (N/2 + 1)대가 살아 있으면</strong> 정상 동작</p>
</li>
</ul>
<p>이 컨트롤러들은 <strong>Raft 합의 알고리즘</strong>을 사용하여 리더를 선출하고, 메타데이터 변경사항을 동기화합니다.</p>
<h3>메타데이터 관리 방식</h3>
<p>KRaft는 <code>__cluster_metadata</code>라는 내부 토픽에 모든 클러스터 메타데이터를 <strong>이벤트 로그</strong>로 기록합니다.</p>
<pre><code class="language-mermaid">flowchart LR
    subgraph Metadata Log["__cluster_metadata"]
        E1["Topic Created"]
        E2["Partition Assigned"]
        E3["Broker Registered"]
        E4["Leader Changed"]
    end

    E1 --&gt; E2 --&gt; E3 --&gt; E4

    Leader["Active Controller"] --&gt;|"write"| E1
    Follower1["Follower Controller"] --&gt;|"replicate"| E4
    Follower2["Follower Controller"] --&gt;|"replicate"| E4
</code></pre>
<p>이 방식의 장점은 명확합니다.</p>
<ul>
<li><p><strong>빠른 장애 복구</strong>: 새 리더는 이미 모든 메타데이터를 메모리에 가지고 있으므로, 외부 시스템에서 다시 읽어올 필요가 없습니다</p>
</li>
<li><p><strong>운영 단순화</strong>: ZooKeeper 없이 카프카만 관리하면 됩니다</p>
</li>
<li><p><strong>확장성 향상</strong>: 메타데이터 관리에 카프카 자체의 로그 구조를 사용하므로 더 많은 파티션을 지원합니다</p>
</li>
</ul>
<hr />
<h2>전체 구조 한눈에 보기</h2>
<p>지금까지 살펴본 구성 요소들을 하나의 그림으로 정리하면 다음과 같습니다.</p>
<pre><code class="language-mermaid">flowchart TB
    subgraph Clients
        P1[Producer]
        P2[Producer]
        C1[Consumer]
        C2[Consumer]
    end

    subgraph Kafka Cluster
        subgraph Controller Quorum
            CT1["Controller 1 (Leader)"]
            CT2[Controller 2]
            CT3[Controller 3]
        end

        subgraph Brokers
            B1["Broker 1"]
            B2["Broker 2"]
            B3["Broker 3"]
        end

        CT1 &lt;--&gt; CT2
        CT2 &lt;--&gt; CT3
        CT1 &lt;--&gt; CT3

        CT1 -.-&gt;|"metadata"| B1
        CT1 -.-&gt;|"metadata"| B2
        CT1 -.-&gt;|"metadata"| B3
    end

    P1 --&gt;|"send"| B1
    P2 --&gt;|"send"| B2
    B1 --&gt;|"fetch"| C1
    B3 --&gt;|"fetch"| C2
</code></pre>
<table>
<thead>
<tr>
<th>구성 요소</th>
<th>역할</th>
<th>비유</th>
</tr>
</thead>
<tbody><tr>
<td><strong>Broker</strong></td>
<td>메시지를 저장하고 전달하는 서버</td>
<td>우체국</td>
</tr>
<tr>
<td><strong>Cluster</strong></td>
<td>브로커들의 집합</td>
<td>우체국 네트워크</td>
</tr>
<tr>
<td><strong>Producer</strong></td>
<td>메시지를 보내는 클라이언트</td>
<td>편지를 보내는 사람</td>
</tr>
<tr>
<td><strong>Consumer</strong></td>
<td>메시지를 읽는 클라이언트</td>
<td>편지를 받는 사람</td>
</tr>
<tr>
<td><strong>Controller</strong></td>
<td>클러스터 상태를 관리하는 관리자</td>
<td>우체국 관제탑</td>
</tr>
<tr>
<td><strong>KRaft</strong></td>
<td>컨트롤러의 합의 메커니즘</td>
<td>관제탑의 의사결정 규칙</td>
</tr>
</tbody></table>
<hr />
<h2>정리</h2>
<p>이번 글에서는 카프카를 구성하는 핵심 요소들을 살펴보았습니다.</p>
<ul>
<li><p><strong>브로커</strong>가 메시지를 저장하고, 여러 브로커가 모여 <strong>클러스터</strong>를 이룹니다</p>
</li>
<li><p><strong>프로듀서</strong>가 메시지를 보내고, <strong>컨슈머</strong>가 메시지를 가져갑니다</p>
</li>
<li><p><strong>컨트롤러</strong>가 클러스터를 관리하며, <strong>KRaft</strong>로 메타데이터를 자체 관리합니다</p>
</li>
</ul>
<p>하지만 아직 답하지 못한 질문들이 있습니다.</p>
<ul>
<li><p>메시지는 브로커 안에서 <strong>어떤 구조</strong>로 저장될까?</p>
</li>
<li><p><strong>토픽과 파티션</strong>은 정확히 무엇이고, 왜 필요할까?</p>
</li>
<li><p>컨슈머의 <strong>오프셋</strong>은 어떻게 동작할까?</p>
</li>
</ul>
<p>이 질문들에 대한 답은 다음 편인 **"토픽, 파티션, 오프셋"**에서 이어집니다.</p>
<hr />
<h2>부록: Q&amp;A</h2>
<h3>Q1. 컨트롤러는 어떻게 선출되는가?</h3>
<details>
<summary>답변 보기</summary>
<p>KRaft에서는 <strong>Raft 합의 알고리즘</strong>을 사용하여 컨트롤러 리더를 선출합니다.</p>
<p>동작 방식은 다음과 같습니다.</p>
<ol>
<li>클러스터가 시작되면 모든 컨트롤러 노드가 **투표(vote)**를 진행합니다</li>
<li>과반수 이상의 표를 얻은 노드가 **활성 컨트롤러(Active Controller)**가 됩니다</li>
<li>활성 컨트롤러가 장애로 응답하지 않으면, 나머지 컨트롤러들이 새로운 투표를 시작합니다</li>
<li>새 리더는 이미 메타데이터 로그를 복제하고 있었으므로, <strong>즉시 역할을 이어받습니다</strong></li>
</ol>
<p>핵심은 "가장 최신의 메타데이터 로그를 가진 컨트롤러"가 리더로 선출될 수 있다는 점입니다. Raft 알고리즘은 로그가 뒤처진 노드에게는 투표하지 않도록 보장합니다.</p>
</details>

<h3>Q2. 컨슈머와 브로커는 어떤 프로토콜로 통신하는가?</h3>
<details>
<summary>답변 보기</summary>
<p>카프카는 <strong>TCP 기반의 자체 바이너리 프로토콜</strong>을 사용합니다. HTTP나 gRPC가 아닌 카프카만의 전용 프로토콜입니다.</p>
<p>주요 특징은 다음과 같습니다.</p>
<ul>
<li><strong>요청-응답 구조</strong>: 클라이언트가 요청을 보내면 브로커가 응답합니다</li>
<li><strong>순서 보장</strong>: 하나의 TCP 연결에서 요청은 보낸 순서대로 처리됩니다</li>
<li><strong>버전 협상</strong>: 클라이언트와 브로커가 서로 지원하는 프로토콜 버전을 맞춰 통신합니다</li>
</ul>
<p>주요 요청 타입을 살펴보면:</p>
<table>
<thead>
<tr>
<th>요청</th>
<th>역할</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>Produce</code></td>
<td>메시지 발행</td>
</tr>
<tr>
<td><code>Fetch</code></td>
<td>메시지 조회</td>
</tr>
<tr>
<td><code>Metadata</code></td>
<td>클러스터 정보 조회</td>
</tr>
<tr>
<td><code>OffsetCommit</code></td>
<td>오프셋 저장</td>
</tr>
<tr>
<td><code>JoinGroup</code></td>
<td>컨슈머 그룹 참여</td>
</tr>
</tbody>
</table>
<p>자체 바이너리 프로토콜을 사용하는 이유는 <strong>성능과 효율성</strong> 때문입니다. HTTP 같은 텍스트 기반 프로토콜에 비해 오버헤드가 적고, 메시지 배치 전송에 최적화되어 있습니다.</p>
</details>

<h3>Q3. 카프카가 높은 처리량을 가질 수 있는 이유는?</h3>
<details>
<summary>답변 보기</summary>
<p>카프카의 높은 처리량은 하나의 비결이 아니라, <strong>여러 설계 결정이 합쳐진 결과</strong>입니다.</p>
<p><strong>1. 순차적 디스크 I/O (Sequential I/O)</strong></p>
<p>카프카는 데이터를 로그 파일 끝에 순서대로 추가합니다. 디스크의 순차 쓰기 속도는 약 600MB/s로, 랜덤 쓰기(약 100KB/s)보다 수천 배 빠릅니다.</p>
<p><strong>2. OS 페이지 캐시 활용</strong></p>
<p>데이터를 JVM 힙 메모리가 아닌 OS의 페이지 캐시에 저장합니다. GC 오버헤드 없이 수십 GB의 캐시를 활용할 수 있고, 브로커를 재시작해도 캐시가 유지됩니다.</p>
<p><strong>3. Zero-Copy 전송</strong></p>
<p>일반적인 데이터 전송은 디스크 → 커널 → 애플리케이션 → 커널 → 네트워크로 4번 복사가 발생합니다. 카프카는 <code>sendfile</code> 시스템 콜로 <strong>디스크에서 네트워크 카드로 직접 전송</strong>하여 복사 횟수를 최소화합니다.</p>
<p><strong>4. 배치 처리</strong></p>
<p>메시지를 하나씩 보내지 않고, 여러 메시지를 <strong>묶어서(batch)</strong> 한 번에 전송합니다. 네트워크 왕복 횟수를 줄이고, 압축 효율도 높아집니다.</p>
<p><strong>5. 파티션 기반 병렬 처리</strong></p>
<p>토픽을 여러 파티션으로 나누어 <strong>여러 브로커가 동시에</strong> 읽기/쓰기를 처리합니다.</p>
<p>이 요소들이 결합되어 카프카는 초당 수백만 건의 메시지를 처리할 수 있습니다.</p>
</details>

<h3>Q4. 브로커가 죽으면 어떤 일이 벌어지는가?</h3>
<details>
<summary>답변 보기</summary>
<p>브로커 장애 시 카프카는 다음과 같은 순서로 대응합니다.</p>
<p><strong>1. 장애 감지</strong></p>
<p>컨트롤러가 브로커의 하트비트를 모니터링합니다. 일정 시간 응답이 없으면 해당 브로커를 "죽은 것"으로 판단합니다.</p>
<p><strong>2. 리더 재선출</strong></p>
<p>죽은 브로커가 리더로 있던 파티션들에 대해 <strong>새로운 리더를 선출</strong>합니다. 같은 파티션의 데이터를 복제하고 있던 다른 브로커(ISR 목록에 있는 브로커) 중 하나가 새 리더가 됩니다.</p>
<p><strong>3. 클라이언트 리디렉션</strong></p>
<p>프로듀서와 컨슈머는 메타데이터를 갱신하여 새 리더 브로커로 요청을 보냅니다. 이 과정은 자동으로 이루어집니다.</p>
<p><strong>4. 데이터는 안전한가?</strong></p>
<p>복제 계수가 3이라면, 데이터가 3개의 브로커에 복제되어 있으므로 1대가 죽어도 <strong>데이터 유실은 없습니다</strong>. 죽었던 브로커가 복구되면 자동으로 클러스터에 재합류하고, 뒤처진 데이터를 따라잡습니다.</p>
</details>

<h3>Q5. 하나의 서버에서 브로커와 컨트롤러를 동시에 실행할 수 있는가?</h3>
<details>
<summary>답변 보기</summary>
<p><strong>가능합니다.</strong> KRaft에서는 <code>process.roles</code> 설정으로 서버의 역할을 지정합니다.</p>
<ul>
<li><code>process.roles=broker</code> — 브로커 전용</li>
<li><code>process.roles=controller</code> — 컨트롤러 전용</li>
<li><code>process.roles=broker,controller</code> — <strong>결합 모드(Combined Mode)</strong></li>
</ul>
<p>결합 모드는 하나의 프로세스가 브로커와 컨트롤러 역할을 동시에 수행합니다. 서버 수를 줄일 수 있어 <strong>개발 환경이나 소규모 클러스터</strong>에서 유용합니다.</p>
<p>하지만 <strong>프로덕션 환경에서는 역할을 분리하는 것이 권장</strong>됩니다. 브로커의 높은 I/O 부하가 컨트롤러의 메타데이터 처리에 영향을 줄 수 있기 때문입니다.</p>
</details>

<h3>Q6. 카프카는 메시지 순서를 보장하는가?</h3>
<details>
<summary>답변 보기</summary>
<p><strong>파티션 단위로 보장합니다.</strong></p>
<p>하나의 파티션 내에서 메시지는 <strong>발행된 순서 그대로</strong> 저장되고, 같은 순서로 소비됩니다. 하지만 서로 다른 파티션 간에는 순서가 보장되지 않습니다.</p>
<p>예를 들어 주문 이벤트를 처리한다면:</p>
<ul>
<li>같은 사용자의 주문을 <strong>같은 파티션에 넣으면</strong> → 해당 사용자의 이벤트 순서가 보장됩니다</li>
<li>서로 다른 사용자의 주문이 <strong>다른 파티션에 들어가면</strong> → 사용자 간 순서는 보장되지 않습니다</li>
</ul>
<p>이를 위해 프로듀서는 **메시지 키(key)**를 사용합니다. 같은 키를 가진 메시지는 항상 같은 파티션으로 전송되므로, 키가 같은 메시지끼리는 순서가 보장됩니다. 파티션과 키에 대한 자세한 내용은 2편에서 다루겠습니다.</p>
</details>

<hr />
<p><em>이 글은</em> <a href="https://kafka.apache.org/42/documentation/"><em>Apache Kafka 4.2 공식 문서</em></a><em>를 기반으로 작성되었습니다.</em></p>
]]></content:encoded></item><item><title><![CDATA[Java GC의 진화 — Serial에서 Generational ZGC까지]]></title><description><![CDATA[Java가 약속한 것 중 하나는 "메모리는 내가 관리할게"였다.
C/C++ 개발자들이 malloc과 free로 메모리와 씨름하던 시절, Java는 Garbage Collector(GC)라는 자동 메모리 관리자를 들고 나왔다. 개발자는 객체를 만들기만 하면 되고, 치우는 건 GC가 알아서 한다.
하지만 "알아서"라는 말에는 대가가 있었다. GC가 동작하는 동안 애플리케이션이 멈추는 것이다. 이 멈춤을 Stop-The-World(STW) 일시 정지...]]></description><link>https://blog.hyunjun.org/java-gc-serial-generational-zgc</link><guid isPermaLink="true">https://blog.hyunjun.org/java-gc-serial-generational-zgc</guid><category><![CDATA[Garbage Collection]]></category><category><![CDATA[Java]]></category><category><![CDATA[jvm]]></category><dc:creator><![CDATA[조현준]]></dc:creator><pubDate>Mon, 16 Mar 2026 10:09:41 GMT</pubDate><content:encoded><![CDATA[<p>Java가 약속한 것 중 하나는 <strong>"메모리는 내가 관리할게"</strong>였다.</p>
<p>C/C++ 개발자들이 <code>malloc</code>과 <code>free</code>로 메모리와 씨름하던 시절, Java는 Garbage Collector(GC)라는 자동 메모리 관리자를 들고 나왔다. 개발자는 객체를 만들기만 하면 되고, 치우는 건 GC가 알아서 한다.</p>
<p>하지만 "알아서"라는 말에는 대가가 있었다. GC가 동작하는 동안 애플리케이션이 <strong>멈추는 것</strong>이다. 이 멈춤을 <strong>Stop-The-World(STW)</strong> 일시 정지라고 부른다. Java GC의 역사는 이 일시 정지를 줄이기 위한 끊임없는 도전의 기록이다.</p>
<p>이 글은 Java GC의 진화를 세 시대로 나눠 살펴본다. <strong>G1GC 이전</strong>, <strong>G1GC</strong>, 그리고 <strong>G1GC 이후</strong>. 각 시대의 GC가 어떤 문제를 해결하려 했고, 어떤 한계를 남겼는지를 따라가 보자.</p>
<hr />
<h2 id="heading-1-g1gc">1부. G1GC 이전 — 단순함의 한계</h2>
<h3 id="heading-serial-gc">Serial GC — 모든 것의 시작</h3>
<p>Java 초기부터 존재해 온 가장 단순한 GC다. 이름 그대로, <strong>단일 스레드</strong>로 동작한다.</p>
<pre><code>App Threads:  ──────────┃ STW ┃──────────
GC Thread:              ┃█████┃
</code></pre><p>GC가 시작되면 모든 애플리케이션 스레드가 멈추고, GC 스레드 하나가 힙 전체를 정리한 뒤에야 애플리케이션이 다시 동작한다.</p>
<p>알고리즘은 두 가지를 조합한다:</p>
<ul>
<li><strong>Young Generation</strong>: Mark-Copy — 살아있는 객체를 식별하고 새로운 영역에 복사</li>
<li><strong>Old Generation</strong>: Mark-Sweep-Compact — 살아있는 객체를 식별하고, 죽은 객체를 제거하고, 남은 객체를 압축</li>
</ul>
<p>힙이 작고 CPU가 하나뿐인 환경에서는 이것으로 충분했다. 하지만 서버 환경에서 힙이 커지면서, GC 동안 애플리케이션이 수 초간 멈추는 것은 용납할 수 없었다.</p>
<pre><code>-XX:+UseSerialGC
</code></pre><h3 id="heading-parallel-gc">Parallel GC — 스레드를 늘려 처리량을 높이다</h3>
<p>Serial GC의 해법은 단순했다. <strong>스레드를 더 쓰자.</strong></p>
<pre><code>App Threads:  ──────────┃  STW  ┃──────────
GC Thread <span class="hljs-number">1</span>:            ┃██████ ┃
GC Thread <span class="hljs-number">2</span>:            ┃██████ ┃
GC Thread <span class="hljs-number">3</span>:            ┃██████ ┃
GC Thread <span class="hljs-number">4</span>:            ┃██████ ┃
</code></pre><p>여러 GC 스레드가 동시에 힙을 정리하므로, 같은 힙 크기에서 GC 시간이 크게 단축된다. 이름에 걸맞게 <strong>Throughput Collector</strong>라고도 불린다. 처리량(단위 시간당 처리하는 작업량)을 극대화하는 것이 목표다.</p>
<p><strong>JDK 8까지 서버 환경의 기본 GC</strong>였다. <code>-XX:GCTimeRatio=99</code>로 GC 시간을 전체의 1% 이하로 유지하는 것이 기본 목표였고, <code>-XX:+UseAdaptiveSizePolicy</code>로 힙 크기를 자동 조절하는 기능도 갖추고 있었다.</p>
<p>하지만 근본적인 한계는 그대로였다. <strong>GC 동안 여전히 애플리케이션이 멈춘다.</strong> 스레드를 늘려서 STW 시간을 줄였을 뿐, STW 자체를 없앤 것이 아니다. 힙이 수 GB로 커지면 일시 정지가 수백 밀리초에서 수 초까지 늘어났다.</p>
<pre><code>-XX:+UseParallelGC
</code></pre><h3 id="heading-cms">CMS — 동시 수집의 첫 시도</h3>
<p><strong>Concurrent Mark Sweep(CMS)</strong>는 발상의 전환을 했다. GC 작업의 대부분을 <strong>애플리케이션과 동시에</strong> 수행하자는 것이다.</p>
<pre><code>App Threads:  ─┃STW┃───────────────┃STW┃──
GC Threads:    ┃███┃▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒┃███┃
               Init  Concurrent     Remark
               Mark  Mark/Sweep
</code></pre><p>CMS는 4단계로 동작한다:</p>
<ol>
<li><strong>Initial Mark (STW)</strong> — GC Root에서 직접 참조하는 객체만 마킹. 짧은 일시 정지.</li>
<li><strong>Concurrent Mark</strong> — 애플리케이션과 동시에 실행하며 참조 그래프를 순회. STW 없음.</li>
<li><strong>Remark (STW)</strong> — Concurrent Mark 도중 변경된 참조를 보정. 짧은 일시 정지.</li>
<li><strong>Concurrent Sweep</strong> — 죽은 객체를 제거. STW 없음.</li>
</ol>
<p>핵심은 가장 시간이 오래 걸리는 Mark와 Sweep을 <strong>동시(Concurrent)</strong>에 처리한다는 것이다. STW는 Initial Mark와 Remark에서만 발생하고, 이 두 단계는 상대적으로 짧다.</p>
<p>혁신적이었지만, CMS에는 치명적인 약점이 있었다:</p>
<ul>
<li><strong>메모리 단편화</strong>: Sweep 후 Compact(압축)를 하지 않으므로, 힙에 빈 공간이 흩어진다. 큰 객체를 할당할 연속 공간이 없으면 Full GC가 발생한다.</li>
<li><strong>Concurrent Mode Failure</strong>: GC가 끝나기 전에 Old Generation이 가득 차면, Serial GC로 폴백하여 긴 STW가 발생한다.</li>
<li><strong>복잡한 튜닝</strong>: CMS 전용 옵션만 <strong>72개</strong>. 일반 GC 옵션 50개까지 합치면 120개 이상의 매개변수를 다뤄야 했다.</li>
</ul>
<p>결국 CMS는 <strong>JDK 9에서 deprecated, JDK 14에서 완전 제거</strong>되었다. JEP 363은 제거 사유로 "유지보수를 맡을 기여자가 없다"고 밝혔다. 72개의 튜닝 옵션이 만든 복잡성의 당연한 귀결이었다.</p>
<h3 id="heading-g1gc">G1GC 이전 시대의 교훈</h3>
<p>세 GC를 관통하는 딜레마가 있다. <strong>처리량(Throughput)과 지연 시간(Latency)은 트레이드오프</strong>라는 것이다.</p>
<div class="hn-table">
<table>
<thead>
<tr>
<td>GC</td><td>목표</td><td>STW 방식</td><td>한계</td></tr>
</thead>
<tbody>
<tr>
<td>Serial</td><td>단순함</td><td>전체 STW, 단일 스레드</td><td>대규모 힙에서 긴 일시 정지</td></tr>
<tr>
<td>Parallel</td><td>처리량</td><td>전체 STW, 멀티 스레드</td><td>STW 자체는 제거 불가</td></tr>
<tr>
<td>CMS</td><td>낮은 지연</td><td>부분 STW + 동시 수집</td><td>단편화, 복잡한 튜닝</td></tr>
</tbody>
</table>
</div><p>이 트레이드오프를 근본적으로 해결하려면, 힙을 관리하는 방식 자체를 바꿔야 했다. 그것이 G1GC다.</p>
<hr />
<h2 id="heading-2-g1gc">2부. G1GC — 힙을 쪼개다</h2>
<h3 id="heading-region">Region이라는 발상</h3>
<p>G1GC 이전의 모든 GC는 힙을 <strong>연속된 두 영역</strong>(Young, Old)으로 나눴다. G1GC는 이 구조를 완전히 바꿨다.</p>
<pre><code class="lang-mermaid">flowchart LR
    subgraph Heap["G1GC Heap — Region-based Layout"]
        direction LR
        E1["Eden"] --- E2["Eden"] --- S1["Survivor"]
        S1 --- O1["Old"] --- O2["Old"] --- H1["Humongous"]
        H1 --- E3["Eden"] --- O3["Old"] --- Free1["Free"]
        Free1 --- O4["Old"] --- E4["Eden"] --- Free2["Free"]
    end

    style E1 fill:#90EE90
    style E2 fill:#90EE90
    style E3 fill:#90EE90
    style E4 fill:#90EE90
    style S1 fill:#FFD700
    style O1 fill:#87CEEB
    style O2 fill:#87CEEB
    style O3 fill:#87CEEB
    style O4 fill:#87CEEB
    style H1 fill:#FF6347
    style Free1 fill:#f5f5f5
    style Free2 fill:#f5f5f5
</code></pre>
<p>힙을 <strong>동일한 크기의 Region(1~32MB)</strong>으로 쪼갠다. 각 Region은 Eden, Survivor, Old, Humongous(대형 객체용) 중 하나의 역할을 <strong>동적으로</strong> 맡는다. 고정된 영역 경계가 없다.</p>
<p>이 구조가 혁명적인 이유는, <strong>전체 힙을 수집할 필요가 없어졌기 때문</strong>이다.</p>
<h3 id="heading-garbage-first">Garbage-First — 쓰레기가 많은 곳부터</h3>
<p>G1GC의 이름인 <strong>Garbage-First</strong>는 동작 방식 그 자체다. 모든 Region의 "쓰레기 비율"을 추적하고, <strong>쓰레기가 가장 많은 Region부터 수집</strong>한다.</p>
<pre><code>Garbage ratio per Region:

Region A: ████████░░  <span class="hljs-number">80</span>% garbage  ← collect first
Region B: ██████░░░░  <span class="hljs-number">60</span>% garbage  ← next
Region C: ██░░░░░░░░  <span class="hljs-number">20</span>% garbage  ← later
Region D: █░░░░░░░░░  <span class="hljs-number">10</span>% garbage  ← <span class="hljs-keyword">if</span> time permits
</code></pre><p>핵심은 <strong>예측 가능한 일시 정지 시간</strong>이다. <code>-XX:MaxGCPauseMillis=200</code> (기본값)으로 목표 일시 정지 시간을 설정하면, G1GC는 그 시간 안에 수집할 수 있는 만큼만 Region을 선택한다. 200ms 안에 모든 Region을 수집하지 못해도 괜찮다. 쓰레기가 많은 곳부터 했으니, 제한된 시간 안에 <strong>최대한의 공간을 확보</strong>한 셈이다.</p>
<h3 id="heading-g1gc-1">G1GC의 동작 사이클</h3>
<pre><code>Young GC (STW)
    │
    ▼
Concurrent Mark (Mostly Concurrent)
    │   ├── Initial Mark (STW, piggybacks on Young GC)
    │   ├── Root Region Scan
    │   ├── Concurrent Mark
    │   ├── Remark (STW)
    │   └── Cleanup (STW + Concurrent)
    ▼
Mixed GC (STW)  ← Young + high-garbage Old Regions
</code></pre><p>G1GC는 세 가지 모드로 동작한다:</p>
<ol>
<li><strong>Young GC</strong>: Eden이 가득 차면 발생. 살아남은 객체를 Survivor 또는 Old Region으로 이동.</li>
<li><strong>Concurrent Mark</strong>: Old 영역의 사용량이 임계치(<code>-XX:InitiatingHeapOccupancyPercent</code>, 기본 45%)를 넘으면 시작. 어떤 Region에 쓰레기가 많은지 파악.</li>
<li><strong>Mixed GC</strong>: Concurrent Mark 결과를 바탕으로, Young Region과 <strong>쓰레기가 많은 Old Region을 함께</strong> 수집.</li>
</ol>
<p>CMS와 비교하면:</p>
<div class="hn-table">
<table>
<thead>
<tr>
<td>항목</td><td>CMS</td><td>G1GC</td></tr>
</thead>
<tbody>
<tr>
<td>힙 구조</td><td>Young/Old 연속 영역</td><td>Region 기반</td></tr>
<tr>
<td>압축</td><td>하지 않음 (단편화)</td><td>Region 단위로 압축</td></tr>
<tr>
<td>목표</td><td>최소 일시 정지</td><td><strong>예측 가능한</strong> 일시 정지</td></tr>
<tr>
<td>튜닝 복잡도</td><td>72개 전용 옵션</td><td>핵심 옵션 소수</td></tr>
<tr>
<td>Full GC 위험</td><td>Concurrent Mode Failure</td><td>드물지만 발생 가능</td></tr>
</tbody>
</table>
</div><h3 id="heading-g1gc-2">G1GC의 위상</h3>
<ul>
<li><strong>JDK 6u14</strong>: 실험적 도입</li>
<li><strong>JDK 7u4</strong>: 정식 지원</li>
<li><strong>JDK 9</strong>: <strong>기본 GC로 채택</strong> (Parallel GC를 대체)</li>
</ul>
<p>G1GC는 현재까지도 <strong>가장 널리 사용되는 GC</strong>다. 대부분의 서버 애플리케이션에서 별도 튜닝 없이도 양호한 성능을 제공한다. 하지만 G1GC도 완벽하지 않다. 일시 정지 시간의 <strong>목표</strong>를 설정할 수는 있지만, <strong>보장</strong>하지는 못한다. 힙이 수십 GB로 커지면, 일시 정지가 수백 밀리초에 이를 수 있다.</p>
<p>금융 거래 시스템, 실시간 데이터 처리, 대규모 인메모리 데이터베이스 — 이런 워크로드는 <strong>밀리초 단위의 일시 정지도 허용할 수 없다.</strong> 그래서 다음 세대가 필요했다.</p>
<hr />
<h2 id="heading-3-g1gc">3부. G1GC 이후 — 일시 정지를 밀리초 미만으로</h2>
<h3 id="heading-zgc">ZGC — 힙 크기와 무관한 일시 정지</h3>
<p><strong>Z Garbage Collector(ZGC)</strong>는 JDK 11에서 실험적으로 등장하고, <strong>JDK 15에서 정식 지원</strong>된 초저지연 GC다. 설계 목표는 명확하다:</p>
<blockquote>
<p><strong>힙 크기에 관계없이 일시 정지 시간을 1ms 미만으로 유지한다.</strong></p>
</blockquote>
<p>8MB 힙이든 16TB 힙이든, 일시 정지 시간이 동일하다. 어떻게 가능할까?</p>
<p>ZGC의 핵심 기술은 <strong>Colored Pointers</strong>와 <strong>Load Barriers</strong>다.</p>
<p><strong>Colored Pointers</strong>: ZGC는 객체 참조(포인터)의 상위 비트에 GC 메타데이터를 저장한다. 포인터 자체에 "이 객체가 이동되었는지", "마킹되었는지" 같은 정보가 담겨 있다. 별도의 마킹 비트맵을 참조할 필요가 없으므로, GC 상태 확인이 매우 빠르다.</p>
<p><strong>Load Barriers</strong>: 애플리케이션이 객체 참조를 <strong>읽을 때</strong> 가로채서, 해당 참조가 최신 상태인지 확인한다. 객체가 이동되었다면 새 주소로 투명하게 갱신한다. 이 덕분에 ZGC는 <strong>객체 이동(Compaction)을 애플리케이션과 동시에</strong> 수행할 수 있다.</p>
<pre><code>G1GC pause:

  ──────┃████████████████████┃──────
        ← tens~hundreds ms →

ZGC pause:

  ──────┃█┃───────────────────┃█┃──
        ←→                    ←→
       ~<span class="hljs-number">0.05</span>ms              ~<span class="hljs-number">0.05</span>ms
</code></pre><p>실제 벤치마크에서 ZGC의 평균 일시 정지 시간은 <strong>약 50μs(마이크로초)</strong>, 최대 일시 정지 시간은 <strong>약 500μs</strong>로 측정되었다. G1GC가 20ms 이상의 일시 정지를 보이는 것과 비교하면, 평균 기준으로 <strong>400배 이상의 차이</strong>다.</p>
<pre><code>-XX:+UseZGC
</code></pre><h3 id="heading-generational-zgc">Generational ZGC — 세대를 되찾다</h3>
<p>초기 ZGC에는 한 가지 약점이 있었다. <strong>세대 구분이 없었다.</strong> 매번 힙 전체를 대상으로 GC를 수행해야 했고, 이는 두 가지 문제를 만들었다:</p>
<ol>
<li><strong>처리량 손실</strong>: 수명이 짧은 객체(Young)도 긴 수명 객체(Old)와 같은 비용으로 수집</li>
<li><strong>할당 지연(Allocation Stall)</strong>: GC가 메모리를 회수하는 속도보다 애플리케이션이 메모리를 할당하는 속도가 빠르면, 애플리케이션이 GC 완료를 <strong>기다려야</strong> 함</li>
</ol>
<p>Netflix의 기술 블로그에 따르면, 동시 클라이언트 75개를 넘어서면 단일 세대 ZGC에서 할당 지연이 급격히 발생했다.</p>
<p><strong>JDK 21에서 도입된 Generational ZGC(JEP 439)</strong>는 이 문제를 해결했다. "대부분의 객체는 금방 죽는다"는 <strong>약한 세대 가설(Weak Generational Hypothesis)</strong>을 ZGC에도 적용한 것이다.</p>
<pre><code>Single-Generation ZGC:

  ┌───────────────────────────────────────┐
  │  Entire heap collected every cycle    │
  └───────────────────────────────────────┘

Generational ZGC:

  ┌──────────────┐  ┌────────────────────┐
  │    Young     │  │       Old          │
  │  frequent    │  │   infrequent       │
  └──────────────┘  └────────────────────┘
</code></pre><p>Young 객체를 자주, 빠르게 수집하고, Old 객체는 필요할 때만 수집한다. 결과는:</p>
<div class="hn-table">
<table>
<thead>
<tr>
<td>항목</td><td>Single-Gen ZGC</td><td>Generational ZGC</td><td>개선</td></tr>
</thead>
<tbody>
<tr>
<td>처리량</td><td>기준</td><td>+10%</td><td>세대별 수집으로 효율 증가</td></tr>
<tr>
<td>P99 일시 정지</td><td>기준</td><td>-20~30μs</td><td>이미 낮았지만 더 개선</td></tr>
<tr>
<td>할당 지연</td><td>75 클라이언트 초과 시 발생</td><td>275 클라이언트까지 안정</td><td><strong>3.6배 더 많은 동시 처리</strong></td></tr>
</tbody>
</table>
</div><p>Generational ZGC는 JDK 21에서 정식 기능으로 도입되었고(단, 명시적 활성화 필요), <strong>JDK 23부터 기본 ZGC 모드</strong>가 되었다.</p>
<pre><code># JDK <span class="hljs-number">21</span>~<span class="hljs-number">22</span>
-XX:+UseZGC -XX:+ZGenerational

# JDK <span class="hljs-number">23</span>+
-XX:+UseZGC  (Generational이 기본)
</code></pre><h3 id="heading-7kce7lk0io2dgoyehoudvoydua">전체 타임라인</h3>
<div class="hn-table">
<table>
<thead>
<tr>
<td>JDK</td><td>GC 관련 변화</td></tr>
</thead>
<tbody>
<tr>
<td>1.0</td><td>Serial GC 도입</td></tr>
<tr>
<td>1.4.1</td><td>Parallel GC, CMS 도입</td></tr>
<tr>
<td>6u14</td><td>G1GC 실험적 도입</td></tr>
<tr>
<td>7u4</td><td>G1GC 정식 지원</td></tr>
<tr>
<td>8</td><td><strong>Parallel GC가 기본</strong></td></tr>
<tr>
<td>9</td><td><strong>G1GC가 기본</strong>, CMS deprecated</td></tr>
<tr>
<td>11</td><td>ZGC 실험적 도입, Epsilon GC 도입</td></tr>
<tr>
<td>12</td><td>Shenandoah 실험적 도입</td></tr>
<tr>
<td>14</td><td><strong>CMS 완전 제거</strong></td></tr>
<tr>
<td>15</td><td><strong>ZGC 정식 지원</strong></td></tr>
<tr>
<td>21</td><td>Generational ZGC 도입 (명시적 활성화 필요)</td></tr>
<tr>
<td>23</td><td>Generational ZGC가 기본 ZGC 모드</td></tr>
<tr>
<td>25</td><td>Generational Shenandoah 정식 지원</td></tr>
</tbody>
</table>
</div><h3 id="heading-gc">어떤 GC를 선택할 것인가</h3>
<pre><code class="lang-mermaid">flowchart TD
    Start["What matters most?"] --&gt; T["Throughput"]
    Start --&gt; L["Latency"]
    Start --&gt; B["Balance"]

    T --&gt; Parallel["Parallel GC\n-XX:+UseParallelGC"]
    L --&gt; HeapSize{"Heap &gt; 4GB?"}
    B --&gt; G1["G1GC\n-XX:+UseG1GC"]

    HeapSize --&gt;|Yes| ZGC["ZGC\n-XX:+UseZGC"]
    HeapSize --&gt;|No| G1

    style Parallel fill:#90EE90
    style G1 fill:#87CEEB
    style ZGC fill:#FFD700
</code></pre>
<div class="hn-table">
<table>
<thead>
<tr>
<td>우선순위</td><td>추천 GC</td><td>적합한 워크로드</td></tr>
</thead>
<tbody>
<tr>
<td><strong>처리량 최대화</strong></td><td>Parallel GC</td><td>배치 처리, 과학 계산, 데이터 파이프라인</td></tr>
<tr>
<td><strong>균형</strong></td><td>G1GC (기본)</td><td>대부분의 웹 애플리케이션, 마이크로서비스</td></tr>
<tr>
<td><strong>초저지연</strong></td><td>ZGC</td><td>금융 거래, 실시간 처리, 대규모 힙(16TB까지)</td></tr>
</tbody>
</table>
</div><p>확신이 없다면? <strong>G1GC를 쓰면 된다.</strong> JDK 9부터 기본 GC이고, 대부분의 워크로드에서 튜닝 없이도 충분한 성능을 제공한다. 일시 정지가 문제가 된다면 그때 ZGC를 고려하면 된다.</p>
<hr />
<h2 id="heading-66ei66y066as">마무리</h2>
<p>Java GC의 역사를 한 줄로 요약하면 이렇다:</p>
<blockquote>
<p><strong>"애플리케이션을 멈추지 않으면서, 어떻게 메모리를 회수할 것인가?"</strong></p>
</blockquote>
<p>Serial GC는 모든 것을 멈추고 청소했다. Parallel GC는 여러 명이 함께 청소해서 시간을 줄였다. CMS는 청소하면서 동시에 일도 했지만, 정리 정돈은 포기했다. G1GC는 구역을 나눠 효율적으로 관리했다. ZGC는 거의 멈추지 않는 경지에 이르렀다.</p>
<p>각 세대의 GC는 이전 세대의 한계를 넘기 위해 탄생했다. 그리고 이 진화는 계속되고 있다. Generational ZGC, Generational Shenandoah — 아직 끝나지 않았다.</p>
<p>중요한 것은 <strong>"최신 GC가 최고"가 아니라는 점</strong>이다. 배치 처리에 ZGC를 쓸 이유는 없고, 실시간 거래 시스템에 Serial GC를 쓸 이유도 없다. <strong>자신의 워크로드를 이해하고, 그에 맞는 GC를 선택하는 것.</strong> 그것이 GC의 역사가 우리에게 남긴 교훈이다.</p>
<hr />
<h2 id="heading-7lc46rogioyekoujja">참고 자료</h2>
<ul>
<li><a target="_blank" href="https://kstefanj.github.io/2023/12/13/jdk-21-the-gcs-keep-getting-better.html">JDK 21: The GCs keep getting better</a> — JDK 21 GC 성능 벤치마크</li>
<li><a target="_blank" href="https://inside.java/2023/11/28/gen-zgc-explainer/">Introducing Generational ZGC – Inside.java</a> — Oracle 공식 Generational ZGC 해설</li>
<li><a target="_blank" href="https://netflixtechblog.com/bending-pause-times-to-your-will-with-generational-zgc-256629c9386b">Bending pause times to your will with Generational ZGC | Netflix</a> — Netflix의 Generational ZGC 적용기</li>
<li><a target="_blank" href="https://blog.gceasy.io/cms-gc-algorithm-removed-from-java-14/">CMS GC algorithm removed from Java 14 | GCeasy</a> — CMS 제거 배경</li>
<li><a target="_blank" href="https://developers.redhat.com/articles/2021/11/02/how-choose-best-java-garbage-collector">How to choose the best Java garbage collector | Red Hat</a> — GC 선택 가이드</li>
<li><a target="_blank" href="https://www.logicbrace.com/2025/10/evolution-of-garbage-collection-in-java.html">The Evolution of Garbage Collection in Java</a> — Java GC 진화 타임라인</li>
</ul>
]]></content:encoded></item><item><title><![CDATA[Spring의 3대 철학 — DI, AOP, PSA가 만드는 코드의 품격]]></title><description><![CDATA[Spring을 처음 배울 때, 나는 어노테이션 수집가였다.
@Autowired를 붙이면 객체가 알아서 들어오고, @Transactional을 붙이면 트랜잭션이 알아서 관리되고, @Cacheable을 붙이면 캐시가 알아서 동작했다. "알아서"라는 말 뒤에 숨은 원리를 몰랐다. 그냥 마법이라고 생각했다.
그러다 문제가 생겼다. @Transactional을 붙였는데 롤백이 안 됐다. 같은 클래스 안에서 메서드를 호출했기 때문이었다. 원인을 찾는 데 ...]]></description><link>https://blog.hyunjun.org/spring-3-di-aop-psa</link><guid isPermaLink="true">https://blog.hyunjun.org/spring-3-di-aop-psa</guid><dc:creator><![CDATA[조현준]]></dc:creator><pubDate>Mon, 16 Mar 2026 09:38:37 GMT</pubDate><content:encoded><![CDATA[<p>Spring을 처음 배울 때, 나는 어노테이션 수집가였다.</p>
<p><code>@Autowired</code>를 붙이면 객체가 알아서 들어오고, <code>@Transactional</code>을 붙이면 트랜잭션이 알아서 관리되고, <code>@Cacheable</code>을 붙이면 캐시가 알아서 동작했다. "알아서"라는 말 뒤에 숨은 원리를 몰랐다. 그냥 마법이라고 생각했다.</p>
<p>그러다 문제가 생겼다. <code>@Transactional</code>을 붙였는데 롤백이 안 됐다. 같은 클래스 안에서 메서드를 호출했기 때문이었다. 원인을 찾는 데 반나절이 걸렸고, 결국 Spring AOP의 프록시 동작 원리를 이해하고 나서야 해결할 수 있었다.</p>
<p>그날 깨달았다. <strong>어노테이션 뒤에 숨은 철학을 모르면, 문제가 생겼을 때 속수무책이라는 것을.</strong></p>
<p>Spring Framework는 세 가지 핵심 철학 위에 서 있다. <strong>DI(Dependency Injection)</strong>, <strong>AOP(Aspect-Oriented Programming)</strong>, <strong>PSA(Portable Service Abstraction)</strong>. 이 세 가지는 독립적인 기술이 아니라, 서로 맞물려 돌아가는 톱니바퀴다. 이 글은 각 철학이 <strong>왜</strong> 필요하고, <strong>어떻게</strong> 동작하며, <strong>어떤 문제</strong>를 해결하는지를 코드와 함께 파헤친다.</p>
<hr />
<h2 id="heading-1-di">1. DI — 객체의 생사여탈권을 넘기다</h2>
<h3 id="heading-7j2y7kg07isx7j20656aioustoyxhyduoqwga">의존성이란 무엇인가</h3>
<pre><code class="lang-java"><span class="hljs-keyword">public</span> <span class="hljs-class"><span class="hljs-keyword">class</span> <span class="hljs-title">OrderService</span> </span>{
    <span class="hljs-keyword">private</span> <span class="hljs-keyword">final</span> OrderRepository repository = <span class="hljs-keyword">new</span> JdbcOrderRepository();

    <span class="hljs-function"><span class="hljs-keyword">public</span> <span class="hljs-keyword">void</span> <span class="hljs-title">placeOrder</span><span class="hljs-params">(Order order)</span> </span>{
        repository.save(order);
    }
}
</code></pre>
<p>이 코드의 문제가 보이는가? <code>OrderService</code>가 <code>JdbcOrderRepository</code>를 <strong>직접 생성</strong>하고 있다. <code>OrderService</code>는 <code>OrderRepository</code>의 구현체가 무엇인지 알고 있고, 그 생성 방법까지 알고 있다. 이것이 <strong>강한 결합(tight coupling)</strong>이다.</p>
<p>만약 데이터베이스를 MongoDB로 바꿔야 한다면? <code>OrderService</code>의 코드를 수정해야 한다. 테스트에서 가짜 저장소를 쓰고 싶다면? 역시 코드를 수정해야 한다. <strong>사용하는 쪽이 구현체를 알고 있으면, 구현체가 바뀔 때 사용하는 쪽도 바뀌어야 한다.</strong></p>
<h3 id="heading-7kcc7ja07j2yioyxreyghcdigjqg64k06rcaiounjoutpoyngcdslyrqsqdri6q">제어의 역전 — 내가 만들지 않겠다</h3>
<p>DI의 핵심은 <strong>Inversion of Control(IoC)</strong>, 제어의 역전이다. 객체를 내가 만들지 않고, 외부에서 만들어서 넣어주는 것이다.</p>
<pre><code class="lang-java"><span class="hljs-keyword">public</span> <span class="hljs-class"><span class="hljs-keyword">class</span> <span class="hljs-title">OrderService</span> </span>{
    <span class="hljs-keyword">private</span> <span class="hljs-keyword">final</span> OrderRepository repository;

    <span class="hljs-function"><span class="hljs-keyword">public</span> <span class="hljs-title">OrderService</span><span class="hljs-params">(OrderRepository repository)</span> </span>{
        <span class="hljs-keyword">this</span>.repository = repository;
    }

    <span class="hljs-function"><span class="hljs-keyword">public</span> <span class="hljs-keyword">void</span> <span class="hljs-title">placeOrder</span><span class="hljs-params">(Order order)</span> </span>{
        repository.save(order);
    }
}
</code></pre>
<p><code>OrderService</code>는 이제 <code>OrderRepository</code>가 JDBC인지, JPA인지, MongoDB인지 모른다. <strong>인터페이스에만 의존</strong>한다. 구현체는 외부(Spring 컨테이너)가 결정하고 주입한다.</p>
<pre><code class="lang-java"><span class="hljs-meta">@Configuration</span>
<span class="hljs-keyword">public</span> <span class="hljs-class"><span class="hljs-keyword">class</span> <span class="hljs-title">AppConfig</span> </span>{
    <span class="hljs-meta">@Bean</span>
    <span class="hljs-function"><span class="hljs-keyword">public</span> OrderRepository <span class="hljs-title">orderRepository</span><span class="hljs-params">()</span> </span>{
        <span class="hljs-keyword">return</span> <span class="hljs-keyword">new</span> JpaOrderRepository();
    }

    <span class="hljs-meta">@Bean</span>
    <span class="hljs-function"><span class="hljs-keyword">public</span> OrderService <span class="hljs-title">orderService</span><span class="hljs-params">(OrderRepository orderRepository)</span> </span>{
        <span class="hljs-keyword">return</span> <span class="hljs-keyword">new</span> OrderService(orderRepository);
    }
}
</code></pre>
<p>이제 저장소를 바꾸고 싶으면 <code>AppConfig</code>만 수정하면 된다. <code>OrderService</code>는 건드릴 필요가 없다.</p>
<h3 id="heading-7iod7isx7j6qioyjvoyeheydtcdqtozsnqxrkjjripqg7kee7kecioydtoycoa">생성자 주입이 권장되는 진짜 이유</h3>
<p>Spring에서 DI를 하는 방법은 세 가지다. 필드 주입, 세터 주입, 생성자 주입. Spring 공식 문서는 <strong>생성자 주입을 권장</strong>한다. 왜일까?</p>
<p><strong>필드 주입의 문제:</strong></p>
<pre><code class="lang-java"><span class="hljs-meta">@Service</span>
<span class="hljs-keyword">public</span> <span class="hljs-class"><span class="hljs-keyword">class</span> <span class="hljs-title">OrderService</span> </span>{
    <span class="hljs-meta">@Autowired</span>
    <span class="hljs-keyword">private</span> OrderRepository repository;  <span class="hljs-comment">// final이 아니다</span>
}
</code></pre>
<p><strong>생성자 주입의 장점:</strong></p>
<pre><code class="lang-java"><span class="hljs-meta">@Service</span>
<span class="hljs-keyword">public</span> <span class="hljs-class"><span class="hljs-keyword">class</span> <span class="hljs-title">OrderService</span> </span>{
    <span class="hljs-keyword">private</span> <span class="hljs-keyword">final</span> OrderRepository repository;  <span class="hljs-comment">// final 선언 가능</span>

    <span class="hljs-function"><span class="hljs-keyword">public</span> <span class="hljs-title">OrderService</span><span class="hljs-params">(OrderRepository repository)</span> </span>{
        <span class="hljs-keyword">this</span>.repository = repository;
    }
}
</code></pre>
<p>차이는 <code>final</code> 한 글자에 있다. 하지만 이 한 글자가 만드는 차이는 크다.</p>
<div class="hn-table">
<table>
<thead>
<tr>
<td>관점</td><td>필드 주입</td><td>생성자 주입</td></tr>
</thead>
<tbody>
<tr>
<td><strong>불변성</strong></td><td>final 불가, 런타임에 변경 가능</td><td>final 선언으로 불변 보장</td></tr>
<tr>
<td><strong>NPE 방지</strong></td><td>주입 실패 시 null, 런타임에 NPE</td><td>생성 시점에 누락 감지, 컴파일 타임 안전</td></tr>
<tr>
<td><strong>순환 참조</strong></td><td>런타임에 발견</td><td>애플리케이션 시작 시 즉시 감지</td></tr>
<tr>
<td><strong>테스트</strong></td><td>리플렉션 필요</td><td>new로 직접 생성 가능</td></tr>
<tr>
<td><strong>의존성 파악</strong></td><td>필드 흩어져 있어 파악 어려움</td><td>생성자 파라미터로 한눈에 파악</td></tr>
</tbody>
</table>
</div><p>생성자의 파라미터가 10개가 넘어간다면? 그것은 생성자 주입의 문제가 아니라, <strong>그 클래스가 너무 많은 책임을 지고 있다는 신호</strong>다. 생성자 주입은 이 신호를 눈에 보이게 만들어준다. 필드 주입은 이 신호를 숨긴다.</p>
<h3 id="heading-di">DI가 만드는 변화</h3>
<p>DI는 단순히 "객체를 대신 만들어주는 편의 기능"이 아니다. DI는 <strong>설계를 바꾼다.</strong></p>
<ul>
<li>구현이 아닌 인터페이스에 의존하게 만든다 (DIP — 의존 역전 원칙)</li>
<li>객체의 생성과 사용을 분리한다 (SRP — 단일 책임 원칙)</li>
<li>구현체를 자유롭게 교체할 수 있게 만든다 (OCP — 개방-폐쇄 원칙)</li>
</ul>
<p><strong>DI는 SOLID 원칙을 코드에 자연스럽게 녹이는 장치다.</strong></p>
<hr />
<h2 id="heading-2-aop">2. AOP — 흩어진 관심사를 한 곳에 모으다</h2>
<h3 id="heading-oop-aop">OOP의 시선, AOP의 시선</h3>
<p>전통적인 객체지향 프로그래밍에서 우리는 코드를 <strong>가로로</strong> 바라본다. Controller → Service → Repository, 레이어를 따라 위에서 아래로 흐르는 비즈니스 로직에 집중한다.</p>
<p>그런데 로깅, 트랜잭션, 보안 같은 관심사는 이 가로 흐름을 <strong>세로로 관통</strong>한다. 모든 레이어에 동일한 코드가 반복된다. OOP는 이 세로 방향의 중복을 해결할 도구가 없다.</p>
<pre><code class="lang-mermaid">flowchart TB
    subgraph Layer["OOP — Business Logic Flow →"]
        direction LR
        subgraph OrderService
            O1["placeOrder()"] --&gt; O2["save()"] --&gt; O3["insert()"]
        end
        subgraph PaymentService
            P1["process()"] --&gt; P2["charge()"] --&gt; P3["update()"]
        end
        subgraph NotificationService
            N1["notify()"] --&gt; N2["send()"] --&gt; N3["insert()"]
        end
    end

    subgraph AOP["AOP — Cross-Cutting Concerns ↓"]
        direction TB
        Logging["Logging"]
        Transaction["Transaction"]
        Security["Security"]
    end

    Logging -.-&gt; O1 &amp; P1 &amp; N1
    Transaction -.-&gt; O2 &amp; P2 &amp; N2
    Security -.-&gt; O3 &amp; P3 &amp; N3

    style AOP fill:#f0e6ff,stroke:#9966cc
    style Logging fill:#f9f,stroke:#333
    style Transaction fill:#f9f,stroke:#333
    style Security fill:#f9f,stroke:#333
</code></pre>
<blockquote>
<p><strong>OOP의 시선은 가로(→)다.</strong> Controller → Service → Repository, 비즈니스 로직의 흐름을 따라간다.
<strong>AOP의 시선은 세로(↑)다.</strong> 로깅, 트랜잭션, 보안이 모든 모듈을 관통한다.</p>
</blockquote>
<p>OOP가 <strong>가로(비즈니스 로직의 흐름)</strong>를 모듈화한다면, AOP는 <strong>세로(여러 모듈을 관통하는 공통 관심사)</strong>를 모듈화한다. 관점(Aspect)이라는 이름이 붙은 이유가 여기에 있다. <strong>코드를 바라보는 관점 자체를 바꾸는 것</strong>이다.</p>
<p>이 시선의 전환을 이해하면, AOP가 OOP를 대체하는 것이 아니라 <strong>보완</strong>하는 기술이라는 것이 명확해진다.</p>
<h3 id="heading-66y47kccoidsvztrk5wg7kce7lk07jeqio2nvoynhcdrozzquyu">문제: 코드 전체에 퍼진 로깅</h3>
<pre><code class="lang-java"><span class="hljs-meta">@Service</span>
<span class="hljs-keyword">public</span> <span class="hljs-class"><span class="hljs-keyword">class</span> <span class="hljs-title">OrderService</span> </span>{
    <span class="hljs-function"><span class="hljs-keyword">public</span> <span class="hljs-keyword">void</span> <span class="hljs-title">placeOrder</span><span class="hljs-params">(Order order)</span> </span>{
        <span class="hljs-keyword">long</span> start = System.currentTimeMillis();
        log.info(<span class="hljs-string">"placeOrder 시작: {}"</span>, order.getId());
        <span class="hljs-keyword">try</span> {
            <span class="hljs-comment">// 비즈니스 로직</span>
            orderRepository.save(order);
            paymentService.process(order);
            notificationService.notify(order);
        } <span class="hljs-keyword">finally</span> {
            <span class="hljs-keyword">long</span> elapsed = System.currentTimeMillis() - start;
            log.info(<span class="hljs-string">"placeOrder 완료: {}ms"</span>, elapsed);
        }
    }
}

<span class="hljs-meta">@Service</span>
<span class="hljs-keyword">public</span> <span class="hljs-class"><span class="hljs-keyword">class</span> <span class="hljs-title">PaymentService</span> </span>{
    <span class="hljs-function"><span class="hljs-keyword">public</span> <span class="hljs-keyword">void</span> <span class="hljs-title">process</span><span class="hljs-params">(Order order)</span> </span>{
        <span class="hljs-keyword">long</span> start = System.currentTimeMillis();
        log.info(<span class="hljs-string">"process 시작: {}"</span>, order.getId());
        <span class="hljs-keyword">try</span> {
            <span class="hljs-comment">// 비즈니스 로직</span>
            paymentGateway.charge(order.getAmount());
        } <span class="hljs-keyword">finally</span> {
            <span class="hljs-keyword">long</span> elapsed = System.currentTimeMillis() - start;
            log.info(<span class="hljs-string">"process 완료: {}ms"</span>, elapsed);
        }
    }
}
</code></pre>
<p>실행 시간을 측정하는 로깅 코드가 모든 서비스에 복붙되어 있다. 이것이 <strong>횡단 관심사(Cross-Cutting Concern)</strong>다. 비즈니스 로직과는 무관하지만, 여러 모듈에 <strong>가로질러</strong> 존재하는 코드다.</p>
<p>횡단 관심사의 대표적인 예:</p>
<ul>
<li>로깅/모니터링</li>
<li>트랜잭션 관리</li>
<li>보안/인증</li>
<li>캐싱</li>
<li>예외 처리</li>
</ul>
<p>이런 코드를 각 서비스에 직접 작성하면 두 가지 문제가 생긴다. <strong>비즈니스 로직이 부가 로직에 파묻히고</strong>, 변경이 필요할 때 <strong>모든 서비스를 수정</strong>해야 한다.</p>
<h3 id="heading-aop">AOP의 해결책 — 관심사를 분리하다</h3>
<p>AOP는 이 횡단 관심사를 <strong>Aspect</strong>라는 모듈로 분리한다.</p>
<pre><code class="lang-java"><span class="hljs-meta">@Aspect</span>
<span class="hljs-meta">@Component</span>
<span class="hljs-keyword">public</span> <span class="hljs-class"><span class="hljs-keyword">class</span> <span class="hljs-title">ExecutionTimeAspect</span> </span>{

    <span class="hljs-meta">@Around("execution(* com.example.service.*.*(..))")</span>
    <span class="hljs-function"><span class="hljs-keyword">public</span> Object <span class="hljs-title">measureExecutionTime</span><span class="hljs-params">(ProceedingJoinPoint joinPoint)</span> <span class="hljs-keyword">throws</span> Throwable </span>{
        <span class="hljs-keyword">long</span> start = System.currentTimeMillis();
        String methodName = joinPoint.getSignature().getName();
        log.info(<span class="hljs-string">"{} 시작"</span>, methodName);
        <span class="hljs-keyword">try</span> {
            <span class="hljs-keyword">return</span> joinPoint.proceed();
        } <span class="hljs-keyword">finally</span> {
            <span class="hljs-keyword">long</span> elapsed = System.currentTimeMillis() - start;
            log.info(<span class="hljs-string">"{} 완료: {}ms"</span>, methodName, elapsed);
        }
    }
}
</code></pre>
<p>이제 <code>OrderService</code>와 <code>PaymentService</code>에서 로깅 코드를 모두 제거할 수 있다. 비즈니스 로직만 남는다.</p>
<pre><code class="lang-java"><span class="hljs-meta">@Service</span>
<span class="hljs-keyword">public</span> <span class="hljs-class"><span class="hljs-keyword">class</span> <span class="hljs-title">OrderService</span> </span>{
    <span class="hljs-function"><span class="hljs-keyword">public</span> <span class="hljs-keyword">void</span> <span class="hljs-title">placeOrder</span><span class="hljs-params">(Order order)</span> </span>{
        orderRepository.save(order);
        paymentService.process(order);
        notificationService.notify(order);
    }
}
</code></pre>
<p><strong>서비스는 자신의 핵심 로직에만 집중한다.</strong> 로깅이라는 관심사는 Aspect가 담당한다. 로깅 방식을 바꾸고 싶으면 Aspect 하나만 수정하면 된다.</p>
<h3 id="heading-aop-1">프록시 — AOP의 동작 원리</h3>
<p>여기서 "왜?"라는 질문을 던져야 한다. <strong>Aspect의 코드가 서비스에 없는데, 어떻게 실행되는 걸까?</strong></p>
<p>답은 <strong>프록시(Proxy)</strong>에 있다. Spring은 AOP가 적용된 빈을 생성할 때, 원본 객체 대신 <strong>프록시 객체</strong>를 만들어 컨테이너에 등록한다.</p>
<pre><code>호출자 → [프록시] → 원본 객체
              │
              ├── Before Advice 실행
              ├── 원본 메서드 호출
              └── After Advice 실행
</code></pre><p>Spring Boot 2.0부터는 <strong>CGLIB 프록시</strong>를 기본으로 사용한다. CGLIB은 대상 클래스의 <strong>서브클래스를 런타임에 생성</strong>하여 메서드를 오버라이드하는 방식으로 프록시를 만든다.</p>
<p>이 프록시 메커니즘을 이해하면, 아까 내가 겪었던 문제의 원인이 명확해진다.</p>
<h3 id="heading-self-invocation">self-invocation 문제 — 프록시의 함정</h3>
<pre><code class="lang-java"><span class="hljs-meta">@Service</span>
<span class="hljs-keyword">public</span> <span class="hljs-class"><span class="hljs-keyword">class</span> <span class="hljs-title">OrderService</span> </span>{

    <span class="hljs-meta">@Transactional</span>
    <span class="hljs-function"><span class="hljs-keyword">public</span> <span class="hljs-keyword">void</span> <span class="hljs-title">placeOrder</span><span class="hljs-params">(Order order)</span> </span>{
        orderRepository.save(order);
        <span class="hljs-keyword">this</span>.sendNotification(order);  <span class="hljs-comment">// ← 프록시를 거치지 않는다!</span>
    }

    <span class="hljs-meta">@Transactional(propagation = Propagation.REQUIRES_NEW)</span>
    <span class="hljs-function"><span class="hljs-keyword">public</span> <span class="hljs-keyword">void</span> <span class="hljs-title">sendNotification</span><span class="hljs-params">(Order order)</span> </span>{
        notificationRepository.save(<span class="hljs-keyword">new</span> Notification(order));
    }
}
</code></pre>
<p><code>placeOrder()</code>에서 <code>this.sendNotification()</code>을 호출하면, <code>sendNotification()</code>의 <code>@Transactional</code>이 <strong>동작하지 않는다.</strong> 왜? <code>this</code>는 프록시가 아니라 <strong>원본 객체</strong>이기 때문이다.</p>
<pre><code>외부 호출자 → [프록시] → placeOrder()
                              │
                              └── <span class="hljs-built_in">this</span>.sendNotification()  ← 프록시를 우회!
</code></pre><p>외부에서 <code>orderService.sendNotification()</code>을 호출하면 프록시를 거치므로 <code>@Transactional</code>이 동작한다. 하지만 같은 클래스 내부에서 <code>this</code>로 호출하면 프록시를 건너뛰고 원본 메서드가 직접 호출된다.</p>
<p><strong>해결 방법:</strong> <code>sendNotification()</code>을 별도의 빈으로 분리하거나, <code>ApplicationContext</code>에서 프록시를 직접 가져와 호출한다. 근본적으로는 <strong>클래스의 책임을 분리</strong>하는 것이 올바른 접근이다.</p>
<p>이런 함정은 프록시 기반 AOP의 동작 원리를 이해해야만 피할 수 있다. <strong>마법이 아니라 메커니즘으로 이해해야 하는 이유</strong>다.</p>
<hr />
<h2 id="heading-3-psa">3. PSA — 기술을 갈아끼워도 코드는 그대로</h2>
<h3 id="heading-7zse66ci7j6e7jum7ygs7jeqioyiheygjeuqncdsvztrk5zsnzgg7iuc64ya">프레임워크에 종속된 코드의 시대</h3>
<p>Spring 이전, Java 엔터프라이즈의 표준은 EJB(Enterprise JavaBeans)였다. EJB로 비즈니스 로직을 작성하려면 이런 코드가 필요했다.</p>
<pre><code class="lang-java"><span class="hljs-comment">// EJB 2.x 시절의 서비스 코드</span>
<span class="hljs-keyword">public</span> <span class="hljs-class"><span class="hljs-keyword">class</span> <span class="hljs-title">OrderServiceBean</span> <span class="hljs-keyword">implements</span> <span class="hljs-title">SessionBean</span> </span>{
    <span class="hljs-keyword">private</span> SessionContext ctx;

    <span class="hljs-function"><span class="hljs-keyword">public</span> <span class="hljs-keyword">void</span> <span class="hljs-title">setSessionContext</span><span class="hljs-params">(SessionContext ctx)</span> </span>{ <span class="hljs-keyword">this</span>.ctx = ctx; }
    <span class="hljs-function"><span class="hljs-keyword">public</span> <span class="hljs-keyword">void</span> <span class="hljs-title">ejbCreate</span><span class="hljs-params">()</span> </span>{}
    <span class="hljs-function"><span class="hljs-keyword">public</span> <span class="hljs-keyword">void</span> <span class="hljs-title">ejbRemove</span><span class="hljs-params">()</span> </span>{}
    <span class="hljs-function"><span class="hljs-keyword">public</span> <span class="hljs-keyword">void</span> <span class="hljs-title">ejbActivate</span><span class="hljs-params">()</span> </span>{}
    <span class="hljs-function"><span class="hljs-keyword">public</span> <span class="hljs-keyword">void</span> <span class="hljs-title">ejbPassivate</span><span class="hljs-params">()</span> </span>{}

    <span class="hljs-comment">// 겨우 여기서부터 비즈니스 로직</span>
    <span class="hljs-function"><span class="hljs-keyword">public</span> <span class="hljs-keyword">void</span> <span class="hljs-title">placeOrder</span><span class="hljs-params">(Order order)</span> </span>{
        <span class="hljs-comment">// ...</span>
    }
}
</code></pre>
<p><code>SessionBean</code> 인터페이스를 구현해야 하고, <code>ejbCreate</code>, <code>ejbRemove</code> 같은 생명주기 메서드를 강제로 오버라이드해야 한다. 비즈니스 로직은 프레임워크 코드에 <strong>파묻힌다.</strong> 이 클래스는 EJB 컨테이너에 강하게 의존하기 때문에, 컨테이너 없이는 일반적인 단위 테스트가 사실상 불가능에 가까웠다. <strong>코드가 프레임워크에 종속</strong>된 것이다.</p>
<p>Spring은 이 문제를 근본적으로 다르게 접근했다. 같은 비즈니스 로직을 Spring에서는 이렇게 작성한다.</p>
<pre><code class="lang-java"><span class="hljs-comment">// Spring의 서비스 코드</span>
<span class="hljs-keyword">public</span> <span class="hljs-class"><span class="hljs-keyword">class</span> <span class="hljs-title">OrderService</span> </span>{
    <span class="hljs-keyword">private</span> <span class="hljs-keyword">final</span> OrderRepository repository;

    <span class="hljs-function"><span class="hljs-keyword">public</span> <span class="hljs-title">OrderService</span><span class="hljs-params">(OrderRepository repository)</span> </span>{
        <span class="hljs-keyword">this</span>.repository = repository;
    }

    <span class="hljs-function"><span class="hljs-keyword">public</span> <span class="hljs-keyword">void</span> <span class="hljs-title">placeOrder</span><span class="hljs-params">(Order order)</span> </span>{
        repository.save(order);
    }
}
</code></pre>
<p>프레임워크 클래스를 상속하지 않는다. 특정 인터페이스를 구현하지 않는다. <strong>순수한 Java 객체, POJO</strong>다. 이 클래스는 Spring 없이도 <code>new OrderService(mockRepo)</code>로 인스턴스를 만들어 테스트할 수 있다.</p>
<p>그렇다면 트랜잭션, 캐싱, 보안 같은 엔터프라이즈 기능은 누가 처리하는가? 바로 <strong>PSA</strong>다. Spring이 추상화 계층을 제공하고, 개발자의 POJO 위에 기능을 입혀주는 것이다. 개발자는 <code>@Transactional</code> 하나만 선언하면 되고, 그 뒤의 복잡한 트랜잭션 관리는 Spring의 추상화가 처리한다.</p>
<p>정리하면 PSA에는 두 가지 측면이 있다:</p>
<ul>
<li><strong>Spring 내부의 메커니즘</strong> — <code>PlatformTransactionManager</code> 같은 인터페이스로 구현체를 추상화한다</li>
<li><strong>개발자가 얻는 결과</strong> — 비즈니스 코드가 프레임워크에 종속되지 않는 POJO로 남는다</li>
</ul>
<p>인터페이스 추상화는 Spring 쪽의 이야기이고, POJO는 개발자 쪽의 이야기다. 둘은 동전의 양면이다.</p>
<h3 id="heading-7lau7iob7zmu6rcaioyxhuuklcdshljqs4q">추상화가 없는 세계</h3>
<p>만약 Spring의 트랜잭션 추상화가 없다면, JDBC로 트랜잭션을 관리하는 코드는 이렇게 생겼을 것이다.</p>
<pre><code class="lang-java"><span class="hljs-function"><span class="hljs-keyword">public</span> <span class="hljs-keyword">void</span> <span class="hljs-title">placeOrder</span><span class="hljs-params">(Order order)</span> <span class="hljs-keyword">throws</span> SQLException </span>{
    Connection conn = dataSource.getConnection();
    <span class="hljs-keyword">try</span> {
        conn.setAutoCommit(<span class="hljs-keyword">false</span>);

        <span class="hljs-comment">// 비즈니스 로직</span>
        PreparedStatement ps = conn.prepareStatement(<span class="hljs-string">"INSERT INTO orders ..."</span>);
        ps.executeUpdate();

        conn.commit();
    } <span class="hljs-keyword">catch</span> (Exception e) {
        conn.rollback();
        <span class="hljs-keyword">throw</span> e;
    } <span class="hljs-keyword">finally</span> {
        conn.close();
    }
}
</code></pre>
<p>비즈니스 로직이 JDBC API에 <strong>완전히 종속</strong>되어 있다. 여기서 JPA로 전환하려면? 코드를 <strong>전부 다시 작성</strong>해야 한다. <code>Connection</code>, <code>PreparedStatement</code>, <code>commit()</code>, <code>rollback()</code> — 이 모든 것이 JDBC라는 특정 기술에 묶여 있기 때문이다.</p>
<h3 id="heading-psa">PSA — 기술 위에 놓인 추상화 계층</h3>
<p>PSA는 <strong>Portable Service Abstraction</strong>, 이동 가능한 서비스 추상화다. 특정 기술에 종속되지 않고, <strong>추상화된 인터페이스를 통해 일관된 방식으로 기술을 사용</strong>할 수 있게 한다.</p>
<p>Spring의 트랜잭션 추상화를 보자. 핵심은 <code>PlatformTransactionManager</code> 인터페이스다.</p>
<pre><code class="lang-java"><span class="hljs-keyword">public</span> <span class="hljs-class"><span class="hljs-keyword">interface</span> <span class="hljs-title">PlatformTransactionManager</span> <span class="hljs-keyword">extends</span> <span class="hljs-title">TransactionManager</span> </span>{
    <span class="hljs-function">TransactionStatus <span class="hljs-title">getTransaction</span><span class="hljs-params">(TransactionDefinition definition)</span>
            <span class="hljs-keyword">throws</span> TransactionException</span>;
    <span class="hljs-function"><span class="hljs-keyword">void</span> <span class="hljs-title">commit</span><span class="hljs-params">(TransactionStatus status)</span> <span class="hljs-keyword">throws</span> TransactionException</span>;
    <span class="hljs-function"><span class="hljs-keyword">void</span> <span class="hljs-title">rollback</span><span class="hljs-params">(TransactionStatus status)</span> <span class="hljs-keyword">throws</span> TransactionException</span>;
}
</code></pre>
<p>이 인터페이스의 구현체는 기술에 따라 달라진다:</p>
<div class="hn-table">
<table>
<thead>
<tr>
<td>기술 스택</td><td>구현체</td></tr>
</thead>
<tbody>
<tr>
<td>JDBC</td><td><code>DataSourceTransactionManager</code></td></tr>
<tr>
<td>JPA/Hibernate</td><td><code>JpaTransactionManager</code></td></tr>
<tr>
<td>JTA (분산 트랜잭션)</td><td><code>JtaTransactionManager</code></td></tr>
</tbody>
</table>
</div><p>리액티브 환경에서는 별도의 <code>ReactiveTransactionManager</code> 인터페이스와 <code>R2dbcTransactionManager</code> 구현체가 존재한다. 명령형과 리액티브의 트랜잭션 관리가 완전히 분리된 것도 PSA 설계의 일부다.</p>
<p>하지만 개발자는 이 구현체를 직접 다루지 않는다. <code>@Transactional</code> 하나면 된다.</p>
<pre><code class="lang-java"><span class="hljs-meta">@Service</span>
<span class="hljs-keyword">public</span> <span class="hljs-class"><span class="hljs-keyword">class</span> <span class="hljs-title">OrderService</span> </span>{

    <span class="hljs-meta">@Transactional</span>
    <span class="hljs-function"><span class="hljs-keyword">public</span> <span class="hljs-keyword">void</span> <span class="hljs-title">placeOrder</span><span class="hljs-params">(Order order)</span> </span>{
        orderRepository.save(order);
        paymentService.process(order);
    }
}
</code></pre>
<p>JDBC를 쓰든, JPA를 쓰든, R2DBC를 쓰든 — <strong>이 코드는 변하지 않는다.</strong> 기술이 바뀌면 Spring이 알아서 다른 <code>TransactionManager</code> 구현체를 주입할 뿐이다. 이것이 <strong>Portable</strong>, 이동 가능하다는 의미다.</p>
<h3 id="heading-psa-1">PSA가 적용된 곳들</h3>
<p><code>@Transactional</code>만 PSA인 것이 아니다. Spring 곳곳에 PSA가 녹아 있다.</p>
<p><strong>캐시 추상화:</strong></p>
<pre><code class="lang-java"><span class="hljs-meta">@Service</span>
<span class="hljs-keyword">public</span> <span class="hljs-class"><span class="hljs-keyword">class</span> <span class="hljs-title">ProductService</span> </span>{

    <span class="hljs-meta">@Cacheable("products")</span>
    <span class="hljs-function"><span class="hljs-keyword">public</span> Product <span class="hljs-title">findById</span><span class="hljs-params">(Long id)</span> </span>{
        <span class="hljs-keyword">return</span> productRepository.findById(id).orElseThrow();
    }
}
</code></pre>
<p><code>@Cacheable</code> 뒤에서 동작하는 <code>CacheManager</code>의 구현체는 바뀔 수 있다.</p>
<div class="hn-table">
<table>
<thead>
<tr>
<td>설정</td><td>구현체</td></tr>
</thead>
<tbody>
<tr>
<td>기본</td><td><code>ConcurrentMapCacheManager</code></td></tr>
<tr>
<td>Redis</td><td><code>RedisCacheManager</code></td></tr>
<tr>
<td>Caffeine</td><td><code>CaffeineCacheManager</code></td></tr>
<tr>
<td>JSR-107 호환 (Ehcache 3 등)</td><td><code>JCacheCacheManager</code></td></tr>
</tbody>
</table>
</div><p>Caffeine에서 Redis로 캐시를 교체해도 <code>@Cacheable</code>이 붙은 서비스 코드는 <strong>한 줄도 바뀌지 않는다.</strong> 의존성과 설정만 바꾸면 된다.</p>
<p><strong>Spring Data:</strong></p>
<pre><code class="lang-java"><span class="hljs-keyword">public</span> <span class="hljs-class"><span class="hljs-keyword">interface</span> <span class="hljs-title">OrderRepository</span> <span class="hljs-keyword">extends</span> <span class="hljs-title">JpaRepository</span>&lt;<span class="hljs-title">Order</span>, <span class="hljs-title">Long</span>&gt; </span>{
    <span class="hljs-function">List&lt;Order&gt; <span class="hljs-title">findByCustomerId</span><span class="hljs-params">(Long customerId)</span></span>;
}
</code></pre>
<p>이 인터페이스는 JPA에 종속된 것처럼 보이지만, Spring Data의 추상화 덕분에 같은 패턴으로 다양한 저장소를 사용할 수 있다.</p>
<div class="hn-table">
<table>
<thead>
<tr>
<td>저장소</td><td>상위 인터페이스</td></tr>
</thead>
<tbody>
<tr>
<td>JPA (RDB)</td><td><code>JpaRepository</code></td></tr>
<tr>
<td>MongoDB</td><td><code>MongoRepository</code></td></tr>
<tr>
<td>Elasticsearch</td><td><code>ElasticsearchRepository</code></td></tr>
<tr>
<td>Redis</td><td><code>CrudRepository</code></td></tr>
</tbody>
</table>
</div><p>메서드 이름 기반 쿼리 생성, 페이징, 정렬 — 이 모든 기능이 저장소 기술에 관계없이 <strong>동일한 방식</strong>으로 동작한다.</p>
<h3 id="heading-psa-dip">PSA의 본질 — DIP를 프레임워크 레벨에서 실현하다</h3>
<p>PSA의 구조를 도식화하면 이렇다:</p>
<pre><code>[내 애플리케이션 코드]
        │
        ▼
[Spring 추상화 계층]  ←── @Transactional, @Cacheable, Repository
        │
        ▼
[구현체]  ←── JpaTransactionManager, RedisCacheManager, ...
        │
        ▼
[실제 기술]  ←── Hibernate, Redis, Elasticsearch, ...
</code></pre><p>내 코드는 Spring의 추상화 계층에만 의존한다. 그 아래의 구현체와 실제 기술은 <strong>설정으로 교체</strong>할 수 있다. 이것은 앞서 살펴본 DI의 <strong>의존 역전 원칙(DIP)</strong>을 프레임워크 레벨에서 대규모로 적용한 것이다.</p>
<hr />
<h2 id="heading-4">4. 세 철학의 연결 — 하나의 목표를 향해</h2>
<p>DI, AOP, PSA는 독립적으로 존재하지 않는다. <strong>서로 맞물려 돌아간다.</strong></p>
<p><code>@Transactional</code> 하나를 예로 들어보자. 이 어노테이션이 동작하려면 세 가지 철학이 모두 필요하다:</p>
<ol>
<li><strong>PSA</strong> — <code>@Transactional</code>은 <code>PlatformTransactionManager</code>라는 추상화에 의존한다. 기술에 종속되지 않는다.</li>
<li><strong>AOP</strong> — <code>@Transactional</code>이 붙은 메서드를 프록시가 감싸서, 메서드 실행 전후에 트랜잭션을 시작하고 커밋/롤백한다.</li>
<li><strong>DI</strong> — 프록시가 사용할 <code>TransactionManager</code> 구현체를 Spring 컨테이너가 주입한다.</li>
</ol>
<pre><code>@Transactional이 동작하는 과정:

<span class="hljs-number">1.</span> [DI]  Spring 컨테이너가 JpaTransactionManager를 생성하고 주입
<span class="hljs-number">2.</span> [AOP] 프록시가 메서드 호출을 가로챔
<span class="hljs-number">3.</span> [PSA] PlatformTransactionManager.getTransaction() 호출
<span class="hljs-number">4.</span>       원본 메서드 실행
<span class="hljs-number">5.</span> [PSA] 성공 시 commit(), 예외 시 rollback()
</code></pre><p><strong>DI가 없으면</strong> AOP의 프록시가 올바른 TransactionManager를 받을 수 없다. <strong>AOP가 없으면</strong> <code>@Transactional</code>을 메서드에 선언하는 것만으로는 트랜잭션이 적용되지 않는다. <strong>PSA가 없으면</strong> 기술이 바뀔 때마다 트랜잭션 로직을 다시 작성해야 한다.</p>
<p>세 철학이 향하는 궁극적 목표는 하나다. <strong>POJO 기반의 엔터프라이즈 개발.</strong> 비즈니스 로직을 담은 객체가 특정 프레임워크나 기술에 종속되지 않고, 순수한 Java 객체로 남을 수 있게 하는 것이다.</p>
<pre><code class="lang-java"><span class="hljs-comment">// 이 클래스는 Spring에 대해 아무것도 모른다.</span>
<span class="hljs-comment">// 하지만 DI, AOP, PSA 덕분에 트랜잭션, 캐싱, 로깅이 모두 적용된다.</span>
<span class="hljs-keyword">public</span> <span class="hljs-class"><span class="hljs-keyword">class</span> <span class="hljs-title">OrderService</span> </span>{
    <span class="hljs-keyword">private</span> <span class="hljs-keyword">final</span> OrderRepository repository;

    <span class="hljs-function"><span class="hljs-keyword">public</span> <span class="hljs-title">OrderService</span><span class="hljs-params">(OrderRepository repository)</span> </span>{
        <span class="hljs-keyword">this</span>.repository = repository;
    }

    <span class="hljs-function"><span class="hljs-keyword">public</span> <span class="hljs-keyword">void</span> <span class="hljs-title">placeOrder</span><span class="hljs-params">(Order order)</span> </span>{
        repository.save(order);
    }
}
</code></pre>
<p>Spring의 어노테이션을 모두 걷어내도 이 클래스는 <strong>컴파일되고, 테스트되고, 동작한다.</strong> 이것이 Spring이 추구하는 코드의 품격이다.</p>
<hr />
<h2 id="heading-66ei66y066as">마무리</h2>
<p>Spring을 쓰면서 <code>@Autowired</code>, <code>@Transactional</code>, <code>@Cacheable</code>을 "그냥 붙이면 되는 것"으로 생각했다면, 이제는 그 뒤에서 세 가지 철학이 어떻게 맞물려 돌아가는지 보일 것이다.</p>
<ul>
<li><strong>DI</strong> — 객체의 생성과 사용을 분리하여, 느슨한 결합과 유연한 구조를 만든다</li>
<li><strong>AOP</strong> — 횡단 관심사를 분리하여, 비즈니스 로직을 깨끗하게 유지한다</li>
<li><strong>PSA</strong> — 기술을 추상화하여, 구현체가 바뀌어도 코드가 바뀌지 않게 한다</li>
</ul>
<p>이 세 가지는 결국 하나의 목표를 향한다. <strong>내 코드가 특정 기술에 종속되지 않고, 변화에 유연하게 대응할 수 있는 구조를 만드는 것.</strong> Spring이 20년 넘게 Java 생태계의 표준으로 자리 잡은 이유가 여기에 있다.</p>
<p>어노테이션 뒤의 원리를 이해하자. 그래야 마법이 풀렸을 때 당황하지 않는다.</p>
<hr />
<h2 id="heading-7lc46rogioyekoujja">참고 자료</h2>
<ul>
<li><a target="_blank" href="https://docs.spring.io/spring-framework/reference/core/beans.html">Spring Framework Core - IoC Container</a> — Spring 공식 IoC/DI 문서</li>
<li><a target="_blank" href="https://docs.spring.io/spring-framework/reference/core/aop.html">Spring Framework Core - AOP</a> — Spring 공식 AOP 문서</li>
<li><a target="_blank" href="https://docs.spring.io/spring-framework/reference/data-access/transaction/strategies.html">Understanding the Spring Framework Transaction Abstraction</a> — Spring 트랜잭션 추상화 공식 문서</li>
<li><a target="_blank" href="https://docs.spring.io/spring-framework/reference/core/aop/proxying.html">Proxying Mechanisms :: Spring Framework</a> — Spring AOP 프록시 메커니즘 공식 문서</li>
<li><a target="_blank" href="https://tecoble.techcourse.co.kr/post/2020-07-18-di-constuctor-injection/">왜 Constructor Injection을 사용해야 하는가? | Tecoble</a> — 생성자 주입 권장 이유</li>
<li><a target="_blank" href="https://velog.io/@clevekim/Spring-PSAPortable-Service-Abstraction">Spring PSA(Portable Service Abstraction)</a> — PSA 개념 설명</li>
</ul>
]]></content:encoded></item><item><title><![CDATA[Spring Boot Docker 이미지, 한 줄 한 줄에 담긴 고민]]></title><description><![CDATA[처음 Spring Boot 애플리케이션을 Docker로 배포했을 때, Dockerfile은 딱 세 줄이었다.
FROM openjdk:17
COPY build/libs/app.jar app.jar
ENTRYPOINT ["java", "-jar", "app.jar"]

동작은 했다. 하지만 이미지 크기는 700MB를 넘겼고, 코드 한 줄 고칠 때마다 전체 JAR를 다시 빌드해야 했다. 프로덕션에 올릴 때는 root 권한으로 실행되고 있었다. "동작...]]></description><link>https://blog.hyunjun.org/spring-boot-docker</link><guid isPermaLink="true">https://blog.hyunjun.org/spring-boot-docker</guid><category><![CDATA[Devops]]></category><category><![CDATA[Docker]]></category><category><![CDATA[Java]]></category><category><![CDATA[spring-boot]]></category><dc:creator><![CDATA[조현준]]></dc:creator><pubDate>Mon, 16 Mar 2026 05:39:23 GMT</pubDate><content:encoded><![CDATA[<p>처음 Spring Boot 애플리케이션을 Docker로 배포했을 때, Dockerfile은 딱 세 줄이었다.</p>
<pre><code class="lang-dockerfile"><span class="hljs-keyword">FROM</span> openjdk:<span class="hljs-number">17</span>
<span class="hljs-keyword">COPY</span><span class="bash"> build/libs/app.jar app.jar</span>
<span class="hljs-keyword">ENTRYPOINT</span><span class="bash"> [<span class="hljs-string">"java"</span>, <span class="hljs-string">"-jar"</span>, <span class="hljs-string">"app.jar"</span>]</span>
</code></pre>
<p>동작은 했다. 하지만 이미지 크기는 700MB를 넘겼고, 코드 한 줄 고칠 때마다 전체 JAR를 다시 빌드해야 했다. 프로덕션에 올릴 때는 root 권한으로 실행되고 있었다. "동작한다"와 "잘 동작한다"는 다르다는 걸 깨닫는 데 오래 걸리지 않았다.</p>
<p>이 글은 내가 Spring Boot Dockerfile을 다듬어가며 했던 고민의 기록이다. 한 줄 한 줄에 "왜?"라는 질문을 던지고, 그 답을 찾아가는 과정을 공유한다.</p>
<hr />
<h2 id="heading-dockerfile">최종 Dockerfile</h2>
<p>먼저 완성된 Dockerfile을 보자. 이후 섹션에서 각 부분의 고민을 하나씩 풀어간다.</p>
<pre><code class="lang-dockerfile"><span class="hljs-keyword">ARG</span> JAR_FILE=application-api/build/libs/application-api-*-SNAPSHOT.jar

<span class="hljs-keyword">FROM</span> eclipse-temurin:<span class="hljs-number">24</span>-jre AS extractor
<span class="hljs-keyword">ARG</span> JAR_FILE
<span class="hljs-keyword">WORKDIR</span><span class="bash"> /extractor</span>
<span class="hljs-keyword">COPY</span><span class="bash"> <span class="hljs-variable">${JAR_FILE}</span> app.jar</span>
<span class="hljs-keyword">RUN</span><span class="bash"> java -Djarmode=tools -jar app.jar extract --layers --launcher --destination extracted</span>

<span class="hljs-keyword">FROM</span> eclipse-temurin:<span class="hljs-number">24</span>-jre
<span class="hljs-keyword">WORKDIR</span><span class="bash"> /app</span>
<span class="hljs-keyword">ENV</span> TZ=UTC

<span class="hljs-keyword">RUN</span><span class="bash"> groupadd -r appgroup &amp;&amp; useradd -r -g appgroup appuser &amp;&amp; chown -R appuser:appgroup /app</span>

<span class="hljs-keyword">COPY</span><span class="bash"> --from=extractor --chown=appuser:appgroup /extractor/extracted/dependencies/ ./</span>
<span class="hljs-keyword">COPY</span><span class="bash"> --from=extractor --chown=appuser:appgroup /extractor/extracted/spring-boot-loader/ ./</span>
<span class="hljs-keyword">COPY</span><span class="bash"> --from=extractor --chown=appuser:appgroup /extractor/extracted/snapshot-dependencies/ ./</span>
<span class="hljs-keyword">COPY</span><span class="bash"> --from=extractor --chown=appuser:appgroup /extractor/extracted/application/ ./</span>

<span class="hljs-keyword">USER</span> appuser
<span class="hljs-keyword">EXPOSE</span> <span class="hljs-number">8080</span>

<span class="hljs-keyword">ENTRYPOINT</span><span class="bash"> [<span class="hljs-string">"java"</span>, \
    <span class="hljs-string">"-XX:+UseContainerSupport"</span>, \
    <span class="hljs-string">"-XX:MaxRAMPercentage=75.0"</span>, \
    <span class="hljs-string">"-Djava.security.egd=file:/dev/./urandom"</span>, \
    <span class="hljs-string">"org.springframework.boot.loader.launch.JarLauncher"</span>]</span>
</code></pre>
<hr />
<h2 id="heading-1">1. 멀티 스테이지 빌드 — 빌드와 실행을 분리하다</h2>
<p>가장 먼저 눈에 들어오는 건 <code>FROM</code>이 두 번 등장한다는 점이다. 이것이 <strong>멀티 스테이지 빌드</strong>다.</p>
<p>첫 번째 스테이지(<code>extractor</code>)에서는 JAR 파일을 레이어별로 추출한다. 두 번째 스테이지에서는 추출된 결과물만 복사해서 최종 이미지를 만든다.</p>
<p>왜 이렇게 나눌까? <strong>최종 이미지에 불필요한 것을 남기지 않기 위해서</strong>다.</p>
<p>만약 빌드 도구(Gradle, Maven)까지 포함된 단일 스테이지를 쓴다면, 빌드에만 필요한 도구들이 프로덕션 이미지에 고스란히 남는다. 이미지 크기가 커지는 건 물론이고, 공격자가 컨테이너에 침입했을 때 활용할 수 있는 도구가 늘어난다.</p>
<p>이 Dockerfile에서는 빌드 자체는 CI 환경에서 이미 완료되었다고 가정하고, <strong>JAR 파일의 추출과 실행만</strong> 컨테이너 안에서 처리한다. 빌드와 실행의 관심사를 깔끔하게 분리하는 것이다.</p>
<hr />
<h2 id="heading-2-jre-jdk">2. 왜 JRE인가 — JDK를 프로덕션에서 쓰지 않는 이유</h2>
<pre><code class="lang-dockerfile"><span class="hljs-keyword">FROM</span> eclipse-temurin:<span class="hljs-number">24</span>-jre AS extractor
</code></pre>
<p>베이스 이미지로 <code>eclipse-temurin:24-jre</code>를 선택했다. <strong>JDK가 아니라 JRE</strong>다.</p>
<p>JDK(Java Development Kit)에는 컴파일러(<code>javac</code>), 디버거, 프로파일러 등 개발 도구가 포함되어 있다. 프로덕션에서 이것들이 필요할까? 필요 없다. 애플리케이션을 <strong>실행만</strong> 하면 된다.</p>
<p>크기 차이도 무시할 수 없다. 같은 버전 기준으로 JDK 이미지는 JRE 이미지보다 <strong>2~3배 이상 크다.</strong> 컴파일러, 헤더 파일, 개발 도구 등이 모두 포함되기 때문이다.</p>
<p>하지만 크기보다 중요한 건 <strong>보안</strong>이다. JDK에 포함된 <code>javac</code>, <code>jdb</code> 같은 도구는 공격자에게 유용한 무기가 될 수 있다. 컨테이너가 침해되었을 때, 개발 도구가 없는 환경은 공격자의 행동 반경을 크게 제한한다.</p>
<p><strong>Eclipse Temurin</strong>을 선택한 이유는 Adoptium 프로젝트에서 관리하는 검증된 OpenJDK 빌드이기 때문이다. LTS 지원, 정기적인 보안 패치, Docker Hub 공식 이미지 지원까지 갖추고 있어 프로덕션 환경에서 신뢰할 수 있다.</p>
<hr />
<h2 id="heading-3-spring-boot-docker">3. Spring Boot 레이어 추출 — Docker 캐시를 이해하면 보이는 것</h2>
<pre><code class="lang-dockerfile"><span class="hljs-keyword">RUN</span><span class="bash"> java -Djarmode=tools -jar app.jar extract --layers --launcher --destination extracted</span>
</code></pre>
<p>이 한 줄이 이 Dockerfile의 핵심이다. Spring Boot의 <strong>레이어드 JAR</strong> 기능을 활용해 fat JAR를 4개 레이어로 분해한다.</p>
<p>일반적인 Spring Boot fat JAR는 모든 것이 하나의 파일에 담겨 있다. 의존성 라이브러리, Spring Boot 로더, 내 애플리케이션 코드까지. 그래서 코드 한 줄만 바꿔도 수십 MB짜리 JAR 전체를 다시 Docker 레이어에 올려야 한다.</p>
<p>Docker는 레이어 기반으로 동작한다. <strong>변경되지 않은 레이어는 캐시에서 재사용</strong>한다. 이 원리를 활용하면, 자주 바뀌는 것과 거의 바뀌지 않는 것을 분리해서 빌드 시간을 극적으로 줄일 수 있다.</p>
<p><code>--layers</code> 옵션으로 추출하면 4개의 디렉토리가 생긴다:</p>
<div class="hn-table">
<table>
<thead>
<tr>
<td>레이어</td><td>내용</td><td>변경 빈도</td></tr>
</thead>
<tbody>
<tr>
<td><code>dependencies</code></td><td>외부 라이브러리 (Spring, Jackson 등)</td><td>거의 안 바뀜</td></tr>
<tr>
<td><code>spring-boot-loader</code></td><td>Spring Boot 로더 클래스</td><td>Spring Boot 버전 업그레이드 시만</td></tr>
<tr>
<td><code>snapshot-dependencies</code></td><td>SNAPSHOT 버전 의존성</td><td>개발 중 가끔</td></tr>
<tr>
<td><code>application</code></td><td>내 애플리케이션 코드</td><td>매번</td></tr>
</tbody>
</table>
</div><p><code>--launcher</code> 옵션은 Spring Boot 로더를 포함시켜서, 최종 이미지에서 <code>java -jar</code> 대신 <code>org.springframework.boot.loader.launch.JarLauncher</code>로 기동할 수 있게 한다. 이렇게 하면 Spring Boot의 클래스 로딩 최적화를 그대로 활용할 수 있다.</p>
<hr />
<h2 id="heading-4-copy">4. COPY 순서의 비밀 — 변경 빈도가 낮은 것부터</h2>
<pre><code class="lang-dockerfile"><span class="hljs-keyword">COPY</span><span class="bash"> --from=extractor --chown=appuser:appgroup /extractor/extracted/dependencies/ ./</span>
<span class="hljs-keyword">COPY</span><span class="bash"> --from=extractor --chown=appuser:appgroup /extractor/extracted/spring-boot-loader/ ./</span>
<span class="hljs-keyword">COPY</span><span class="bash"> --from=extractor --chown=appuser:appgroup /extractor/extracted/snapshot-dependencies/ ./</span>
<span class="hljs-keyword">COPY</span><span class="bash"> --from=extractor --chown=appuser:appgroup /extractor/extracted/application/ ./</span>
</code></pre>
<p>4개의 <code>COPY</code>가 특정한 순서로 나열되어 있다. 이 순서는 <strong>의도적</strong>이다.</p>
<p>Docker는 Dockerfile을 위에서 아래로 실행하면서 각 명령어의 결과를 레이어로 캐싱한다. 그런데 <strong>어떤 레이어가 변경되면, 그 아래의 모든 레이어도 무효화</strong>된다. 이것이 Docker의 레이어 캐시 무효화 규칙이다.</p>
<p>이 규칙을 이해하면 순서가 왜 중요한지 명확해진다:</p>
<ol>
<li><code>dependencies</code> — 거의 바뀌지 않는다. 맨 위에 놓으면 거의 항상 캐시된다.</li>
<li><code>spring-boot-loader</code> — Spring Boot 버전을 올릴 때만 바뀐다.</li>
<li><code>snapshot-dependencies</code> — 개발 중에 가끔 바뀐다.</li>
<li><code>application</code> — 매 배포마다 바뀐다. 맨 아래에 놓는다.</li>
</ol>
<p>결과적으로, 일상적인 코드 변경에서는 <strong>마지막 <code>application</code> 레이어만 다시 빌드</strong>된다. 나머지 세 레이어는 캐시에서 가져온다. 이것이 빌드 시간을 단축시키고, 레지스트리에 푸시할 때도 변경된 레이어만 전송하므로 네트워크 비용도 줄어든다.</p>
<hr />
<h2 id="heading-5">5. 눈으로 확인하는 차이 — 이미지 레이어 비교</h2>
<p>이론은 충분하다. 실제로 어떤 차이가 나는지 확인해보자.</p>
<p>실제 운영 중인 Spring Boot 애플리케이션(멀티 모듈, 의존성 라이브러리 40여 개 규모)으로 두 방식의 Docker 이미지를 빌드하고 <code>docker history</code>로 레이어를 비교했다.</p>
<h3 id="heading-fat-jar">Fat JAR 방식의 레이어 구조</h3>
<p><code>docker history</code> 명령어로 이미지의 레이어 구조를 확인할 수 있다. 초기 Dockerfile로 만든 이미지를 보면:</p>
<pre><code>$ docker history hanpyo:fat-jar

SIZE        CREATED BY
<span class="hljs-number">89.3</span>MB      COPY app.jar .                          ← 전체 JAR, 단일 레이어
<span class="hljs-number">0</span>B          WORKDIR /app
<span class="hljs-number">274</span>MB       eclipse-temurin:<span class="hljs-number">24</span>-jre 베이스 이미지
</code></pre><p>전체 JAR가 <strong>하나의 레이어(89.3MB)</strong>로 들어간다. 구조는 단순하지만, 코드 한 줄만 바꿔도 이 89.3MB 레이어 전체가 무효화된다.</p>
<h3 id="heading-layered-jar">Layered JAR 방식의 레이어 구조</h3>
<p>동일한 애플리케이션을 레이어 추출 방식으로 빌드하면:</p>
<pre><code>$ docker history hanpyo:layered

SIZE        CREATED BY
<span class="hljs-number">1.7</span>MB       COPY .../application/ .                  ← 내 코드
<span class="hljs-number">4.1</span>KB       COPY .../snapshot-dependencies/ .        ← SNAPSHOT
<span class="hljs-number">692</span>KB       COPY .../spring-boot-loader/ .           ← 로더
<span class="hljs-number">88</span>MB        COPY .../dependencies/ .                 ← 외부 라이브러리
<span class="hljs-number">45.1</span>KB      RUN groupadd &amp;&amp; useradd ...
<span class="hljs-number">0</span>B          WORKDIR /app
<span class="hljs-number">274</span>MB       eclipse-temurin:<span class="hljs-number">24</span>-jre 베이스 이미지
</code></pre><p>fat JAR 안에 있던 89.3MB가 4개 레이어로 분해되었다. 핵심은 <strong>비율</strong>이다:</p>
<div class="hn-table">
<table>
<thead>
<tr>
<td>레이어</td><td>크기</td><td>전체 대비 비율</td></tr>
</thead>
<tbody>
<tr>
<td>dependencies</td><td>88MB</td><td>97.4%</td></tr>
<tr>
<td>spring-boot-loader</td><td>692KB</td><td>0.8%</td></tr>
<tr>
<td>snapshot-dependencies</td><td>4.1KB</td><td>~0%</td></tr>
<tr>
<td><strong>application</strong></td><td><strong>1.7MB</strong></td><td><strong>1.9%</strong></td></tr>
</tbody>
</table>
</div><p><strong>전체의 97.4%를 차지하는 dependencies는 거의 바뀌지 않고, 매번 바뀌는 application은 전체의 1.9%에 불과하다.</strong></p>
<blockquote>
<p>직접 확인해보자. <code>docker history &lt;이미지명&gt;</code> 명령어로 자신의 이미지 레이어를 살펴보면, 대부분의 Spring Boot 애플리케이션에서 dependencies가 95% 이상을 차지하는 것을 확인할 수 있다.</p>
</blockquote>
<h3 id="heading-7l2u65ociouzgoqyvsdsi5wg7j6s67mm65ociou5hoq1ka">코드 변경 시 재빌드 비교</h3>
<p>실제로 코드를 한 줄 수정하고 다시 <code>docker build</code>를 실행했다. 두 방식의 차이를 시각화하면:</p>
<pre><code>[Fat JAR — 코드 변경 후 재빌드]

  ┌──────────────────────────────┐
  │ eclipse-temurin:<span class="hljs-number">24</span>-jre       │ <span class="hljs-number">274</span>MB   캐시 ──── 재사용
  ├──────────────────────────────┤
  │ app.jar                      │ <span class="hljs-number">89.3</span>MB  재빌드 ── 전체 전송 ★
  └──────────────────────────────┘
                                   전송량: <span class="hljs-number">89.3</span>MB

[Layered JAR — 코드 변경 후 재빌드]

  ┌──────────────────────────────┐
  │ eclipse-temurin:<span class="hljs-number">24</span>-jre       │ <span class="hljs-number">274</span>MB   캐시 ──── 재사용
  ├──────────────────────────────┤
  │ dependencies                 │ <span class="hljs-number">88</span>MB    캐시 ──── 재사용
  ├──────────────────────────────┤
  │ spring-boot-loader           │ <span class="hljs-number">692</span>KB   캐시 ──── 재사용
  ├──────────────────────────────┤
  │ snapshot-dependencies        │ <span class="hljs-number">4.1</span>KB   캐시 ──── 재사용
  ├──────────────────────────────┤
  │ application                  │ <span class="hljs-number">1.7</span>MB   재빌드 ── 변경분만 ★
  └──────────────────────────────┘
                                   전송량: <span class="hljs-number">1.7</span>MB
</code></pre><p><strong>재빌드 시 전송량: 89.3MB → 1.7MB. 약 98% 감소.</strong></p>
<h3 id="heading-67mm65ocicsg7zg47iuciouypoy5mounio2bra">빌드 + 푸시 벤치마크</h3>
<p>말로만 하면 설득력이 없다. 코드를 한 줄씩 바꿔가며 10회 반복 빌드 + 레지스트리 푸시를 실측했다.</p>
<pre><code>측정 환경: 로컬 Docker 레지스트리 (registry:<span class="hljs-number">2</span>), <span class="hljs-number">10</span>회 반복, 코드 변경 후 재빌드
</code></pre><div class="hn-table">
<table>
<thead>
<tr>
<td>항목</td><td>Fat JAR</td><td>Layered JAR</td><td>개선</td></tr>
</thead>
<tbody>
<tr>
<td>빌드 시간 (평균)</td><td>3.34초</td><td>2.15초</td><td><strong>35.8%</strong></td></tr>
<tr>
<td>푸시 시간 (평균)</td><td>0.99초</td><td>0.79초</td><td><strong>19.9%</strong></td></tr>
<tr>
<td>빌드+푸시 합계 (평균)</td><td><strong>4.33초</strong></td><td><strong>2.94초</strong></td><td><strong>32.2%</strong></td></tr>
<tr>
<td>푸시 시간 표준편차</td><td>0.121초</td><td>0.036초</td><td>—</td></tr>
</tbody>
</table>
</div><p>주목할 점은 <strong>푸시 시간의 표준편차</strong>다. Fat JAR은 0.121초, Layered JAR은 0.036초. Layered 방식은 변경된 application 레이어(1.7MB)만 매번 동일하게 전송하므로 <strong>일관성이 높다.</strong> Fat JAR은 89.3MB 전체를 매번 전송하므로 I/O 상황에 따라 편차가 크다.</p>
<p>이 벤치마크는 로컬 레지스트리에서 측정한 것이라 네트워크 지연이 거의 없다. 실제 원격 레지스트리(Docker Hub, GHCR 등)에서는 차이가 훨씬 극적이다. 네트워크 대역폭 100Mbps 기준으로 추산하면:</p>
<ul>
<li>Fat JAR 푸시: 89.3MB ÷ 12.5MB/s ≈ <strong>~7.1초</strong></li>
<li>Layered JAR 푸시: 1.7MB ÷ 12.5MB/s ≈ <strong>~0.14초</strong></li>
</ul>
<h3 id="heading-cicd">CI/CD 파이프라인에서의 체감</h3>
<p>Docker 이미지를 빌드하고 레지스트리에 푸시하는 CI/CD 파이프라인에서, 레이어 캐싱의 효과는 극적이다. <strong>변경된 레이어만 I/O가 발생</strong>하기 때문이다.</p>
<div class="hn-table">
<table>
<thead>
<tr>
<td>시나리오</td><td>Fat JAR 전송량</td><td>Layered JAR 전송량</td><td>절감률</td></tr>
</thead>
<tbody>
<tr>
<td>코드만 변경</td><td>89.3MB</td><td>1.7MB</td><td><strong>98%</strong></td></tr>
<tr>
<td>SNAPSHOT 의존성 추가</td><td>89.3MB</td><td>~2MB</td><td>~97%</td></tr>
<tr>
<td>외부 의존성 변경</td><td>89.3MB</td><td>~90MB</td><td>동일</td></tr>
</tbody>
</table>
</div><p>일상적인 개발에서 가장 빈번한 시나리오는 <strong>코드만 변경</strong>하는 경우다. 하루에 10번 배포하는 팀이라면:</p>
<ul>
<li>Fat JAR 방식: 89.3MB × 10 = <strong>일일 893MB</strong> 전송</li>
<li>Layered JAR 방식: 1.7MB × 10 = <strong>일일 17MB</strong> 전송</li>
<li>한 달(20 영업일) 기준: <strong>약 17.9GB → 340MB</strong></li>
</ul>
<p>실측에서도 로컬 환경 기준 빌드+푸시가 4.33초 → 2.94초로 <strong>32% 단축</strong>되었다. 원격 레지스트리 환경에서는 네트워크 전송 비중이 커지면서 이 차이가 수 배로 벌어진다.</p>
<blockquote>
<p>레이어 캐싱의 핵심은 간단하다. <strong>변하지 않는 것은 다시 보내지 않는다.</strong> 코드만 바꿨다면, 코드만 보내면 된다.</p>
</blockquote>
<hr />
<h2 id="heading-6-jvm">6. 컨테이너 안의 JVM — 메모리를 제대로 인식시키기</h2>
<pre><code class="lang-dockerfile"><span class="hljs-keyword">ENTRYPOINT</span><span class="bash"> [<span class="hljs-string">"java"</span>, \
    <span class="hljs-string">"-XX:+UseContainerSupport"</span>, \
    <span class="hljs-string">"-XX:MaxRAMPercentage=75.0"</span>, \
    ...</span>
</code></pre>
<p>JVM은 원래 베어메탈 서버에서 태어났다. 호스트의 전체 CPU와 메모리를 자기 것으로 인식하는 게 기본 동작이다. 그런데 컨테이너 환경에서는 이것이 문제가 된다.</p>
<p>Docker는 Linux의 <strong>cgroup</strong>을 통해 컨테이너에 메모리 제한을 건다. 예를 들어, <code>--memory=1g</code>로 1GB를 할당했다고 하자. UseContainerSupport가 없는 구버전 JVM은 호스트의 전체 메모리(예: 16GB)를 보고 힙 크기를 계산한다. 그러면 할당된 1GB를 넘어서 메모리를 사용하려 하고, Docker는 이 컨테이너를 <strong>OOM Kill</strong>한다.</p>
<p><code>-XX:+UseContainerSupport</code>는 JVM이 cgroup의 메모리/CPU 제한을 인식하도록 한다. Java 10에서 도입되어 기본 활성화되었고, Java 8u191에도 백포트되었다. 즉, 현대 Java에서는 이미 기본값이다. 그런데도 나는 이것을 <strong>명시적으로 선언</strong>한다. 코드가 의도를 드러내야 하듯, 설정도 의도를 드러내야 한다고 생각하기 때문이다.</p>
<p><code>-XX:MaxRAMPercentage=75.0</code>은 컨테이너에 할당된 메모리의 75%를 JVM 힙으로 사용하라는 의미다. 왜 100%가 아닐까?</p>
<p>JVM은 힙만 쓰지 않는다. <strong>메타스페이스</strong>, <strong>스레드 스택</strong>, <strong>GC 오버헤드</strong>, <strong>네이티브 메모리</strong>, <strong>소켓 버퍼</strong> 등이 힙 바깥에서 메모리를 사용한다. 75%로 힙을 제한하고 나머지 25%를 이런 비힙 영역에 남겨두는 것이다.</p>
<blockquote>
<p>경험적으로 70~80%가 적절한 범위다. 너무 높이면 비힙 영역이 부족해 OOM이 발생하고, 너무 낮추면 힙이 부족해 GC 빈도가 올라간다.</p>
</blockquote>
<hr />
<h2 id="heading-7-non-root">7. Non-root 실행 — 최소 권한의 원칙</h2>
<pre><code class="lang-dockerfile"><span class="hljs-keyword">RUN</span><span class="bash"> groupadd -r appgroup &amp;&amp; useradd -r -g appgroup appuser &amp;&amp; chown -R appuser:appgroup /app</span>
...
<span class="hljs-keyword">USER</span> appuser
</code></pre>
<p>Docker 컨테이너의 기본 실행 사용자는 <strong>root</strong>다. 컨테이너 안의 root가 호스트의 root와 동일한 건 아니지만, 그래도 위험하다.</p>
<p>컨테이너 런타임의 취약점이 발견되면, root로 실행 중인 프로세스는 <strong>컨테이너 탈출(container escape)</strong> 시 호스트에 더 큰 영향을 줄 수 있다. 이것은 이론적인 위협이 아니라, 실제로 CVE가 보고된 바 있는 공격 벡터다.</p>
<p><strong>최소 권한의 원칙(Principle of Least Privilege)</strong>을 따라, 애플리케이션이 필요로 하는 최소한의 권한만 부여한다:</p>
<ol>
<li><code>groupadd -r appgroup</code> — 시스템 그룹 생성 (<code>-r</code>은 시스템 계정을 의미)</li>
<li><code>useradd -r -g appgroup appuser</code> — 시스템 사용자 생성, 홈 디렉토리 없이 최소 구성</li>
<li><code>chown -R appuser:appgroup /app</code> — 작업 디렉토리 소유권 부여</li>
<li><code>USER appuser</code> — 이후 모든 명령어를 이 사용자로 실행</li>
</ol>
<p>주의할 점은 <strong>USER 지시어의 위치</strong>다. <code>RUN</code>으로 패키지 설치나 사용자 생성 같은 root 권한이 필요한 작업을 모두 먼저 수행하고, 그 이후에 <code>USER appuser</code>로 전환한다. <code>COPY</code>에서는 <code>--chown=appuser:appgroup</code>으로 파일 소유권을 함께 지정하여, 별도의 <code>RUN chown</code> 없이도 올바른 권한을 설정한다.</p>
<hr />
<h2 id="heading-8-devurandom">8. /dev/./urandom — 아직도 필요한가?</h2>
<pre><code class="lang-dockerfile"><span class="hljs-string">"-Djava.security.egd=file:/dev/./urandom"</span>
</code></pre>
<p>이 설정은 Java의 <code>SecureRandom</code>이 난수를 생성할 때 사용하는 <strong>엔트로피 소스</strong>를 지정한다.</p>
<p>Linux에는 두 가지 난수 소스가 있다:</p>
<ul>
<li><code>/dev/random</code> — 충분한 엔트로피가 모일 때까지 <strong>블로킹</strong>된다</li>
<li><code>/dev/urandom</code> — 블로킹 없이 유사 난수를 생성한다</li>
</ul>
<p>컨테이너 환경은 물리적 입력 장치가 없어 엔트로피 수집이 느리다. 초기 Java 버전에서는 <code>SecureRandom</code>이 <code>/dev/random</code>을 사용해서, 애플리케이션 시작 시 수십 초간 멈추는 문제가 있었다. 특히 TLS 핸드셰이크나 세션 ID 생성 같은 곳에서 <code>SecureRandom</code>이 호출되므로, 이 지연은 실질적인 영향을 미쳤다.</p>
<p>그런데 왜 <code>/dev/urandom</code>이 아니라 <code>/dev/./urandom</code>일까? 경로에 <code>./</code>가 끼어 있는 이유가 있다.</p>
<p>JDK 8 이전에는 <code>SeedGenerator</code> 클래스의 초기화 과정에서 문제가 있었다. <code>securerandom.source</code> 속성값이 <code>file:/dev/urandom</code>과 정확히 일치하면, 내부적으로 항상 <code>/dev/random</code>에서 읽는 <code>NativeSeedGenerator</code>를 사용했다. 즉, <code>/dev/urandom</code>을 지정해도 실제로는 <code>/dev/random</code>이 사용되는 <strong>문자열 매칭 기반의 분기 로직 문제</strong>였다. <code>file:/dev/./urandom</code>은 이 exact string matching을 피하면서도, OS 레벨에서는 같은 <code>/dev/urandom</code> 장치를 가리키는 <strong>워크어라운드</strong>였다.</p>
<p><strong>JDK 8에서 이 문제는 수정되었다.</strong> 그리고 현대 Java의 기본 구현인 NativePRNG는 난수 생성(<code>nextBytes()</code>)에는 <code>/dev/urandom</code>을, 시드 생성(<code>generateSeed()</code>)에는 <code>/dev/random</code>을 사용한다. 즉, 용도에 따라 적절한 소스를 자동으로 선택한다. 그렇다면 이 설정은 불필요한 것인가?</p>
<p>솔직히 말하면, JDK 24를 사용하는 이 Dockerfile에서 이 설정은 <strong>기술적으로 불필요하다.</strong> 하지만 나는 이것을 <strong>방어적 설정</strong>으로 남겨두었다. 다양한 환경에서 실행될 가능성, 베이스 이미지의 <code>java.security</code> 설정이 변경될 가능성을 고려한 것이다. 해가 되지 않는 설정이라면, 한 줄의 보험으로 남겨두는 편이 나의 성향이다.</p>
<p>다만, 이 선택에 대해서는 의견이 갈릴 수 있다. 불필요한 설정은 제거하는 것이 깔끔하다는 주장도 충분히 합리적이다.</p>
<hr />
<h2 id="heading-66ei66y066as">마무리</h2>
<p>Dockerfile은 단순한 빌드 스크립트가 아니다. 그 안에는 <strong>보안, 성능, 운영 효율성</strong>에 대한 수많은 결정이 녹아 있다.</p>
<p>이 글에서 다룬 각 설정의 의미를 정리하면:</p>
<ul>
<li><strong>멀티 스테이지 빌드</strong> → 불필요한 도구를 프로덕션에서 제거</li>
<li><strong>JRE 사용</strong> → 공격 표면 축소 + 이미지 경량화</li>
<li><strong>레이어 추출</strong> → Docker 캐시 최적화로 빌드/배포 속도 향상</li>
<li><strong>COPY 순서</strong> → 변경 빈도 기반 레이어 배치</li>
<li><strong>레이어 비교</strong> → 코드 변경 시 전송량 89.3MB에서 1.7MB로 98% 감소</li>
<li><strong>UseContainerSupport + MaxRAMPercentage</strong> → 컨테이너 환경에서의 안정적 메모리 관리</li>
<li><strong>Non-root 실행</strong> → 최소 권한 원칙으로 보안 강화</li>
<li><strong>/dev/./urandom</strong> → 방어적 설정으로 엔트로피 블로킹 방지</li>
</ul>
<p>Dockerfile을 작성할 때 "동작하는 것"에서 멈추지 말고, 한 줄 한 줄 "왜?"를 물어보자. 그 질문들이 모여 프로덕션에서 견고하게 살아남는 이미지를 만든다.</p>
<hr />
<h2 id="heading-7lc46rogioyekoujja">참고 자료</h2>
<ul>
<li><a target="_blank" href="https://docs.spring.io/spring-boot/reference/packaging/container-images/dockerfiles.html">Dockerfiles :: Spring Boot</a> — Spring Boot 공식 Docker 가이드</li>
<li><a target="_blank" href="https://www.docker.com/blog/9-tips-for-containerizing-your-spring-boot-code/">9 Tips for Containerizing Your Spring Boot Code | Docker</a> — Docker 공식 블로그의 Spring Boot 컨테이너화 팁</li>
<li><a target="_blank" href="https://www.javathinking.com/blog/what-does-usecontainersupport-vm-parameter-do/">What Does the UseContainerSupport VM Parameter Do in Docker?</a> — UseContainerSupport 파라미터 상세 설명</li>
<li><a target="_blank" href="https://dzone.com/articles/best-practices-java-memory-arguments-for-container">Best Practices: Java Memory Arguments for Containers</a> — 컨테이너 환경 JVM 메모리 설정 가이드</li>
<li><a target="_blank" href="https://www.docker.com/blog/understanding-the-docker-user-instruction/">Understanding the Docker USER Instruction | Docker</a> — Docker USER 지시어와 보안</li>
<li><a target="_blank" href="https://www.baeldung.com/docker-layers-spring-boot">Reusing Docker Layers with Spring Boot | Baeldung</a> — Spring Boot Docker 레이어 재사용</li>
</ul>
]]></content:encoded></item><item><title><![CDATA[높은 생산성을 위한 Harness 환경 구성]]></title><description><![CDATA[모델은 같은데, 왜 결과가 다를까?
같은 팀, 같은 모델, 같은 IDE. 그런데 A 엔지니어는 10분 만에 복잡한 리팩토링을 끝내고, B 엔지니어는 1시간을 할루시네이션과 씨름한다. 이 차이는 코딩 실력에서 오는 것이 아니다.
LangChain 팀이 이를 증명했다. Terminal Bench 2.0에서 모델을 바꾸지 않고 환경만 개선했더니 52.8%에서 6]]></description><link>https://blog.hyunjun.org/harness</link><guid isPermaLink="true">https://blog.hyunjun.org/harness</guid><category><![CDATA[AI]]></category><category><![CDATA[Developer Tools]]></category><category><![CDATA[Productivity]]></category><dc:creator><![CDATA[조현준]]></dc:creator><pubDate>Sun, 15 Mar 2026 07:52:33 GMT</pubDate><content:encoded><![CDATA[<h2>모델은 같은데, 왜 결과가 다를까?</h2>
<p>같은 팀, 같은 모델, 같은 IDE. 그런데 A 엔지니어는 10분 만에 복잡한 리팩토링을 끝내고, B 엔지니어는 1시간을 할루시네이션과 씨름한다. 이 차이는 코딩 실력에서 오는 것이 아니다.</p>
<p>LangChain 팀이 이를 증명했다. Terminal Bench 2.0에서 <strong>모델을 바꾸지 않고 환경만 개선했더니 52.8%에서 66.5%로 성능이 뛰었다.</strong> 모델은 그대로인데, 에이전트가 동작하는 환경을 바꾸자 결과가 달라진 것이다.</p>
<p>2025년이 에이전트의 해였다면, 2026년은 <strong>에이전트 하네스의 해</strong>다. 이제 "어떤 모델을 쓸 것인가"보다 "에이전트가 동작하는 환경을 어떻게 설계할 것인가"가 생산성을 결정한다.</p>
<hr />
<h2>Harness란 무엇인가</h2>
<p>Harness는 말의 고삐에서 온 비유다. AI 모델은 강력하지만 예측 불가능한 말이다. 고삐 없이 놓아두면 아무 방향으로 달린다. 하네스는 그 힘을 원하는 방향으로 이끄는 장치다.</p>
<p>Terraform의 창시자 Mitchell Hashimoto가 2026년 2월 자신의 블로그에서 이 개념을 체계적으로 정리하며 대중화했다.</p>
<blockquote>
<p>"에이전트가 실수할 때마다, 그 실수를 다시는 하지 않도록 환경을 엔지니어링한다."</p>
</blockquote>
<p>구체적으로 하네스는 에이전트가 동작하는 <strong>제약, 도구, 문서, 피드백 루프의 총체</strong>다. CLAUDE.md 같은 설정 파일, pre-commit hook, 커스텀 린터, slash command, MCP 서버 — 이 모든 것이 하네스를 구성한다.</p>
<p>이전 글에서 다뤘던 Context Engineering이 "무엇을 줄 것인가"의 문제였다면, Harness Engineering은 "어떤 환경에서 동작하게 할 것인가"의 문제다. 컨텍스트는 하네스의 한 구성 요소이고, 하네스는 컨텍스트를 포함한 전체 시스템이다.</p>
<hr />
<h2>Anthropic의 접근: 장기 실행 에이전트를 위한 하네스</h2>
<p>Anthropic은 "Effective Harnesses for Long-Running Agents"에서 핵심 문제를 짚었다. <strong>에이전트가 여러 컨텍스트 윈도우에 걸쳐 일관된 진행을 유지하지 못한다.</strong> 새 세션이 시작되면 이전 작업의 기억이 없고, 토큰을 낭비하며 재탐색하거나 이전 진행을 되돌린다.</p>
<h3>2단계 아키텍처</h3>
<p>이 문제를 해결하기 위해 Anthropic은 에이전트를 둘로 나눴다.</p>
<p><strong>Initializer Agent</strong> — 첫 세션에서만 실행되며 작업 환경을 구축한다.</p>
<ul>
<li><p><code>init.sh</code>: 개발 서버를 띄우는 스크립트</p>
</li>
<li><p><code>claude-progress.txt</code>: 작업 이력을 기록하는 파일</p>
</li>
<li><p>Feature list (JSON): 구현할 기능 200개 이상을 사전 분해</p>
</li>
<li><p>초기 git commit: 추가된 파일들의 스냅샷</p>
</li>
</ul>
<p><strong>Coding Agent</strong> — 이후 세션마다 실행되며, 세션당 하나의 기능만 구현한다.</p>
<p>매 세션의 시작 패턴은 동일하다:</p>
<pre><code class="language-plaintext">pwd → claude-progress.txt 읽기 → feature_list.json 확인 → git log 확인
→ init.sh로 dev server 시작 → 기존 테스트 통과 확인 → 다음 기능 구현
</code></pre>
<h3>왜 JSON인가</h3>
<p>Feature list를 Markdown이 아닌 JSON으로 관리하는 것은 의도적인 설계다. 에이전트는 Markdown의 자유로운 형식에서 테스트 항목을 삭제하거나 수정하는 경향이 있다. JSON은 구조가 엄격해서 에이전트가 <code>passes</code> 필드 외에는 건드리기 어렵다.</p>
<pre><code class="language-json">{
  "category": "functional",
  "description": "새 채팅 버튼이 새 대화를 생성한다",
  "steps": ["메인 인터페이스 이동", "버튼 클릭", "생성 확인"],
  "passes": false
}
</code></pre>
<p>에이전트는 이 <code>passes</code> 값만 바꿀 수 있다. 테스트 자체를 삭제하거나 변경하는 것은 프롬프트에서 명시적으로 금지한다.</p>
<h3>Git을 상태 복구 도구로</h3>
<p>Anthropic이 발견한 가장 효과적인 패턴은 <strong>git을 에이전트의 상태 복구 도구로 활용하는 것</strong>이다. 에이전트에게 매 작업마다 설명적인 커밋 메시지로 커밋하게 하면, 나쁜 변경이 생겼을 때 <code>git revert</code>로 작동하던 상태로 되돌릴 수 있다.</p>
<p>progress 파일과 git history의 조합으로, 새 세션의 에이전트는 이전에 무슨 일이 있었는지 추측하느라 시간을 낭비하지 않는다. 명확한 상태와 이력이 있으니 바로 다음 작업에 들어간다.</p>
<hr />
<h2>토스의 접근: 팀의 생산성 저점을 올리는 하네스</h2>
<p>토스 기술 블로그의 "Software 3.0 시대, Harness를 통한 조직 생산성 저점 높이기"는 다른 각도에서 같은 문제를 본다. Anthropic이 기술적 아키텍처에 집중했다면, 토스는 <strong>조직의 역량 편차</strong>에 집중한다.</p>
<h3>문제: 각자도생</h3>
<p>현재 많은 팀이 LLM을 도입했지만 실상은 "각자도생"이다. A 엔지니어는 작업 전에 레포의 코딩 가이드라인, lint 규칙, 기존 패턴을 에이전트에 주입한다. B 엔지니어는 단순 질문으로 시작해 수정 루프에 갇힌다. 도구는 같은데 결과가 다르다.</p>
<p>이 차이를 개인 역량에 맡겨두면, 팀 전체의 생산성은 가장 느린 사람에 의해 결정된다.</p>
<h3>해법: 플러그인을 하네스로</h3>
<p>토스는 Claude Code의 플러그인 생태계를 하네스로 활용한다. 핵심은 세 가지 특성이다.</p>
<p><strong>Frictionless Integration</strong> — 브라우저로 나가서 챗봇에 코드를 붙여넣는 문맥 교환 비용을 없앤다. 터미널 안에서 자연어와 코드가 끊김 없이 섞인다.</p>
<p><strong>Executable SSOT (실행 가능한 단일 진실 공급원)</strong> — Wiki나 Notion 문서는 작성되는 순간부터 낡는다. 하지만 플러그인 형태의 지식은 사람이 읽으면 업무 가이드라인이 되고, LLM이 읽으면 시스템 프롬프트가 된다. 플러그인 코드를 업데이트하면 팀원 모두의 에이전트 행동이 즉시 바뀐다.</p>
<p><strong>저점 상향 평준화</strong> — oh-my-zsh처럼 누군가 미리 고민해둔 베스트 프랙티스를 즉시 가져다 쓸 수 있다. 하지만 여기서 한 발 더, 팀의 도메인 맥락을 반영한 특화된 플러그인이 핵심이다.</p>
<h3>3-Layer 아키텍처</h3>
<p>토스는 지식을 세 계층으로 분리한다.</p>
<table>
<thead>
<tr>
<th>Layer</th>
<th>범위</th>
<th>예시</th>
</tr>
</thead>
<tbody><tr>
<td><strong>Global</strong></td>
<td>전사 공통</td>
<td>보안 정책, 기본 코딩 스타일</td>
</tr>
<tr>
<td><strong>Domain</strong></td>
<td>팀/비즈니스별</td>
<td>결제 도메인 로직, 정산 규칙</td>
</tr>
<tr>
<td><strong>Local</strong></td>
<td>레포지토리 특화</td>
<td>프로젝트별 구현 디테일</td>
</tr>
</tbody></table>
<p>신입에게 전사 문서를 통째로 던지지 않듯, LLM에게도 현재 작업에 필요한 지식만 주입한다. 이 계층화된 플러그인들이 모이면 별도의 RAG 시스템 없이도 살아있는 지식 베이스가 된다.</p>
<h3>노하우의 민주화</h3>
<p>가장 인상적인 부분은 <strong>팀 최고 엔지니어의 워크플로우를 slash command로 배포</strong>하는 패턴이다.</p>
<pre><code class="language-plaintext">/new-feature 입력
→ Claude가 구현 기능의 맥락 수집
→ Jira 이슈 발급, 브랜치 생성, 구현 계획 작성
→ 엔지니어 검토/승인
→ 구현 시작
</code></pre>
<p>B 엔지니어도 <code>/new-feature</code> 하나로 A 엔지니어와 동일한 품질의 워크플로우를 실행한다. LLM 활용 능력이 더 이상 개인의 센스 영역이 아니라, 팀이 설계하고 배포하는 시스템의 영역으로 넘어간다.</p>
<hr />
<h2>Harness Engineering의 4대 요소</h2>
<p>Anthropic, 토스, OpenAI, Stripe 등의 사례를 종합하면 효과적인 하네스는 네 가지 요소로 구성된다.</p>
<h3>1. Architecture as Guardrails: 구조가 곧 제약</h3>
<p>여기서 짚고 넘어가야 할 것이 있다. CLAUDE.md에 "이 프로젝트는 헥사고날 아키텍처를 따릅니다"라고 적으면 에이전트가 지켜줄까? <strong>아니다.</strong> CLAUDE.md는 본질적으로 비강제(non-enforcing)다. 에이전트는 할루시네이션에 의해 규칙을 잊거나 무시할 수 있다. "하지 마세요"라는 지시는 확률적으로 무시될 수 있지만, 빌드가 깨지는 것은 무시할 수 없다.</p>
<p>이것이 ArchUnit, Konsist 같은 <strong>아키텍처 테스트 도구</strong>가 하네스에서 핵심적인 이유다. 컨벤션을 문서가 아니라 코드로 강제한다.</p>
<p>Java 진영의 ArchUnit은 아키텍처 규칙을 단위 테스트로 작성한다:</p>
<pre><code class="language-java">@Test
void 도메인_레이어는_인프라에_의존하지_않는다() {
    noClasses()
        .that().resideInAPackage("..domain..")
        .should().dependOnClassesThat()
        .resideInAPackage("..infrastructure..")
        .check(importedClasses);
}
</code></pre>
<p>Kotlin 프로젝트라면 Konsist가 같은 역할을 한다. 네이밍 컨벤션, 패키지 구조, 클래스 가시성, 의존성 방향까지 — 코딩 규칙을 테스트 코드로 표현한다:</p>
<pre><code class="language-kotlin">@Test
fun `UseCase 클래스는 반드시 execute 메서드를 가진다`() {
    Konsist.scopeFromProject()
        .classes()
        .withNameEndingWith("UseCase")
        .assertTrue { it.hasFunction { func -&gt; func.name == "execute" } }
}
</code></pre>
<p>에이전트가 이 규칙을 어기면 <strong>테스트가 실패하고 빌드가 깨진다.</strong> 에이전트는 빌드 실패를 감지하면 스스로 수정한다. CLAUDE.md의 "~하지 마세요"는 무시할 수 있지만, 빨간 테스트는 무시할 수 없다. 이것이 "문서로 안내"와 "코드로 강제"의 결정적 차이다.</p>
<p>무신사도 이와 같은 접근을 적용하고 있다. AI 에이전트를 도입하면서 기존의 아키텍처 테스트와 린터를 하네스의 일부로 활용한다. 에이전트가 생성한 코드가 팀의 컨벤션을 위반하면 CI에서 잡히고, 에이전트가 자동으로 수정한다. 사람이 리뷰할 때쯤이면 이미 컨벤션을 준수한 코드가 나온다.</p>
<p>OpenAI도 같은 원리를 적용했다. Codex 에이전트로 <strong>수동 코드 0줄, 100만 줄 코드베이스</strong>를 5개월간 3명의 엔지니어로 만들었다. 이것이 가능했던 핵심은 엄격한 아키텍처 제약이다.</p>
<p>의존성 방향을 기계적으로 강제했다:</p>
<pre><code class="language-plaintext">Types → Config → Repo → Service → Runtime → UI
</code></pre>
<p>에이전트는 이 레이어 안에서만 동작하고, 커스텀 린터가 구조적 위반을 자동으로 잡는다. 직관에 반하지만, <strong>AI가 생성하는 코드는 자유도를 줄여야 품질이 올라간다.</strong> Vercel도 처음엔 풍부한 도구 라이브러리를 제공했다가, 오히려 도구를 줄이고 선택지를 단순화하자 에이전트가 더 빠르고 안정적으로 동작했다.</p>
<p>정리하면, 컨벤션 강제의 스펙트럼은 이렇다:</p>
<table>
<thead>
<tr>
<th>수단</th>
<th>강제력</th>
<th>예시</th>
</tr>
</thead>
<tbody><tr>
<td>CLAUDE.md</td>
<td>약함 (비강제, 할루시네이션으로 무시 가능)</td>
<td>"헥사고날 아키텍처를 따릅니다"</td>
</tr>
<tr>
<td>Hook</td>
<td>중간 (특정 액션 시점에 검증)</td>
<td>commit 시 브랜치명 검사, lint 실행</td>
</tr>
<tr>
<td>ArchUnit / Konsist</td>
<td>강함 (빌드 실패로 강제)</td>
<td>레이어 의존성 위반 시 테스트 실패</td>
</tr>
<tr>
<td>커스텀 린터</td>
<td>강함 (에러 메시지가 수정 방법까지 안내)</td>
<td>구조적 위반 감지 + 에이전트 자동 수정</td>
</tr>
</tbody></table>
<p>효과적인 하네스는 이 수단들을 조합한다. 안내는 CLAUDE.md로, 검증은 hook으로, 강제는 아키텍처 테스트로.</p>
<h3>2. Tools as Foundation: 도구가 곧 기반</h3>
<p>에이전트에게는 인간 엔지니어와 동일한 도구 접근권이 필요하다.</p>
<p>Stripe의 Minions는 <strong>약 500개의 내부 도구를 MCP 서버로 노출</strong>해서 사전 준비된 샌드박스 환경에서 에이전트가 동작하게 한다. 개발자가 Slack에 태스크를 올리면, 에이전트가 코드를 작성하고, CI를 통과시키고, 리뷰 가능한 PR을 열어준다. 코딩 과정에서 인간의 개입은 없다. 최종 PR은 사람이 리뷰한 뒤 머지되며, 주간 1,000개 이상의 PR이 이 방식으로 처리된다.</p>
<p>핵심 인사이트: <strong>커스텀 린터의 에러 메시지가 이중 목적을 수행한다.</strong> 위반을 표시하면서 동시에 에이전트에게 수정 방법을 알려준다. 도구가 교육 기제가 되는 것이다.</p>
<h3>3. Documentation as Living System: 문서가 곧 인프라</h3>
<p>CLAUDE.md(또는 AGENTS.md)는 단순한 문서가 아니라 <strong>살아있는 인프라</strong>다.</p>
<p>Hashimoto의 Ghostty 프로젝트에서 AGENTS.md는 빌드 명령어, 구조적 안내와 함께 <strong>과거 에이전트가 저질렀던 실패를 방지하는 규칙들</strong>을 담고 있다. 에이전트가 실수할 때마다 그 실수를 방지하는 규칙이 문서에 추가된다. Hashimoto 본인도 이 규칙들을 추가한 뒤 문제가 "거의 완전히 해결되었다"고 밝혔다.</p>
<p>OpenAI는 여기서 한 발 더 나아가, <strong>백그라운드 에이전트가 주기적으로 문서를 스캔해서 오래된 내용을 감지하고 정리 PR을 자동으로 여는</strong> 구조를 만들었다.</p>
<p>효과적인 문서의 원칙:</p>
<ul>
<li><p>에이전트가 실패할 때마다 업데이트</p>
</li>
<li><p>코드에서 유추할 수 없는 정보만 포함</p>
</li>
<li><p>모노레포에서는 중첩된 CLAUDE.md로 팀/패키지별 컨텍스트 분리</p>
</li>
</ul>
<h3>4. Verification &amp; Feedback Loops: 검증과 피드백 순환</h3>
<p>에이전트의 가장 흔한 실패 모드는 <strong>기능을 제대로 테스트하지 않고 완료로 표시하는 것</strong>이다.</p>
<p>Anthropic은 이를 해결하기 위해:</p>
<ul>
<li><p>Feature list를 JSON으로 구조화하여 에이전트가 테스트 항목을 삭제하지 못하게 함</p>
</li>
<li><p>브라우저 자동화(Puppeteer MCP)로 사용자 관점의 E2E 테스트 강제</p>
</li>
<li><p>스크린샷을 통한 시각적 버그 감지 (Vision 활용)</p>
</li>
</ul>
<p>피드백 루프에서 중요한 것은 <strong>성공은 조용히, 실패만 상세하게</strong>다. 매번 전체 테스트 결과를 출력하면 컨텍스트 윈도우가 넘친다. 통과한 테스트는 무시하고 실패한 테스트만 상세 로그를 보여줘야 한다.</p>
<hr />
<h2>실전: 나만의 하네스 구성하기</h2>
<p>이론은 충분하다. 실제로 하네스를 구성하는 방법을 살펴보자.</p>
<h3>CLAUDE.md: 하네스의 시작점</h3>
<p>CLAUDE.md는 하네스의 가장 기본적인 구성 요소다. 에이전트가 세션을 시작할 때 자동으로 읽는 프로젝트 설명서이자 행동 지침이다.</p>
<p>하지만 CLAUDE.md에 대한 가장 흔한 실수는 <strong>코드에서 이미 알 수 있는 것을 적는 것</strong>이다. "이 프로젝트는 Spring Boot를 사용합니다", "패키지 구조는 domain/application/infrastructure입니다" — 에이전트는 코드를 읽으면 이것을 안다. 이런 내용은 토큰만 낭비할 뿐 아니라, 코드와 문서가 어긋나면 오히려 혼란을 준다.</p>
<p>반대로, 코드에서 절대 알 수 없는 것은 반드시 적어야 한다:</p>
<pre><code class="language-markdown"># 빌드 &amp; 테스트
./gradlew build              # 전체 빌드 (ArchUnit 테스트 포함)
./gradlew test --tests "*UseCase*"  # UseCase 단위 테스트만
docker compose up -d          # 로컬 DB/Redis 실행

# 배포
dev 환경: main 브랜치 push 시 자동 배포
prod 환경: 릴리스 태그 생성 시 배포 (수동 승인 필요)

# 작업 규칙
- PR은 반드시 Jira 티켓 번호를 포함: feat/PROJ-123-description
- hotfix 외에는 main 직접 커밋 금지
</code></pre>
<p>CLI 명령어, 배포 절차, 브랜치 전략, 외부 시스템 연동 방법 — 이런 것들은 코드를 아무리 읽어도 알 수 없다. 이것이 CLAUDE.md에 담아야 할 내용이다.</p>
<p>코드 패턴이나 아키텍처 규칙은? CLAUDE.md에 쓰지 말고 <strong>ArchUnit이나 Konsist 테스트로 강제</strong>하라. 에이전트가 규칙을 어기면 빌드가 깨지고, 에이전트가 스스로 고친다. 문서에 적는 것보다 확실하고, 문서가 낡을 걱정도 없다.</p>
<p>핵심 원칙은 두 가지다:</p>
<ul>
<li><p><strong>"코드에서 알 수 있는가?"</strong> → 알 수 있으면 적지 않는다</p>
</li>
<li><p><strong>"이것이 없으면 에이전트가 실패하는가?"</strong> → 실패하지 않으면 적지 않는다</p>
</li>
</ul>
<h3>Hooks: CLAUDE.md가 안내라면, Hook은 강제다</h3>
<p>CLAUDE.md에 "main 브랜치에 직접 커밋하지 마세요"라고 적어도, 에이전트는 이를 무시하고 main에 커밋할 수 있다. 하지만 hook은 다르다. 물리적으로 차단한다.</p>
<p>토스의 사례:</p>
<pre><code class="language-plaintext">Claude가 git commit 시도
→ Hook이 현재 브랜치 검사
→ "현재 main 브랜치입니다. feature/ 브랜치 생성 후 작업하겠습니다"
→ 자동 교정
</code></pre>
<p>Claude Code의 hook 시스템이나 pre-commit hook으로 이런 검증을 걸 수 있다:</p>
<ul>
<li><p><strong>커밋 시점</strong>: 브랜치명 규칙 검사, lint 실행, ArchUnit/Konsist 테스트 실행</p>
</li>
<li><p><strong>파일 수정 시점</strong>: 금지된 파일(.env, 설정 파일) 수정 차단</p>
</li>
<li><p><strong>명령 실행 시점</strong>: 위험한 명령어(rm -rf, DROP TABLE) 차단</p>
</li>
</ul>
<p>핵심은 <strong>안내와 강제의 역할 분리</strong>다. CLAUDE.md는 에이전트에게 방향을 알려주고, hook과 아키텍처 테스트는 그 방향을 벗어나지 못하게 강제한다. 안내만으로는 부족하고, 강제만으로는 맥락이 없다. 둘 다 필요하다.</p>
<h3>Skills &amp; Slash Commands: 워크플로우 패키징</h3>
<p>반복되는 워크플로우를 스킬로 패키징하면 팀 전체의 역량 바닥이 올라간다.</p>
<p>좋은 스킬의 조건:</p>
<ul>
<li><p><strong>단일 책임</strong>: 하나의 스킬은 하나의 워크플로우만 담당</p>
</li>
<li><p><strong>승인 게이트</strong>: 에이전트가 잘못된 방향으로 달리기 전에 사람이 검토</p>
</li>
<li><p><strong>검증 단계 내장</strong>: 결과물을 자체적으로 확인하는 단계 포함</p>
</li>
</ul>
<h3>MCP 서버: 도구 확장</h3>
<p>Model Context Protocol로 에이전트에게 외부 도구 접근권을 제공한다. Jira 연동, Slack 알림, 데이터베이스 조회, 모니터링 대시보드 확인 등 — 인간 엔지니어가 쓰는 도구를 에이전트도 쓸 수 있게 한다.</p>
<h3>settings.local.json: 권한 설계</h3>
<p>에이전트의 권한을 명시적으로 설계한다. 모든 것을 허용하는 것도, 모든 것을 차단하는 것도 답이 아니다.</p>
<pre><code class="language-json">{
  "permissions": {
    "allow": [
      "Read", "Edit", "Write",
      "Bash(git diff *)", "Bash(git commit *)",
      "WebSearch"
    ],
    "deny": [
      "Bash(git push *)"
    ]
  }
}
</code></pre>
<p>안전한 작업은 자동 허용하고, 돌이킬 수 없는 작업(push, 프로덕션 배포 등)은 차단하거나 승인 게이트를 둔다.</p>
<hr />
<h2>실제 성과로 증명된 패턴</h2>
<p>하네스 엔지니어링은 이론이 아니다. 실제 성과가 이를 뒷받침한다.</p>
<table>
<thead>
<tr>
<th>사례</th>
<th>방식</th>
<th>성과</th>
</tr>
</thead>
<tbody><tr>
<td><strong>OpenAI 내부</strong></td>
<td>3명, 수동 코드 0줄, 아키텍처 제약 + 커스텀 린터</td>
<td>5개월간 100만 줄, 일평균 3.5 PR/인</td>
</tr>
<tr>
<td><strong>Stripe Minions</strong></td>
<td>~500 도구 MCP 노출, 샌드박스 환경</td>
<td>주간 1,000+ PR 처리, 코딩 과정 자동화</td>
</tr>
<tr>
<td><strong>Peter Steinberger</strong></td>
<td>4-10 에이전트 동시 운용, 아키텍처 감독 집중</td>
<td>1인 월 6,600+ 커밋</td>
</tr>
<tr>
<td><strong>LangChain</strong></td>
<td>모델 변경 없이 하네스만 개선</td>
<td>52.8% → 66.5% (Terminal Bench 2.0)</td>
</tr>
</tbody></table>
<p>공통점이 보인다. <strong>모델을 바꾸지 않았다.</strong> 환경을 바꿨다. 제약을 설계했다. 도구를 정비했다. 문서를 살아있게 만들었다. 그러자 같은 모델에서 다른 결과가 나왔다.</p>
<hr />
<h2>모델은 commodity, 하네스가 성패를 가른다</h2>
<p>이 글의 핵심을 한 문장으로 요약하면 이것이다.</p>
<blockquote>
<p><strong>에이전트의 결과 품질은 모델이 아니라 환경이 결정한다.</strong></p>
</blockquote>
<p>Claude든 GPT든 Gemini든, 모델의 성능 차이보다 그 모델이 동작하는 환경의 설계가 결과에 더 큰 영향을 미친다. 빈 컨텍스트에 강력한 모델을 놓으면 할루시네이션과 삽질이 나오고, 잘 설계된 하네스에 적당한 모델을 놓으면 일관된 고품질 산출물이 나온다.</p>
<p>이전 글에서 "스킬은 바닥을 올리고, 사람은 천장을 결정한다"고 했다. 하네스는 그 스킬을 포함한 더 큰 그림이다. CLAUDE.md, hooks, skills, MCP, 권한 설계, 아키텍처 제약, 피드백 루프 — 이 모든 것이 하나의 시스템으로 엮일 때 에이전트는 비로소 안정적으로 동작한다.</p>
<p>오늘 당장 할 수 있는 것부터 시작해보자. 에이전트가 실수할 때마다 CLAUDE.md에 한 줄을 추가하는 것. 이것이 하네스 엔지니어링의 첫걸음이다.</p>
<hr />
<h3>참고 자료</h3>
<ul>
<li><p><a href="https://www.anthropic.com/engineering/effective-harnesses-for-long-running-agents">Effective Harnesses for Long-Running Agents — Anthropic Engineering</a> — 장기 실행 에이전트를 위한 2단계 하네스 아키텍처</p>
</li>
<li><p><a href="https://toss.tech/article/harness-for-team-productivity">Software 3.0 시대, Harness를 통한 조직 생산성 저점 높이기 — 토스 기술 블로그</a> — 팀 단위 하네스 전략과 플러그인 마켓플레이스</p>
</li>
<li><p><a href="https://martinfowler.com/articles/exploring-gen-ai/harness-engineering.html">Harness Engineering — Martin Fowler</a> — OpenAI 사례 분석과 하네스 엔지니어링 개요</p>
</li>
<li><p><a href="https://www.ignorance.ai/p/the-emerging-harness-engineering">The Emerging "Harness Engineering" Playbook</a> — 4대 구성 요소와 실전 사례 종합</p>
</li>
<li><p><a href="https://www.anthropic.com/engineering/building-agents-with-the-claude-agent-sdk">Building Agents with the Claude Agent SDK — Anthropic Engineering</a> — Claude Agent SDK와 하네스의 관계</p>
</li>
<li><p><a href="https://www.humanlayer.dev/blog/skill-issue-harness-engineering-for-coding-agents">Skill Issue: Harness Engineering for Coding Agents — HumanLayer</a> — 코딩 에이전트 하네스의 실전 패턴</p>
</li>
<li><p><a href="https://techblog.musinsa.com/%EC%84%A4-%EC%97%B0%ED%9C%B4%EC%97%90-claude-code-agent-teams%EB%A5%BC-%EB%8D%B0%EB%A0%A4%EA%B0%94%EC%8A%B5%EB%8B%88%EB%8B%A4-fa96286f6954">설 연휴에 Claude Code Agent Teams를 데려갔습니다 — 무신사 테크 블로그</a> — 무신사의 AI 에이전트 실전 적용 사례</p>
</li>
<li><p><a href="https://docs.konsist.lemonappdev.com/">Konsist — Kotlin Architectural Linter</a> — Kotlin 프로젝트의 아키텍처 규칙을 테스트로 강제</p>
</li>
<li><p><a href="https://www.archunit.org/">ArchUnit — Unit Test Your Java Architecture</a> — Java 아키텍처 규칙을 단위 테스트로 검증</p>
</li>
</ul>
]]></content:encoded></item><item><title><![CDATA[같은 AI에게 다른 결과를 얻는 법: Claude Code 스킬과 Context Engineering]]></title><description><![CDATA[AI 코딩 도구의 불편한 진실
요즘 AI 코딩 도구를 쓰지 않는 개발자를 찾기 어렵다. 하지만 솔직히 말하면, 대부분의 개발자가 비슷한 불만을 갖고 있다.
"어제는 잘 해줬는데 오늘은 왜 이러지?"
같은 도구, 같은 요청인데 결과가 들쭉날쭉하다. CLAUDE.md를 작성해달라고 하면 어떤 날은 프로젝트에 딱 맞는 걸 만들어주고, 어떤 날은 어디서 복사해온 ]]></description><link>https://blog.hyunjun.org/ai-claude-code-context-engineering</link><guid isPermaLink="true">https://blog.hyunjun.org/ai-claude-code-context-engineering</guid><category><![CDATA[AI]]></category><category><![CDATA[claude]]></category><category><![CDATA[Developer Tools]]></category><dc:creator><![CDATA[조현준]]></dc:creator><pubDate>Sat, 14 Mar 2026 14:58:55 GMT</pubDate><content:encoded><![CDATA[<h2>AI 코딩 도구의 불편한 진실</h2>
<p>요즘 AI 코딩 도구를 쓰지 않는 개발자를 찾기 어렵다. 하지만 솔직히 말하면, 대부분의 개발자가 비슷한 불만을 갖고 있다.</p>
<p>"어제는 잘 해줬는데 오늘은 왜 이러지?"</p>
<p>같은 도구, 같은 요청인데 결과가 들쭉날쭉하다. CLAUDE.md를 작성해달라고 하면 어떤 날은 프로젝트에 딱 맞는 걸 만들어주고, 어떤 날은 어디서 복사해온 듯한 일반적인 내용만 나열한다. 기능을 개발해달라고 하면 기존 코드를 완전히 무시하고 새로 작성하기도 한다.</p>
<p>나도 그랬다. Claude Code를 쓰면서 매번 같은 것을 반복해서 설명했다. "이 프로젝트는 이런 구조고, 이런 컨벤션을 쓰고, 이건 이렇게 해야 해." 매 세션마다 처음부터. 결과의 품질은 그날 내가 얼마나 상세하게 설명했느냐에 달려 있었다.</p>
<p>문제는 AI가 아니었다. <strong>내가 AI에게 주는 맥락이 문제였다.</strong></p>
<hr />
<h2>Prompt Engineering에서 Context Engineering으로</h2>
<p>Andrej Karpathy는 이렇게 말했다.</p>
<blockquote>
<p>"Context engineering is the delicate art and science of filling the context window with just the right information for the next step."</p>
</blockquote>
<p>"프롬프트 엔지니어링"이라는 말을 들으면 뭐가 떠오르는가? 대부분은 짧은 질문을 잘 다듬는 기술을 떠올린다. "이렇게 물어보면 더 좋은 답이 나온다" 같은 팁. 하지만 실제로 AI를 업무에 활용하려면, 단순한 질문 하나로는 부족하다.</p>
<p>Karpathy가 제안한 Context Engineering은 더 넓은 개념이다. LLM을 CPU로, 컨텍스트 윈도우를 RAM으로 비유하면, 개발자의 역할은 <strong>운영체제</strong>다. 작업에 필요한 정확한 정보를 적시에 메모리에 올리는 것. 너무 적으면 AI가 제대로 된 결과를 못 내고, 너무 많으면 핵심이 묻힌다.</p>
<p>토스 기술 블로그의 "소프트웨어 3.0 시대를 맞이하며"에서는 이 개념을 더 구체화한다. Claude Code의 구조를 전통적인 레이어드 아키텍처에 비유하면서, CLAUDE.md는 <code>package.json</code>처럼 정적 설정을, Skills는 단일 책임 원칙(SRP)을 따르는 도메인 컴포넌트 역할을 한다고 설명한다.</p>
<p>핵심은 이것이다: <strong>같은 모델이라도 어떤 맥락을 주느냐에 따라 결과가 극적으로 달라진다.</strong> 그리고 그 맥락을 체계적으로 설계하는 것이 바로 Context Engineering이다.</p>
<hr />
<h2>반복되는 맥락을 패키징하다: 스킬이라는 해법</h2>
<p>이 깨달음은 자연스럽게 다음 질문으로 이어졌다. "매번 수동으로 맥락을 구성하는 대신, 잘 설계된 맥락을 재사용할 수는 없을까?"</p>
<p>Claude Code의 스킬(Skills) 시스템이 바로 그 답이었다. 스킬은 <code>SKILL.md</code> 파일에 정의된 지시사항으로, Claude가 특정 작업을 수행할 때 자동으로 로드되는 <strong>재사용 가능한 컨텍스트 패키지</strong>다.</p>
<p>나는 두 가지 스킬을 만들었다.</p>
<h3>claude-md-writer: CLAUDE.md 작성 자동화</h3>
<p>CLAUDE.md를 잘 쓰는 것은 Context Engineering의 기본이다. 하지만 "잘" 쓰는 것이 의외로 어렵다. Anthropic 공식 권장사항인 200줄 제한을 지키면서, 코드에서 유추할 수 없는 정보만 포함하고, 모든 명령어는 복사-붙여넣기로 실행 가능해야 한다.</p>
<p>이 스킬은 5단계 워크플로우를 따른다:</p>
<ol>
<li><p><strong>탐색</strong> — 기존 파일과 프로젝트 타입을 자동 감지</p>
</li>
<li><p><strong>분석</strong> — 빌드 시스템, 아키텍처, 환경을 병렬로 조사</p>
</li>
<li><p><strong>인터뷰</strong> — 코드에서 알 수 없는 정보를 사용자에게 질문</p>
</li>
<li><p><strong>생성</strong> — 분석과 인터뷰 결과를 종합하여 CLAUDE.md 작성</p>
</li>
<li><p><strong>검증</strong> — 품질 기준에 부합하는지 확인</p>
</li>
</ol>
<p>핵심 설계 원칙은 "Would Claude fail without this?"다. 이 질문을 통과하지 못하는 정보는 포함하지 않는다. 코드를 읽으면 알 수 있는 것은 적지 않고, Claude가 모르면 틀릴 수밖에 없는 것만 남긴다.</p>
<h3>new-feature: 기능 개발 워크플로우</h3>
<p>기능 개발 요청을 받으면 바로 코드부터 작성하는 것이 AI의 본능이다. 하지만 이러면 기존 코드를 무시하고 새로 작성하거나, 잘못된 방향으로 한참을 달린 뒤에 되돌아오는 일이 생긴다.</p>
<p>이 스킬은 4단계 게이트를 강제한다:</p>
<ol>
<li><p><strong>리서치</strong> — 요구사항 수집, 기존 코드 분석, 외부 자료 조사</p>
</li>
<li><p><strong>작업</strong> — 승인된 방향으로만 구현 (승인 없이는 코드를 쓰지 않음)</p>
</li>
<li><p><strong>검토</strong> — 요구사항 대조, 엣지케이스 확인, 보안 점검</p>
</li>
<li><p><strong>보고</strong> — 구현 내용과 검증 결과를 정리하여 전달</p>
</li>
</ol>
<p>가장 중요한 설계 결정은 1단계와 2단계 사이의 <strong>승인 게이트</strong>다. AI가 조사 결과를 먼저 보여주고, 사용자의 승인을 받은 후에야 구현에 들어간다. "잘못된 방향으로 열심히 달리는" 문제를 구조적으로 차단한다.</p>
<hr />
<h2>스킬 도입 전과 후</h2>
<p><strong>Before:</strong></p>
<ul>
<li><p>매 세션마다 프로젝트 컨텍스트를 처음부터 설명</p>
</li>
<li><p>같은 요청에도 들쭉날쭉한 결과 품질</p>
</li>
<li><p>보안 체크, 엣지케이스 확인 등을 빠뜨리는 경우 발생</p>
</li>
<li><p>"이번엔 잘 해줄까?" 하는 불확실함</p>
</li>
</ul>
<p><strong>After:</strong></p>
<ul>
<li><p><code>/claude-md-writer create</code> 한 번이면 프로젝트 분석부터 CLAUDE.md 생성까지 완료</p>
</li>
<li><p>누가 실행해도, 언제 실행해도 <strong>일관된 프로세스</strong>가 보장됨</p>
</li>
<li><p>체계적인 검토 단계 덕분에 빠뜨리는 것이 줄어듦</p>
</li>
<li><p>반복 작업에 들이던 시간이 줄어 핵심 의사결정에 집중 가능</p>
</li>
</ul>
<p>가장 큰 변화는 <strong>일관성</strong>이었다. 스킬은 바닥을 올려준다. 최소한의 품질이 항상 보장되니, "오늘은 운이 좋아서 잘 나왔다"가 아니라 "항상 이 정도는 나온다"가 된다.</p>
<hr />
<h2>스킬은 바닥을 올리고, 사람은 천장을 결정한다</h2>
<p>하지만 솔직하게 말해야 할 것이 있다. 스킬이 모든 것을 해결해주지는 않는다.</p>
<p>스킬은 <strong>프로세스</strong>를 패키징한 것이지, <strong>판단력</strong>을 패키징한 것이 아니다. <code>new-feature</code> 스킬이 리서치 결과를 보여주고 승인을 요청할 때, 그 결과를 제대로 검토하고 올바른 방향을 제시하는 것은 여전히 사람의 몫이다. <code>claude-md-writer</code>가 인터뷰 질문을 할 때, 프로젝트의 진짜 맥락을 전달하는 것도 사람의 역할이다.</p>
<p>결국 <strong>최종 결과물의 품질은 그것을 검토하고 피드백하는 사람의 역량에 크게 좌우된다.</strong></p>
<p>이것은 Context Engineering의 본질과도 맞닿아 있다. AI에게 좋은 맥락을 주는 것은 단순히 정보를 많이 넣는 것이 아니라, <strong>올바른 정보를 올바른 시점에</strong> 제공하는 것이다. 스킬은 이 과정의 구조를 잡아주지만, 구조 안에 채워넣는 내용의 질은 사용하는 사람에게 달려 있다.</p>
<p>나는 이것을 이렇게 정리한다:</p>
<blockquote>
<p><strong>스킬은 바닥을 올려주고, 피드백은 천장을 결정한다.</strong></p>
</blockquote>
<p>AI 코딩 도구의 효과를 극대화하고 싶다면, 더 좋은 프롬프트를 고민하는 것을 넘어서 두 가지를 함께 해야 한다. 반복되는 맥락은 스킬로 체계화하고, 그 위에서 자신의 도메인 지식과 판단력으로 방향을 잡아주는 것. Context Engineering은 AI를 더 똑똑하게 만드는 기술이 아니다. <strong>같은 AI에게서 더 나은 결과를 끌어내는 기술</strong>이다.</p>
<hr />
<p><em>이 글에서 소개한 스킬들은 오픈소스로 공개되어 있습니다.GitHub:</em> <a href="https://github.com/Tianea2160/claude-skills"><em>Tianea2160/claude-skills</em></a></p>
<hr />
<h3>참고 자료</h3>
<ul>
<li><p><a href="https://x.com/karpathy/status/1937902205765607626">Andrej Karpathy on Context Engineering (X/Twitter)</a> — "Context engineering is the delicate art and science of filling the context window with just the right information for the next step."</p>
</li>
<li><p><a href="https://toss.tech/article/44539">소프트웨어 3.0 시대를 맞이하며 — 토스 기술 블로그</a> — Claude Code를 레이어드 아키텍처로 비유한 Context Engineering 실전 가이드</p>
</li>
<li><p><a href="https://code.claude.com/docs/en/skills">Extend Claude with skills — Claude Code 공식 문서</a> — Skills 시스템의 구조, 프론트매터, 설계 패턴 레퍼런스</p>
</li>
<li><p><a href="https://codeconductor.ai/blog/context-engineering">Context Engineering: A Complete Guide (2026)</a> — Context Engineering 개념의 전반적 개요</p>
</li>
<li><p><a href="https://thenewstack.io/context-is-ai-codings-real-bottleneck-in-2026/">Context is AI coding's real bottleneck in 2026 — The New Stack</a> — AI 코딩에서 컨텍스트가 실질적 병목인 이유</p>
</li>
<li><p><a href="https://martinfowler.com/articles/exploring-gen-ai/context-engineering-coding-agents.html">Context Engineering for Coding Agents — Martin Fowler</a> — 코딩 에이전트를 위한 Context Engineering 심화</p>
</li>
</ul>
]]></content:encoded></item><item><title><![CDATA[유한 오토마타는 신이야]]></title><description><![CDATA[상태머신이란 무엇인가?
다른 단어로 유한 오토마타(Finite Automata)라고도 이야기하는데, 단순하게 해석하면 유한한 상태를 가지는 기계라고 이해하면 편하다.
우리는 많은 경우에 상태값을 가지고서 행동하게 된다.
대기 ──이벤트 1──▶ 진행중 ──이벤트 2──▶ 완료 or 취소
stateDiagram-v2
    [*] --> 대기
    대기 --> 진행중 : 이벤트 1
    진행중 --> 완료 : 이벤트 2
    진행중 --> ...]]></description><link>https://blog.hyunjun.org/7jyg7zwcioyypo2goounio2dgouklcdsi6dsnbtslbw</link><guid isPermaLink="true">https://blog.hyunjun.org/7jyg7zwcioyypo2goounio2dgouklcdsi6dsnbtslbw</guid><dc:creator><![CDATA[조현준]]></dc:creator><pubDate>Sun, 08 Feb 2026 11:58:02 GMT</pubDate><enclosure url="https://cdn.hashnode.com/res/hashnode/image/upload/v1770551792271/6a6622f5-80a0-43c5-bc66-f20ce1e6d5a6.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<h1 id="heading-7iob7yoc66i47iug7j20656aioustoyxhyduoqwgd8">상태머신이란 무엇인가?</h1>
<p>다른 단어로 <strong>유한 오토마타(Finite Automata)</strong>라고도 이야기하는데, 단순하게 해석하면 <strong>유한한 상태를 가지는 기계</strong>라고 이해하면 편하다.</p>
<p>우리는 많은 경우에 상태값을 가지고서 행동하게 된다.</p>
<pre><code>대기 ──이벤트 <span class="hljs-number">1</span>──▶ 진행중 ──이벤트 <span class="hljs-number">2</span>──▶ 완료 or 취소
</code></pre><pre><code class="lang-mermaid">stateDiagram-v2
    [*] --&gt; 대기
    대기 --&gt; 진행중 : 이벤트 1
    진행중 --&gt; 완료 : 이벤트 2
    진행중 --&gt; 취소 : 이벤트 3
    완료 --&gt; [*]
    취소 --&gt; [*]
</code></pre>
<p>위 flow를 다음과 같이 해석할 수 있다.</p>
<ul>
<li><strong>대기</strong>는 '시작 상태'다.</li>
<li>상태가 변화하는 것은 특정한 <strong>이벤트</strong>가 일어날 때이다.</li>
<li><strong>완료</strong>와 <strong>취소</strong>는 '끝 상태'이다.</li>
</ul>
<p>이와 같이 우리는 언젠가 저 기계가 '끝 상태'에 도달할 것이라고 예상한다. 이것을 사람들은 '유한한 상태를 가진다'라고 표현하며, <strong>유한 오토마타</strong>라고 부르게 된 것이다.</p>
<p>사실 나는 지금 굉장히 허술하게 이 상태머신을 설명하고 있지만, 컴퓨터공학에서 말하는 모델과 수학에서 이야기하는 모델로 나뉘는 편이다. 자세한 이야기는 하단의 위키백과를 참고하면 된다.</p>
<blockquote>
<p><a target="_blank" href="https://ko.wikipedia.org/wiki/%EC%9C%A0%ED%95%9C_%EC%83%81%ED%83%9C_%EA%B8%B0%EA%B3%84">유한 상태 기계 - 위키백과</a></p>
</blockquote>
<hr />
<h2 id="heading-7l2u65oc66gciouztouklcdsg4htg5zrqljsi6a">코드로 보는 상태머신</h2>
<p>말로만 하면 와닿지 않을 수 있다. 상태머신 없이 상태를 관리하는 코드부터 살펴보자.</p>
<h3 id="heading-7iob7yoc66i47iugioyxhuydtcdqtidrpqztlzjripqg6rk97jqw">상태머신 없이 관리하는 경우</h3>
<pre><code class="lang-kotlin"><span class="hljs-class"><span class="hljs-keyword">class</span> <span class="hljs-title">OrderService</span></span>(
    <span class="hljs-keyword">private</span> <span class="hljs-keyword">val</span> orderRepository: OrderRepository,
) {
    <span class="hljs-function"><span class="hljs-keyword">fun</span> <span class="hljs-title">approve</span><span class="hljs-params">(orderId: <span class="hljs-type">Long</span>)</span></span> {
        <span class="hljs-keyword">val</span> order = orderRepository.findById(orderId)

        <span class="hljs-comment">// 상태 검증을 개발자가 직접 해야 한다</span>
        <span class="hljs-keyword">if</span> (order.status != OrderStatus.PENDING) {
            <span class="hljs-keyword">throw</span> IllegalStateException(<span class="hljs-string">"승인은 PENDING 상태에서만 가능합니다. 현재: <span class="hljs-subst">${order.status}</span>"</span>)
        }

        order.status = OrderStatus.APPROVED
        orderRepository.save(order)
    }

    <span class="hljs-function"><span class="hljs-keyword">fun</span> <span class="hljs-title">ship</span><span class="hljs-params">(orderId: <span class="hljs-type">Long</span>)</span></span> {
        <span class="hljs-keyword">val</span> order = orderRepository.findById(orderId)

        <span class="hljs-keyword">if</span> (order.status != OrderStatus.APPROVED) {
            <span class="hljs-keyword">throw</span> IllegalStateException(<span class="hljs-string">"배송은 APPROVED 상태에서만 가능합니다. 현재: <span class="hljs-subst">${order.status}</span>"</span>)
        }

        order.status = OrderStatus.SHIPPED
        orderRepository.save(order)
    }

    <span class="hljs-function"><span class="hljs-keyword">fun</span> <span class="hljs-title">cancel</span><span class="hljs-params">(orderId: <span class="hljs-type">Long</span>)</span></span> {
        <span class="hljs-keyword">val</span> order = orderRepository.findById(orderId)

        <span class="hljs-comment">// PENDING과 APPROVED에서만 취소 가능... 맞나?</span>
        <span class="hljs-keyword">if</span> (order.status != OrderStatus.PENDING &amp;&amp; order.status != OrderStatus.APPROVED) {
            <span class="hljs-keyword">throw</span> IllegalStateException(<span class="hljs-string">"취소할 수 없는 상태입니다. 현재: <span class="hljs-subst">${order.status}</span>"</span>)
        }

        order.status = OrderStatus.CANCELLED
        orderRepository.save(order)
    }
}
</code></pre>
<p>이 코드의 문제는 명확하다.</p>
<ul>
<li>상태 전이 규칙이 <strong>각 메서드에 흩어져 있다.</strong> 전체 흐름을 파악하려면 모든 메서드를 뒤져봐야 한다.</li>
<li><code>if</code> 분기를 하나라도 빠뜨리면 <strong>비정상 전이가 허용</strong>된다.</li>
<li>상태가 추가될 때마다 <strong>모든 메서드를 수정</strong>해야 한다.</li>
</ul>
<h3 id="heading-7iob7yoc66i47iug7jy866gcioq0goumro2vmouklcdqsr3smra">상태머신으로 관리하는 경우</h3>
<pre><code class="lang-kotlin"><span class="hljs-keyword">val</span> orderMachine = stateMachine&lt;OrderStatus, OrderEvent, Order&gt; {
    from(OrderStatus.PENDING) {
        on&lt;OrderEvent.Approve&gt;() goto OrderStatus.APPROVED
        on&lt;OrderEvent.Cancel&gt;() goto OrderStatus.CANCELLED
    }
    from(OrderStatus.APPROVED) {
        on&lt;OrderEvent.Ship&gt;() goto OrderStatus.SHIPPED
        on&lt;OrderEvent.Cancel&gt;() goto OrderStatus.CANCELLED
    }
    from(OrderStatus.SHIPPED) {
        on&lt;OrderEvent.Deliver&gt;() goto OrderStatus.DELIVERED
    }
}
</code></pre>
<pre><code class="lang-mermaid">stateDiagram-v2
    [*] --&gt; PENDING
    PENDING --&gt; APPROVED : Approve
    PENDING --&gt; CANCELLED : Cancel
    APPROVED --&gt; SHIPPED : Ship
    APPROVED --&gt; CANCELLED : Cancel
    SHIPPED --&gt; DELIVERED : Deliver
    DELIVERED --&gt; [*]
    CANCELLED --&gt; [*]
</code></pre>
<p>이 코드 한 블록만 보면 <strong>전체 상태 흐름을 한눈에 파악할 수 있다.</strong> 정의되지 않은 전이(예: <code>SHIPPED</code>에서 <code>Cancel</code>)는 상태머신이 자동으로 거부한다. 서비스 레이어는 이렇게 간결해진다.</p>
<pre><code class="lang-kotlin"><span class="hljs-class"><span class="hljs-keyword">class</span> <span class="hljs-title">OrderService</span></span>(
    <span class="hljs-keyword">private</span> <span class="hljs-keyword">val</span> orderRepository: OrderRepository,
    <span class="hljs-keyword">private</span> <span class="hljs-keyword">val</span> orderMachine: StateMachine&lt;OrderStatus, OrderEvent, Order&gt;,
) {
    <span class="hljs-function"><span class="hljs-keyword">fun</span> <span class="hljs-title">approve</span><span class="hljs-params">(orderId: <span class="hljs-type">Long</span>)</span></span> {
        <span class="hljs-keyword">val</span> order = orderRepository.findById(orderId)
        <span class="hljs-keyword">val</span> result = orderMachine.fire(order, OrderEvent.Approve)
        orderRepository.save(result.context)
    }

    <span class="hljs-function"><span class="hljs-keyword">fun</span> <span class="hljs-title">ship</span><span class="hljs-params">(orderId: <span class="hljs-type">Long</span>)</span></span> {
        <span class="hljs-keyword">val</span> order = orderRepository.findById(orderId)
        <span class="hljs-keyword">val</span> result = orderMachine.fire(order, OrderEvent.Ship)
        orderRepository.save(result.context)
    }
}
</code></pre>
<p>상태 검증 로직이 사라졌다. <strong>상태머신이 대신 검증해주기 때문이다.</strong></p>
<hr />
<h2 id="heading-7ja47kccioywtouwuqyjcdsgqzsmqntlzjripqg6rkd7j20ioyiiydhoq5jd8">언제 어떻게 사용하는 것이 좋을까?</h2>
<p>지금까지 장황하게 상태머신에 대해서 이야기했다. 그러면 이걸 언제 사용해야 하는지에 대해서 모른다면 굳이 상태머신의 정의를 알 필요가 없을 것이다.</p>
<h3 id="heading-7j20650iouvjcdsgqzsmqntlzjrqbqg7kkl64uk">이럴 때 사용하면 좋다</h3>
<ul>
<li><strong>상태값을 2개 이상 관리해야 하는 순간부터</strong> 사용하는 것이 좋다.</li>
<li><strong>이벤트 기반 아키텍처</strong>를 추구한다면 사용하면 좋다.</li>
<li>자신이 속한 <strong>비즈니스가 너무 복잡</strong>해서 어딘가 명세가 있었으면 좋겠다고 생각한다면 사용하는 것이 좋다.</li>
</ul>
<h3 id="heading-64ya7zgc7kcb7j24ioycroyaqsdsgqzroya">대표적인 사용 사례</h3>
<div class="hn-table">
<table>
<thead>
<tr>
<td>도메인</td><td>상태 예시</td><td>이벤트 예시</td></tr>
</thead>
<tbody>
<tr>
<td>주문 관리</td><td>PENDING → APPROVED → SHIPPED → DELIVERED</td><td>Approve, Ship, Deliver, Cancel</td></tr>
<tr>
<td>문서 관리</td><td>DRAFT → REVIEW → PUBLISHED → ARCHIVED</td><td>Submit, Approve, Publish, Archive</td></tr>
<tr>
<td>결재 시스템</td><td>작성 → 1차 승인 → 2차 승인 → 완료</td><td>Submit, Approve, Reject, Cancel</td></tr>
<tr>
<td>CI/CD 파이프라인</td><td>Build → Test → Deploy → Running</td><td>Trigger, Pass, Fail, Rollback</td></tr>
</tbody>
</table>
</div><hr />
<h2 id="heading-64k06rcaioydneqwge2vmouklcdsg4htg5zrqljsi6dsnzgg7j6l7kcq6ro8ioulqoygka">내가 생각하는 상태머신의 장점과 단점</h2>
<h3 id="heading-7j6l7kcq">장점</h3>
<ul>
<li>이벤트를 기반으로 상태의 변화에 대한 <strong>명세를 관리</strong>하기 때문에 데이터의 흐름을 파악하거나 비즈니스를 이해하기에 수월하다.</li>
<li>개발자의 실수를 상태머신이 어느 정도 보완해주며 막아주기 때문에 <strong>프레임워크로서의 역할</strong>을 해준다.</li>
<li>특정 상태에서의 비정상(기대하지 않는) 이벤트에 대한 <strong>검증을 상태머신이 대신</strong> 해준다.</li>
</ul>
<pre><code class="lang-kotlin"><span class="hljs-comment">// ARCHIVED 상태에서 Publish 이벤트를 보내면?</span>
<span class="hljs-keyword">val</span> archived = Document(DocumentStatus.ARCHIVED, <span class="hljs-string">"content"</span>)
machine.canFire(archived, DocumentEvent.Publish) <span class="hljs-comment">// false</span>

<span class="hljs-comment">// fire()를 호출하면 InvalidTransitionException이 발생한다</span>
machine.fire(archived, DocumentEvent.Publish)
<span class="hljs-comment">// → InvalidTransitionException: No valid transition from state 'ARCHIVED' with event 'Publish'</span>
</code></pre>
<h3 id="heading-64uo7kcq">단점</h3>
<ul>
<li>아주 간단한 작업(특정 필드의 값을 수정)에 대해서도 이벤트를 정의하고 상태에 대한 명세를 작성해야 하기 때문에 <strong>약간 번거롭다.</strong></li>
<li>이벤트를 추가하면 <strong>삭제하기가 어렵다.</strong></li>
<li><strong>버전 관리를 하기 힘들다.</strong> 이미 운영 중인 상태머신의 전이 규칙을 변경하면 기존 데이터와의 정합성 문제가 발생할 수 있다.</li>
</ul>
<p>위 내용을 종합해 보면, 상태머신은 프레임워크로서 강력한 상태 및 이벤트 관리를 보장하지만 그만큼 <strong>기존 flow를 변경하는 것은 고비용</strong>이라는 것을 알 수 있다.</p>
<hr />
<h2 id="heading-64k06rcaioydge2dnououoylooydhcdsp4hsojeg66em65ogioydtoycoa">내가 상태머신을 직접 만든 이유</h2>
<p>나는 상태머신을 직접 만들어서 사용하는 편인데, 이런저런 복잡한 사정이 있다.</p>
<blockquote>
<p>관심이 있는 분들은 아래의 GitHub을 이용해 주세요. (contribute 대환영)</p>
<p><a target="_blank" href="https://github.com/Tianea2160/statemachine">https://github.com/Tianea2160/statemachine</a></p>
</blockquote>
<p>일단 나는 웬만하면 직접 만들어서 사용하지는 않는 편이다. 왜냐하면 Spring이라는 거대한 생태계가 내가 필요로 하는 모든 것을 거의 대부분 지원해주고 있기 때문이다.</p>
<p>그런데 상태머신에 대해서는 그다지 만족스럽지 못하다.</p>
<p><strong>spring-statemachine</strong>은 일단 굉장히 잘 만든 프레임워크라는 것을 먼저 이야기하고 싶다. 그렇지만 이걸 사용하지 않는 이유는 다음과 같다.</p>
<h3 id="heading-1-spring-boot">1. 최신 Spring Boot 버전에 대한 지원이 느리다</h3>
<p>현재(2026.02.08)를 기준으로 Spring Boot 4가 나왔고 Spring Framework 7을 지원하지만, 아직 spring-statemachine은 Spring 7을 지원하지 않는다.</p>
<p>회사라면 이렇게 빠른 버전을 바로 사용하지는 않을 것이라서 괜찮겠지만, 개인적으로 사용할 때는 이것 때문에 버전을 낮춰야 하는데, 그만큼의 메리트가 있느냐고 생각할 때에는 <strong>'아니다'</strong>라고 말할 것이다.</p>
<blockquote>
<p><a target="_blank" href="https://github.com/spring-projects/spring-statemachine">Spring Statemachine - GitHub</a></p>
</blockquote>
<h3 id="heading-2">2. 가볍게 사용하고 싶다</h3>
<p>나는 spring-statemachine-core를 사용하는데, 솔직히 인메모리 상태머신만 사용하는 상황에서 스프링 프레임워크가 주는 부수적인 기능들은 아무것도 필요하지 않았다.</p>
<p>내가 필요한 것은 딱 이것뿐이었다.</p>
<ul>
<li>상태와 이벤트를 정의한다.</li>
<li>전이 규칙을 선언한다.</li>
<li>이벤트를 보내면 상태가 바뀐다.</li>
<li>잘못된 전이는 거부한다.</li>
</ul>
<h3 id="heading-3">3. 상태머신에 비즈니스 로직을 넣지 않는 것이 더 낫다</h3>
<p>spring-statemachine의 Action에 비즈니스 로직을 넣어도 봤는데, <strong>그렇게 안 넣는 것이 오히려 더 유지보수하기 편하고 가독성도 올라간다.</strong></p>
<p>이와 관련해서는 말보다는 코드로 이야기하겠다.</p>
<h4 id="heading-spring-statemachine">spring-statemachine에 비즈니스 로직을 넣는 경우</h4>
<pre><code class="lang-kotlin"><span class="hljs-comment">// Spring StateMachine 방식 - Action에 비즈니스 로직이 들어간다</span>
<span class="hljs-meta">@Configuration</span>
<span class="hljs-class"><span class="hljs-keyword">class</span> <span class="hljs-title">OrderStateMachineConfig</span> : <span class="hljs-type">StateMachineConfigurerAdapter</span>&lt;<span class="hljs-type">OrderStatus, OrderEvent</span>&gt;</span>() {

    <span class="hljs-keyword">override</span> <span class="hljs-function"><span class="hljs-keyword">fun</span> <span class="hljs-title">configure</span><span class="hljs-params">(transitions: <span class="hljs-type">StateMachineTransitionConfigurer</span>&lt;<span class="hljs-type">OrderStatus</span>, OrderEvent&gt;)</span></span> {
        transitions
            .withExternal()
            .source(OrderStatus.PENDING).target(OrderStatus.APPROVED)
            .event(OrderEvent.APPROVE)
            .action { context -&gt;
                <span class="hljs-comment">// 비즈니스 로직이 여기에...</span>
                <span class="hljs-keyword">val</span> order = context.getExtendedState().<span class="hljs-keyword">get</span>(<span class="hljs-string">"order"</span>, Order::<span class="hljs-keyword">class</span>.java)
                order.approvedAt = Instant.now()
                order.approvedBy = SecurityContextHolder.getContext().authentication.name
                notificationService.sendApprovalNotification(order)
                inventoryService.reserve(order.items)
                <span class="hljs-comment">// 점점 비대해진다</span>
            }
    }
}
</code></pre>
<p>이 방식은 상태머신 설정 안에 서비스 호출, 알림, 재고 처리 등이 뒤섞여서 <strong>상태 전이 규칙이 비즈니스 로직에 묻혀버린다.</strong></p>
<h4 id="heading-7iob7yoc66i47iug7j2aioydge2dncdqtidrpqzrp4wsiou5hoymioulioykpcdrozzsp4hsnyag7isc67me7iqk7jeq7isc">상태머신은 상태 관리만, 비즈니스 로직은 서비스에서</h4>
<pre><code class="lang-kotlin"><span class="hljs-comment">// 상태머신은 순수하게 전이 규칙만 정의</span>
<span class="hljs-keyword">val</span> orderMachine = stateMachine&lt;OrderStatus, OrderEvent, Order&gt; {
    from(OrderStatus.PENDING) {
        on&lt;OrderEvent.Approve&gt;() goto OrderStatus.APPROVED
        on&lt;OrderEvent.Cancel&gt;() goto OrderStatus.CANCELLED
    }
    from(OrderStatus.APPROVED) {
        on&lt;OrderEvent.Ship&gt;() goto OrderStatus.SHIPPED
    }
}

<span class="hljs-comment">// 비즈니스 로직은 서비스 레이어에서 명확하게 분리</span>
<span class="hljs-class"><span class="hljs-keyword">class</span> <span class="hljs-title">OrderService</span></span>(
    <span class="hljs-keyword">private</span> <span class="hljs-keyword">val</span> orderMachine: StateMachine&lt;OrderStatus, OrderEvent, Order&gt;,
    <span class="hljs-keyword">private</span> <span class="hljs-keyword">val</span> orderRepository: OrderRepository,
    <span class="hljs-keyword">private</span> <span class="hljs-keyword">val</span> notificationService: NotificationService,
) {
    <span class="hljs-function"><span class="hljs-keyword">fun</span> <span class="hljs-title">approve</span><span class="hljs-params">(orderId: <span class="hljs-type">Long</span>)</span></span> {
        <span class="hljs-keyword">val</span> order = orderRepository.findById(orderId)

        <span class="hljs-comment">// 1. 상태 전이 (상태머신이 검증 + 전이)</span>
        <span class="hljs-keyword">val</span> result = orderMachine.fire(order, OrderEvent.Approve)

        <span class="hljs-comment">// 2. 비즈니스 로직 (서비스가 담당)</span>
        notificationService.sendApprovalNotification(result.context)

        <span class="hljs-comment">// 3. 저장</span>
        orderRepository.save(result.context)
    }
}
</code></pre>
<p>이렇게 하면 <strong>상태머신 정의만 보면 전체 상태 흐름이 보이고</strong>, 비즈니스 로직은 서비스 레이어에서 읽으면 된다. 각자의 책임이 명확하다.</p>
<hr />
<h2 id="heading-7keb7kcriounjoutocdrnbzsnbtruizrn6zrpqwg7iam6rcc">직접 만든 라이브러리 소개</h2>
<p>위와 같은 이유로 직접 만든 라이브러리를 간단히 소개한다.</p>
<h3 id="heading-7isk7lmy">설치</h3>
<pre><code class="lang-kotlin"><span class="hljs-comment">// settings.gradle.kts</span>
dependencyResolutionManagement {
    repositories {
        mavenCentral()
        maven { url = uri(<span class="hljs-string">"https://jitpack.io"</span>) }
    }
}

<span class="hljs-comment">// build.gradle.kts</span>
dependencies {
    implementation(<span class="hljs-string">"com.github.Tianea2160:statemachine:v1.0.0"</span>)
}
</code></pre>
<h3 id="heading-6riw67o4ioycroyaqeuylq">기본 사용법</h3>
<p>상태, 이벤트, 도메인 모델을 정의한다.</p>
<pre><code class="lang-kotlin"><span class="hljs-comment">// 1. 상태 정의</span>
<span class="hljs-keyword">enum</span> <span class="hljs-class"><span class="hljs-keyword">class</span> <span class="hljs-title">DocumentStatus</span> : <span class="hljs-type">State {</span></span>
    DRAFT, PUBLISHED, ARCHIVED
}

<span class="hljs-comment">// 2. 이벤트 정의</span>
<span class="hljs-keyword">sealed</span> <span class="hljs-class"><span class="hljs-keyword">interface</span> <span class="hljs-title">DocumentEvent</span> : <span class="hljs-type">Event {</span></span>
    <span class="hljs-keyword">data</span> <span class="hljs-keyword">object</span> Publish : DocumentEvent
    <span class="hljs-keyword">data</span> <span class="hljs-keyword">object</span> Archive : DocumentEvent
}

<span class="hljs-comment">// 3. 도메인 모델 - Stateful 인터페이스를 구현</span>
<span class="hljs-keyword">data</span> <span class="hljs-class"><span class="hljs-keyword">class</span> <span class="hljs-title">Document</span></span>(
    <span class="hljs-keyword">override</span> <span class="hljs-keyword">val</span> state: DocumentStatus,
    <span class="hljs-keyword">val</span> content: String,
) : Stateful&lt;DocumentStatus, Document&gt; {
    <span class="hljs-keyword">override</span> <span class="hljs-function"><span class="hljs-keyword">fun</span> <span class="hljs-title">withState</span><span class="hljs-params">(newState: <span class="hljs-type">DocumentStatus</span>)</span></span>: Document =
        copy(state = newState)
}
</code></pre>
<p>상태머신을 선언하고 사용한다.</p>
<pre><code class="lang-kotlin"><span class="hljs-comment">// 4. 상태머신 정의</span>
<span class="hljs-keyword">val</span> machine = stateMachine&lt;DocumentStatus, DocumentEvent, Document&gt; {
    from(DocumentStatus.DRAFT) {
        on&lt;DocumentEvent.Publish&gt;() goto DocumentStatus.PUBLISHED
        on&lt;DocumentEvent.Archive&gt;() goto DocumentStatus.ARCHIVED
    }
    from(DocumentStatus.PUBLISHED) {
        on&lt;DocumentEvent.Archive&gt;() goto DocumentStatus.ARCHIVED
    }
}

<span class="hljs-comment">// 5. 사용</span>
<span class="hljs-keyword">val</span> doc = Document(DocumentStatus.DRAFT, <span class="hljs-string">"Hello World"</span>)
<span class="hljs-keyword">val</span> result = machine.fire(doc, DocumentEvent.Publish)

println(result.previousState)  <span class="hljs-comment">// DRAFT</span>
println(result.newState)       <span class="hljs-comment">// PUBLISHED</span>
println(result.stateChanged)   <span class="hljs-comment">// true</span>
</code></pre>
<pre><code class="lang-mermaid">stateDiagram-v2
    [*] --&gt; DRAFT
    DRAFT --&gt; PUBLISHED : Publish
    DRAFT --&gt; ARCHIVED : Archive
    PUBLISHED --&gt; ARCHIVED : Archive
    ARCHIVED --&gt; [*]
</code></pre>
<h3 id="heading-guard">Guard - 조건부 전이</h3>
<p>특정 조건을 만족할 때만 전이를 허용할 수 있다.</p>
<pre><code class="lang-kotlin"><span class="hljs-keyword">val</span> machine = stateMachine&lt;DocumentStatus, DocumentEvent, Document&gt; {
    from(DocumentStatus.DRAFT) {
        on&lt;DocumentEvent.Publish&gt;() goto DocumentStatus.PUBLISHED guardedBy {
            it.content.isNotBlank()  <span class="hljs-comment">// 내용이 비어있으면 발행 불가</span>
        }
    }
}

<span class="hljs-keyword">val</span> emptyDoc = Document(DocumentStatus.DRAFT, <span class="hljs-string">""</span>)
machine.canFire(emptyDoc, DocumentEvent.Publish)  <span class="hljs-comment">// false</span>

<span class="hljs-keyword">val</span> validDoc = Document(DocumentStatus.DRAFT, <span class="hljs-string">"Hello World"</span>)
machine.canFire(validDoc, DocumentEvent.Publish)   <span class="hljs-comment">// true</span>
</code></pre>
<p>Guard는 조합할 수 있다. <code>and</code>, <code>or</code>, <code>not</code> 연산자를 지원한다.</p>
<pre><code class="lang-kotlin"><span class="hljs-keyword">val</span> notBlank: Guard&lt;Document&gt; = Guard { it.content.isNotBlank() }
<span class="hljs-keyword">val</span> longEnough: Guard&lt;Document&gt; = Guard { it.content.length &gt;= <span class="hljs-number">10</span> }

<span class="hljs-comment">// AND 조합 - 둘 다 만족해야 전이</span>
<span class="hljs-keyword">val</span> publishable = notBlank and longEnough

<span class="hljs-comment">// OR 조합 - 하나만 만족해도 전이</span>
<span class="hljs-keyword">val</span> archivable = notBlank or Guard { it.state == DocumentStatus.PUBLISHED }

<span class="hljs-comment">// NOT - 부정</span>
<span class="hljs-keyword">val</span> notArchived = !Guard&lt;Document&gt; { it.state == DocumentStatus.ARCHIVED }
</code></pre>
<h3 id="heading-action">Action - 전이 시 컨텍스트 변환</h3>
<p>전이가 일어날 때 컨텍스트를 변환할 수 있다. 모든 것이 불변이므로 <code>copy</code>를 사용한다.</p>
<pre><code class="lang-kotlin"><span class="hljs-keyword">val</span> machine = stateMachine&lt;DocumentStatus, DocumentEvent, Document&gt; {
    from(DocumentStatus.DRAFT) {
        on&lt;DocumentEvent.Publish&gt;() goto DocumentStatus.PUBLISHED guardedBy {
            it.content.isNotBlank()
        } action { doc, _ -&gt;
            doc.copy(publishedAt = System.currentTimeMillis())
        }
    }
}
</code></pre>
<p>Action도 <code>then</code>으로 체이닝할 수 있다.</p>
<pre><code class="lang-kotlin"><span class="hljs-keyword">val</span> setTimestamp: Action&lt;Document, DocumentEvent&gt; = Action { doc, _ -&gt;
    doc.copy(publishedAt = System.currentTimeMillis())
}
<span class="hljs-keyword">val</span> normalizeContent: Action&lt;Document, DocumentEvent&gt; = Action { doc, _ -&gt;
    doc.copy(content = doc.content.trim().lowercase())
}

<span class="hljs-comment">// 순차 실행: setTimestamp → normalizeContent</span>
<span class="hljs-keyword">val</span> publishAction = setTimestamp then normalizeContent
</code></pre>
<h3 id="heading-ontransition">onTransition - 전이 콜백</h3>
<p>모든 전이에 대해 로깅이나 이벤트 발행 같은 횡단 관심사를 처리할 수 있다.</p>
<pre><code class="lang-kotlin"><span class="hljs-keyword">val</span> machine = stateMachine&lt;DocumentStatus, DocumentEvent, Document&gt; {
    from(DocumentStatus.DRAFT) {
        on&lt;DocumentEvent.Publish&gt;() goto DocumentStatus.PUBLISHED
        on&lt;DocumentEvent.Archive&gt;() goto DocumentStatus.ARCHIVED
    }
    from(DocumentStatus.PUBLISHED) {
        on&lt;DocumentEvent.Archive&gt;() goto DocumentStatus.ARCHIVED
    }

    onTransition { from, event, to -&gt;
        println(<span class="hljs-string">"[<span class="hljs-variable">$from</span>] --<span class="hljs-subst">${event::class.simpleName}</span>--&gt; [<span class="hljs-variable">$to</span>]"</span>)
        <span class="hljs-comment">// [DRAFT] --Publish--&gt; [PUBLISHED]</span>
    }
}
</code></pre>
<hr />
<h2 id="heading-api">핵심 API 정리</h2>
<div class="hn-table">
<table>
<thead>
<tr>
<td>컴포넌트</td><td>타입</td><td>설명</td></tr>
</thead>
<tbody>
<tr>
<td><code>State</code></td><td>interface</td><td>상태를 나타내는 마커 인터페이스. enum class로 구현</td></tr>
<tr>
<td><code>Event</code></td><td>interface</td><td>이벤트를 나타내는 마커 인터페이스. sealed interface로 구현</td></tr>
<tr>
<td><code>Stateful&lt;S, C&gt;</code></td><td>interface</td><td>상태를 가진 도메인 모델이 구현하는 인터페이스</td></tr>
<tr>
<td><code>Guard&lt;C&gt;</code></td><td>fun interface</td><td>전이 조건 <code>(C) -&gt; Boolean</code>. <code>and</code>, <code>or</code>, <code>not</code> 조합 가능</td></tr>
<tr>
<td><code>Action&lt;C, E&gt;</code></td><td>fun interface</td><td>전이 시 실행되는 동작 <code>(C, E) -&gt; C</code>. <code>then</code>으로 체이닝 가능</td></tr>
<tr>
<td><code>fire(model, event)</code></td><td>method</td><td>이벤트를 발행하고 전이 실행. 유효하지 않으면 예외 발생</td></tr>
<tr>
<td><code>canFire(model, event)</code></td><td>method</td><td>전이 가능 여부를 예외 없이 확인</td></tr>
<tr>
<td><code>availableEvents(model)</code></td><td>method</td><td>현재 상태에서 가능한 모든 이벤트 목록 반환</td></tr>
</tbody>
</table>
</div><hr />
<h2 id="heading-66ei66y066as">마무리</h2>
<p>상태머신은 결국 <strong>"이 상태에서 이 이벤트가 오면 저 상태로 간다"</strong>는 규칙을 명시적으로 선언하는 도구다.</p>
<p><code>if-else</code>로 흩어져 있던 상태 검증 로직을 한곳에 모으고, 정의되지 않은 전이는 프레임워크가 알아서 거부해 준다. 그 대신 모든 변경을 이벤트로 정의해야 하는 비용이 따른다.</p>
<p>상태가 2개 이상일때 도입하면 편하고 4개 이상이 되면 거의 필수다. 자신의 도메인에서 상태 흐름이 복잡해지기 시작했다면, 상태머신 도입을 고려해 보길 바란다.</p>
<hr />
<h2 id="heading-7lc46rogioyekoujja">참고 자료</h2>
<ul>
<li><a target="_blank" href="https://ko.wikipedia.org/wiki/%EC%9C%A0%ED%95%9C_%EC%83%81%ED%83%9C_%EA%B8%B0%EA%B3%84">유한 상태 기계 - 위키백과</a></li>
<li><a target="_blank" href="https://github.com/spring-projects/spring-statemachine">Spring Statemachine - GitHub</a></li>
<li><a target="_blank" href="https://docs.spring.io/spring-statemachine/docs/current/reference/">Spring Statemachine - Reference Documentation</a></li>
<li><a target="_blank" href="https://github.com/Tianea2160/statemachine">Tianea2160/statemachine - GitHub</a></li>
</ul>
]]></content:encoded></item><item><title><![CDATA[[백준] 삼각 그래프]]></title><description><![CDATA[Link : https://www.acmicpc.net/problem/4883
문제
이 문제는 삼각 그래프의 가장 위쪽 가운데 정점에서 가장 아래쪽 가운데 정점으로 가는 최단 경로를 찾는 문제이다.
삼각 그래프는 사이클이 없는 그래프로 N ≥ 2 개의 행과 3열로 이루어져 있다. 삼각 그래프는 보통 그래프와 다르게 간선이 아닌 정점에 비용이 있다. 어떤 경로의 비용은 그 경로에서 지나간 정점의 비용의 합이다.
오른쪽 그림은 N = 4인 삼각 그...]]></description><link>https://blog.hyunjun.org/wuwseykgf0g7ik86rcbioq3uouemo2uha</link><guid isPermaLink="true">https://blog.hyunjun.org/wuwseykgf0g7ik86rcbioq3uouemo2uha</guid><category><![CDATA[Dynamic Programming]]></category><dc:creator><![CDATA[조현준]]></dc:creator><pubDate>Mon, 04 Aug 2025 08:27:11 GMT</pubDate><content:encoded><![CDATA[<p>Link : <a target="_blank" href="https://www.acmicpc.net/problem/4883">https://www.acmicpc.net/problem/4883</a></p>
<h2 id="heading-66y47kcc">문제</h2>
<p>이 문제는 삼각 그래프의 가장 위쪽 가운데 정점에서 가장 아래쪽 가운데 정점으로 가는 최단 경로를 찾는 문제이다.</p>
<p>삼각 그래프는 사이클이 없는 그래프로 N ≥ 2 개의 행과 3열로 이루어져 있다. 삼각 그래프는 보통 그래프와 다르게 간선이 아닌 정점에 비용이 있다. 어떤 경로의 비용은 그 경로에서 지나간 정점의 비용의 합이다.</p>
<p>오른쪽 그림은 N = 4인 삼각 그래프이고, 가장 위쪽 가운데 정점에서 가장 아래쪽 가운데 정점으로 경로 중 아래로만 가는 경로의 비용은 7+13+3+6 = 29가 된다. 삼각 그래프의 간선은 항상 오른쪽 그림과 같은 형태로 연결되어 있다.</p>
<h2 id="heading-7j6f66cl">입력</h2>
<p>입력은 여러 개의 테스트 케이스로 이루어져 있다. 각 테스트 케이스의 첫째 줄에는 그래프의 행의 개수 N이 주어진다. (2 ≤ N ≤ 100,000) 다음 N개 줄에는 그래프의 i번째 행에 있는 정점의 비용이 순서대로 주어진다. 비용은 정수이며, 비용의 제곱은 1,000,000보다 작다.</p>
<p>입력의 마지막 줄에는 0이 하나 주어진다.</p>
<h2 id="heading-7lac66cl">출력</h2>
<p>각 테스트 케이스에 대해서, 가장 위쪽 가운데 정점에서 가장 아래쪽 가운데 정점으로 가는 최소 비용을 테스트 케이스 번호와 아래와 같은 형식으로 출력한다.</p>
<pre><code class="lang-kotlin">k. n
</code></pre>
<p>k는 테스트 케이스 번호, n은 최소 비용이다.</p>
<h2 id="heading-1">예제 입력 1</h2>
<pre><code class="lang-kotlin"><span class="hljs-number">4</span>
<span class="hljs-number">13</span> <span class="hljs-number">7</span> <span class="hljs-number">5</span>
<span class="hljs-number">7</span> <span class="hljs-number">13</span> <span class="hljs-number">6</span>
<span class="hljs-number">14</span> <span class="hljs-number">3</span> <span class="hljs-number">12</span>
<span class="hljs-number">15</span> <span class="hljs-number">6</span> <span class="hljs-number">16</span>
<span class="hljs-number">0</span>
</code></pre>
<h2 id="heading-1-1">예제 출력 1</h2>
<pre><code class="lang-kotlin"><span class="hljs-number">1</span>. <span class="hljs-number">22</span>
</code></pre>
<h2 id="heading-1-bfs">1차 시도(BFS)</h2>
<p>문제를 너무 가볍게 봤을때네느 그래프 탐색으로 풀 수 있을 것으로 생각을 했습니다. 그런데 BFS/DFS로 해결이 안되는 문제 였고 첫번때 시도는 실패로 돌아갔습니다.</p>
<pre><code class="lang-kotlin"><span class="hljs-keyword">import</span> java.util.*
<span class="hljs-keyword">import</span> kotlin.math.min

<span class="hljs-keyword">val</span> dx = arrayListOf(<span class="hljs-number">1</span>, <span class="hljs-number">1</span>, <span class="hljs-number">1</span>, <span class="hljs-number">0</span>)
<span class="hljs-keyword">val</span> dy = arrayListOf(-<span class="hljs-number">1</span>, <span class="hljs-number">0</span>, <span class="hljs-number">1</span>, <span class="hljs-number">1</span>)

<span class="hljs-function"><span class="hljs-keyword">fun</span> <span class="hljs-title">main</span><span class="hljs-params">()</span></span> {
    <span class="hljs-keyword">val</span> br = System.`<span class="hljs-keyword">in</span>`.bufferedReader()
    <span class="hljs-keyword">val</span> bw = System.<span class="hljs-keyword">out</span>.bufferedWriter()
    <span class="hljs-keyword">var</span> cnt = <span class="hljs-number">1</span>
    <span class="hljs-keyword">while</span> (<span class="hljs-literal">true</span>) {
        <span class="hljs-keyword">val</span> n = br.readLine().toInt()

        <span class="hljs-keyword">if</span> (n == <span class="hljs-number">0</span>) <span class="hljs-keyword">break</span>

        <span class="hljs-keyword">val</span> array = Array(n) { br.readLine().split(<span class="hljs-string">" "</span>).map { it.toInt() }.toIntArray() }

        <span class="hljs-keyword">val</span> weight = bfs(<span class="hljs-number">0</span> to <span class="hljs-number">1</span>, n-<span class="hljs-number">1</span> to <span class="hljs-number">1</span>, array)

        bw.write(<span class="hljs-string">"<span class="hljs-subst">${cnt++}</span>. <span class="hljs-variable">$weight</span>"</span>)
        bw.newLine()
    }

    br.close()
    bw.close()
}

<span class="hljs-function"><span class="hljs-keyword">fun</span> <span class="hljs-title">bfs</span><span class="hljs-params">(
    start: <span class="hljs-type">Pair</span>&lt;<span class="hljs-type">Int</span>, <span class="hljs-built_in">Int</span>&gt;,
    end: <span class="hljs-type">Pair</span>&lt;<span class="hljs-type">Int</span>, <span class="hljs-built_in">Int</span>&gt;,
    array: <span class="hljs-type">Array</span>&lt;<span class="hljs-type">IntArray</span>&gt;
)</span></span>: <span class="hljs-built_in">Int</span> {
    <span class="hljs-keyword">var</span> answer = <span class="hljs-built_in">Int</span>.MAX_VALUE
    <span class="hljs-keyword">val</span> queue = LinkedList&lt;Node&gt;()
    <span class="hljs-keyword">val</span> visited = mutableSetOf&lt;Pair&lt;<span class="hljs-built_in">Int</span>, <span class="hljs-built_in">Int</span>&gt;&gt;()

    queue.add(Node(start.first, start.second, array[start.first][start.second]))
    visited.add(start)

    <span class="hljs-keyword">while</span> (queue.isNotEmpty()) {
        <span class="hljs-keyword">val</span> cur = queue.poll()

        <span class="hljs-keyword">if</span> (cur.x to cur.y == end) {
            answer = min(answer, cur.weight)
        }

        <span class="hljs-keyword">for</span> (i <span class="hljs-keyword">in</span> <span class="hljs-number">0</span> until <span class="hljs-number">4</span>) {
            <span class="hljs-keyword">val</span> nx = cur.x + dx[i]
            <span class="hljs-keyword">val</span> ny = cur.y + dy[i]

            <span class="hljs-keyword">if</span> (nx &lt; <span class="hljs-number">0</span> || ny &lt; <span class="hljs-number">0</span> || nx &gt; array.lastIndex || ny &gt; array[<span class="hljs-number">0</span>].lastIndex) {
                <span class="hljs-keyword">continue</span>
            }

            visited.add(nx to ny)
            queue.add(Node(nx, ny, array[nx][ny] + cur.weight))
        }
    }
    <span class="hljs-keyword">return</span> answer
}

<span class="hljs-class"><span class="hljs-keyword">class</span> <span class="hljs-title">Node</span></span>(
    <span class="hljs-keyword">val</span> x: <span class="hljs-built_in">Int</span>,
    <span class="hljs-keyword">val</span> y: <span class="hljs-built_in">Int</span>,
    <span class="hljs-keyword">val</span> weight: <span class="hljs-built_in">Int</span>,
)
</code></pre>
<h2 id="heading-2-dp">2차 시도(DP)</h2>
<p>2번째로 다익스트라를 사용할까 했지만 다익스트라의 조건(가장 최선의 결과를 따라 갔을때 최적의 결과가 나온다는 보장을 할 수 있어야한다)을 만족할 수 없다는 생각을 하게 되었고 이건 DP로 푸는 것이 맞다라는 결론에 도달하게 됩니다.</p>
<p>그래서 점화식을 세우고 문제를 해결하게되었습니다.</p>
<p>점화식은 다음과 같습니다.</p>
<p>$$$$\begin{align} &amp;DP[i][j] = (i, j)위치에서 가지는 가장 적은 비용 \\ &amp;DP[i][0] = min(DP[i-1][0], DP[i-1][1]) + array[i][0] \\ &amp;DP[i][1] = min(DP[i-1][0], DP[i-1][1], DP[i-1][2], DP[i][0]) + array[i][1] \\ &amp;DP[i][2] = min(DP[i-1][1], DP[i-1][2], DP[i][1]) + array[i][2] \end{align}$$</p><p>$$</p>
<p>점화식이 복잡하다고 생각이되지만 삼각 그래프의 특성으로 인해서 같은 행, 열일때와 아닐때는 구분해서 점화식에 고려를 해줘야하기 때문에 복잡할 수 밖에 없다고 생각이 됩니다.</p>
<p>결론 적으로 코드로 적으면 아래와 같습니다.</p>
<pre><code class="lang-kotlin"><span class="hljs-function"><span class="hljs-keyword">fun</span> <span class="hljs-title">main</span><span class="hljs-params">()</span></span> {
    <span class="hljs-keyword">val</span> br = System.`<span class="hljs-keyword">in</span>`.bufferedReader()
    <span class="hljs-keyword">val</span> bw = System.<span class="hljs-keyword">out</span>.bufferedWriter()
    <span class="hljs-keyword">var</span> cnt = <span class="hljs-number">1</span>

    <span class="hljs-keyword">while</span> (<span class="hljs-literal">true</span>) {
        <span class="hljs-keyword">val</span> n = br.readLine().toInt()

        <span class="hljs-keyword">if</span> (n == <span class="hljs-number">0</span>) <span class="hljs-keyword">break</span>

        <span class="hljs-keyword">val</span> array = Array(n) { br.readLine().split(<span class="hljs-string">" "</span>).map { it.toInt() }.toIntArray() }
        <span class="hljs-keyword">val</span> dp = Array(n) { IntArray(<span class="hljs-number">3</span>) { <span class="hljs-built_in">Int</span>.MAX_VALUE } }

        <span class="hljs-comment">// init</span>
        dp[<span class="hljs-number">0</span>][<span class="hljs-number">1</span>] = array[<span class="hljs-number">0</span>][<span class="hljs-number">1</span>]
        dp[<span class="hljs-number">0</span>][<span class="hljs-number">2</span>] = dp[<span class="hljs-number">0</span>][<span class="hljs-number">1</span>] + array[<span class="hljs-number">0</span>][<span class="hljs-number">2</span>]

        <span class="hljs-keyword">for</span> (i <span class="hljs-keyword">in</span> <span class="hljs-number">1</span> until n) {
            dp[i][<span class="hljs-number">0</span>] = minOf(dp[i - <span class="hljs-number">1</span>][<span class="hljs-number">0</span>], dp[i - <span class="hljs-number">1</span>][<span class="hljs-number">1</span>]) + array[i][<span class="hljs-number">0</span>]
            dp[i][<span class="hljs-number">1</span>] = minOf(dp[i - <span class="hljs-number">1</span>][<span class="hljs-number">0</span>], dp[i - <span class="hljs-number">1</span>][<span class="hljs-number">1</span>], dp[i - <span class="hljs-number">1</span>][<span class="hljs-number">2</span>], dp[i][<span class="hljs-number">0</span>]) + array[i][<span class="hljs-number">1</span>]
            dp[i][<span class="hljs-number">2</span>] = minOf(dp[i - <span class="hljs-number">1</span>][<span class="hljs-number">1</span>], dp[i - <span class="hljs-number">1</span>][<span class="hljs-number">2</span>], dp[i][<span class="hljs-number">1</span>]) + array[i][<span class="hljs-number">2</span>]
        }

        bw.write(<span class="hljs-string">"<span class="hljs-subst">${cnt++}</span>. <span class="hljs-subst">${dp[n - <span class="hljs-number">1</span>][<span class="hljs-number">1</span>]}</span>"</span>)
        bw.newLine()
    }

    br.close()
    bw.close()
}
</code></pre>
<p>여기서 주의할 점, 처음 row를 초기화를 잘 해야합니다.</p>
<pre><code class="lang-kotlin">        dp[<span class="hljs-number">0</span>][<span class="hljs-number">1</span>] = array[<span class="hljs-number">0</span>][<span class="hljs-number">1</span>]
        dp[<span class="hljs-number">0</span>][<span class="hljs-number">2</span>] = dp[<span class="hljs-number">0</span>][<span class="hljs-number">1</span>] + array[<span class="hljs-number">0</span>][<span class="hljs-number">2</span>]
</code></pre>
<p>(0, 1)에서 동작하기 때문에 (0, 1),(0, 2)에 대해서 초기화를 직접 해줘야하는 것을 고려해야합니다.</p>
]]></content:encoded></item><item><title><![CDATA[[LeetCode] 2434. Using a Robot to Print the Lexicographically Smallest String]]></title><description><![CDATA[Edit this2434. Using a Robot to Print the Lexicographically Smallest String text
문제 설명
You are given a string s and a robot that currently holds an empty string t. Apply one of the following operations until s and t are both empty:

Remove the first ...]]></description><link>https://blog.hyunjun.org/leetcode-2434-using-a-robot-to-print-the-lexicographically-smallest-string</link><guid isPermaLink="true">https://blog.hyunjun.org/leetcode-2434-using-a-robot-to-print-the-lexicographically-smallest-string</guid><category><![CDATA[stack]]></category><category><![CDATA[leetcode]]></category><dc:creator><![CDATA[조현준]]></dc:creator><pubDate>Mon, 23 Jun 2025 04:33:39 GMT</pubDate><content:encoded><![CDATA[<p><a target="_blank" href="https://leetcode.com/problems/using-a-robot-to-print-the-lexicographically-smallest-string/description/?envType=daily-question&amp;envId=2025-06-06">Edit this2434. Using a Robot to Print the Lexicographically Smallest String text</a></p>
<h2 id="heading-66y47kccioyepouqhq">문제 설명</h2>
<p>You are given a string <code>s</code> and a robot that currently holds an empty string <code>t</code>. Apply one of the following operations until <code>s</code> and <code>t</code> <strong>are both empty</strong>:</p>
<ul>
<li><p>Remove the <strong>first</strong> character of a string <code>s</code> and give it to the robot. The robot will append this character to the string <code>t</code>.</p>
</li>
<li><p>Remove the <strong>last</strong> character of a string <code>t</code> and give it to the robot. The robot will write this character on paper.</p>
</li>
</ul>
<p>Return <em>the lexicographically smallest string that can be written on the paper.</em></p>
<p><strong>Example 1:</strong></p>
<pre><code class="lang-plaintext">Input: s = "zza"
Output: "azz"
Explanation: Let p denote the written string.
Initially p="", s="zza", t="".
Perform first operation three times p="", s="", t="zza".
Perform second operation three times p="azz", s="", t="".
</code></pre>
<p><strong>Example 2:</strong></p>
<pre><code class="lang-plaintext">Input: s = "bac"
Output: "abc"
Explanation: Let p denote the written string.
Perform first operation twice p="", s="c", t="ba". 
Perform second operation twice p="ab", s="c", t="". 
Perform first operation p="ab", s="", t="c". 
Perform second operation p="abc", s="", t="".
</code></pre>
<p><strong>Example 3:</strong></p>
<pre><code class="lang-plaintext">Input: s = "bdda"
Output: "addb"
Explanation: Let p denote the written string.
Initially p="", s="bdda", t="".
Perform first operation four times p="", s="", t="bdda".
Perform second operation four times p="addb", s="", t="".
</code></pre>
<p><strong>Constraints:</strong></p>
<ul>
<li><p><code>1 &lt;= s.length &lt;= 10&lt;sup&gt;5&lt;/sup&gt;</code></p>
</li>
<li><p><code>s</code> consists of only English lowercase letters.</p>
</li>
</ul>
<h2 id="heading-66y47kccioyaloyvvq">문제 요약</h2>
<ul>
<li><p>로봇과 내가 각각 문자열 t, s를 들고 있습니다.</p>
</li>
<li><p>t, s가 모두 빈 문자열이 될때까지 다음의 동작을 반복합니다</p>
</li>
<li><p>s의 맨 앞 문자을 때서 t에 뒤로 붙입니다</p>
</li>
<li><p>t의 맵 뒤 문자를 때서 로봇이 그걸 종이에 출력합니다.</p>
</li>
<li><p>이때 출력되는 문자가 사전적으로 가장 순서가 빠른 문자를 출력하도록 문자열을 구성하시오</p>
</li>
</ul>
<h2 id="heading-67ae7isd">분석</h2>
<p>결국 해당 문제는 정해진 규칙 내에서 사전적으로 가장 순서가 빠른 문자를 출력할 수 있도록 하는 문제입니다.</p>
<p>가능 빠른 문자를 출력하기 위해서는 가장 작은 문자가 먼저 출력되도록 처리하는 것이 좋습니다.</p>
<p>그래서 문제를 탐욕적으로 해결 할 수 있을 것으로 보입니다.</p>
<h3 id="heading-7lkriouyioynucdsojhqt7w6ioyngeq0goyggsdtlbtqsrdrspu">첫 번째 접근: 직관적 해결법</h3>
<p>가장 먼저 떠오르는 방법은 <strong>"현재 상황에서 최선의 선택하기"</strong>입니다.</p>
<pre><code class="lang-kotlin">kotlinclass Solution {
    <span class="hljs-function"><span class="hljs-keyword">fun</span> <span class="hljs-title">robotWithString</span><span class="hljs-params">(s: <span class="hljs-type">String</span>)</span></span>: String {
        <span class="hljs-keyword">var</span> t = <span class="hljs-string">""</span>
        <span class="hljs-keyword">var</span> u = s
        <span class="hljs-keyword">var</span> answer = <span class="hljs-string">""</span>

        <span class="hljs-keyword">while</span> (u.isNotEmpty()) {
            <span class="hljs-keyword">val</span> a = u.min()  <span class="hljs-comment">// 남은 문자 중 최소값</span>
            <span class="hljs-keyword">val</span> b = t.lastOrNull()  <span class="hljs-comment">// 스택 top</span>

            <span class="hljs-keyword">if</span> (b == <span class="hljs-literal">null</span> || a &lt; b) {
                <span class="hljs-comment">// 더 작은 문자가 남아있으니 계속 스택에 쌓기</span>
                <span class="hljs-keyword">val</span> idx = u.indexOfFirst { it == a }
                <span class="hljs-keyword">for</span> (i <span class="hljs-keyword">in</span> <span class="hljs-number">0</span>..idx) {
                    t += u[i]
                }
                u = u.substring(idx + <span class="hljs-number">1</span>)
            } <span class="hljs-keyword">else</span> {
                <span class="hljs-comment">// 스택 top을 꺼내는 것이 유리</span>
                answer += b
                t = t.substring(<span class="hljs-number">0</span>, t.lastIndex)
            }
        }

        <span class="hljs-comment">// 남은 스택 모두 pop</span>
        <span class="hljs-keyword">for</span> (i <span class="hljs-keyword">in</span> t.lastIndex downTo <span class="hljs-number">0</span>) {
            answer += t[i]
        }

        <span class="hljs-keyword">return</span> answer
    }
}
</code></pre>
<h3 id="heading-7isx64qliousuoygnoygka">성능 문제점</h3>
<p>이 접근법은 논리적으로는 맞지만 심각한 성능 문제가 있습니다:</p>
<ol>
<li><p><strong>매번 u.min() 계산</strong>: O(n) × O(n) = O(n²)</p>
</li>
<li><p><strong>문자열 연결 연산</strong>: <code>t += u[i]</code>가 O(n²) 시간 소요</p>
</li>
<li><p><strong>substring 연산</strong>: 매번 새로운 문자열 객체 생성</p>
</li>
<li><p><strong>전체 시간복잡도</strong>: O(n³)</p>
</li>
</ol>
<h3 id="heading-minsuffix">최적화 아이디어: minSuffix 전처리</h3>
<p>핵심 통찰: <strong>"매번 최소값을 찾지 말고, 미리 계산해두자!"</strong></p>
<pre><code class="lang-kotlin">kotlin<span class="hljs-comment">// 각 위치에서 그 위치부터 끝까지의 최소값을 미리 계산</span>
<span class="hljs-keyword">val</span> minSuffix = CharArray(n)
minSuffix[n-<span class="hljs-number">1</span>] = s[n-<span class="hljs-number">1</span>]
<span class="hljs-keyword">for</span> (i <span class="hljs-keyword">in</span> n-<span class="hljs-number">2</span> downTo <span class="hljs-number">0</span>) {
    minSuffix[i] = minOf(s[i], minSuffix[i+<span class="hljs-number">1</span>])
}
</code></pre>
<h3 id="heading-7jii7iuc66gcioydto2vto2vmoq4sa">예시로 이해하기</h3>
<pre><code class="lang-kotlin">s = <span class="hljs-string">"bac"</span>

minSuffix 계산:
minSuffix[<span class="hljs-number">2</span>] = <span class="hljs-string">'c'</span>           <span class="hljs-comment">// 위치 2부터 끝: "c" → 최소값 'c'</span>
minSuffix[<span class="hljs-number">1</span>] = min(<span class="hljs-string">'a'</span>,<span class="hljs-string">'c'</span>) = <span class="hljs-string">'a'</span>  <span class="hljs-comment">// 위치 1부터 끝: "ac" → 최소값 'a'  </span>
minSuffix[<span class="hljs-number">0</span>] = min(<span class="hljs-string">'b'</span>,<span class="hljs-string">'a'</span>) = <span class="hljs-string">'a'</span>  <span class="hljs-comment">// 위치 0부터 끝: "bac" → 최소값 'a'</span>

결과: minSuffix = [<span class="hljs-string">'a'</span>, <span class="hljs-string">'a'</span>, <span class="hljs-string">'c'</span>]
</code></pre>
<h2 id="heading-7zw064u1">해답</h2>
<pre><code class="lang-kotlin"><span class="hljs-keyword">import</span> java.util.*

<span class="hljs-class"><span class="hljs-keyword">class</span> <span class="hljs-title">Solution</span> </span>{
    <span class="hljs-function"><span class="hljs-keyword">fun</span> <span class="hljs-title">robotWithString</span><span class="hljs-params">(s: <span class="hljs-type">String</span>)</span></span>: String {
        <span class="hljs-keyword">val</span> answer = StringBuilder()
        <span class="hljs-keyword">val</span> t = ArrayDeque&lt;<span class="hljs-built_in">Char</span>&gt;()
        <span class="hljs-keyword">val</span> minSuffix = CharArray(s.length)

        minSuffix[s.lastIndex] = s[s.lastIndex]

        <span class="hljs-keyword">for</span> (i <span class="hljs-keyword">in</span> s.length - <span class="hljs-number">2</span> downTo <span class="hljs-number">0</span>) {
            minSuffix[i] = minOf(minSuffix[i + <span class="hljs-number">1</span>], s[i])
        }

        <span class="hljs-keyword">var</span> i = <span class="hljs-number">0</span>
        <span class="hljs-keyword">val</span> n = s.length

        <span class="hljs-keyword">while</span> (i &lt; n || t.isNotEmpty()) {

            <span class="hljs-keyword">while</span> (t.isNotEmpty() &amp;&amp; (i &gt;= n || t.last() &lt;= minSuffix[i])) {
                answer.append(t.removeLast())
            }

            <span class="hljs-keyword">if</span> (i &lt; n) {
                t.addLast(s[i])
                i++
            }
        }

        <span class="hljs-keyword">return</span> answer.toString()
    }
}
</code></pre>
]]></content:encoded></item><item><title><![CDATA[Kafka Streams로 실시간 데이터 처리하기: merge()와 join() 연산 비교]]></title><description><![CDATA[실시간 데이터 처리는 현대 애플리케이션에서 필수적인 요소가 되었습니다. 수많은 이벤트가 끊임없이 발생하는 환경에서 이를 효과적으로 처리하기 위해 Kafka와 같은 메시징 시스템이 널리 사용되고 있습니다. 특히 Kafka Streams API는 복잡한 스트림 처리 애플리케이션을 손쉽게 구축할 수 있게 해주는 강력한 도구입니다.
이 글에서는 Kafka Stre]]></description><link>https://blog.hyunjun.org/kafka-streams-merge-join</link><guid isPermaLink="true">https://blog.hyunjun.org/kafka-streams-merge-join</guid><category><![CDATA[kafka]]></category><category><![CDATA[Kafka streams]]></category><dc:creator><![CDATA[조현준]]></dc:creator><pubDate>Sun, 18 May 2025 13:13:24 GMT</pubDate><content:encoded><![CDATA[<p>실시간 데이터 처리는 현대 애플리케이션에서 필수적인 요소가 되었습니다. 수많은 이벤트가 끊임없이 발생하는 환경에서 이를 효과적으로 처리하기 위해 Kafka와 같은 메시징 시스템이 널리 사용되고 있습니다. 특히 Kafka Streams API는 복잡한 스트림 처리 애플리케이션을 손쉽게 구축할 수 있게 해주는 강력한 도구입니다.</p>
<p>이 글에서는 Kafka Streams의 대표적인 두 연산인 <code>merge()</code>와 <code>join()</code>의 차이점과 실제 구현 방법을 살펴보겠습니다. 실제 예제 코드와 함께 각 연산의 동작 방식, 성능 특성, 그리고 적합한 사용 사례를 비교해 보겠습니다.</p>
<h2>Kafka Streams 소개</h2>
<p>Kafka Streams는 Apache Kafka에서 제공하는 클라이언트 라이브러리로, 스트림 처리 애플리케이션을 쉽게 개발할 수 있도록 도와줍니다. 일반적인 ETL(Extract, Transform, Load) 도구나 데이터 처리 프레임워크와 달리, Kafka Streams는 별도의 클러스터나 인프라 없이 표준 Java 애플리케이션으로 실행됩니다.</p>
<p>Kafka Streams의 주요 특징:</p>
<ul>
<li><p><strong>상태 관리</strong>: 로컬 상태 저장소를 통한 효율적인 상태 관리</p>
</li>
<li><p><strong>실시간 처리</strong>: 이벤트 발생 즉시 처리 가능</p>
</li>
<li><p><strong>내결함성</strong>: 장애 발생 시 자동 복구 메커니즘</p>
</li>
<li><p><strong>확장성</strong>: 수평적 확장을 통한 병렬 처리</p>
</li>
<li><p><strong>정확히 한 번 처리</strong>: 정확히 한 번 처리 보장(exactly-once semantics)</p>
</li>
</ul>
<h2>프로젝트 설정</h2>
<p>이 예제는 Spring Boot와 Kafka Streams를 사용하여 구현되었습니다. 주요 의존성은 다음과 같습니다:</p>
<pre><code class="language-yaml">kotlindependencies {
    implementation("org.springframework.boot:spring-boot-starter-web")
    implementation("org.springframework.kafka:spring-kafka")
    implementation("org.apache.kafka:kafka-streams")
    implementation("com.fasterxml.jackson.module:jackson-module-kotlin")
    implementation("org.jetbrains.kotlin:kotlin-reflect")
}
</code></pre>
<p>application.yml 파일에는 Kafka 및 Kafka Streams 관련 설정을 추가합니다:</p>
<pre><code class="language-yaml">yamlspring:
  kafka:
    bootstrap-servers: localhost:9092
    streams:
      application-id: kafka-stream-example
      properties:
        default.key.serde: org.apache.kafka.common.serialization.Serdes$StringSerde
        default.value.serde: org.apache.kafka.common.serialization.Serdes$StringSerde
        num.stream.threads: 3
</code></pre>
<h2>기본 스트림 처리 구현</h2>
<p>먼저 가장 기본적인 스트림 처리 로직을 구현해 보겠습니다. 하나의 입력 토픽에서 메시지를 읽어 처리한 후 출력 토픽으로 전송하는 예제입니다:</p>
<pre><code class="language-kotlin">@Configuration
@EnableKafkaStreams
class StreamsProcessor {

    private val logger = LoggerFactory.getLogger(StreamsProcessor::class.java)

    @Value("\${kafka.input-topic:input-topic}")
    private lateinit var inputTopic: String

    @Value("\${kafka.output-topic:output-topic}")
    private lateinit var outputTopic: String

    @Bean
    fun kStream(streamsBuilder: StreamsBuilder): KStream&lt;String, String&gt; {
        // 입력 토픽에서 스트림 생성
        val stream = streamsBuilder.stream&lt;String, String&gt;(inputTopic)
        
        // 간단한 변환 수행
        stream
            .peek { key, value -&gt; logger.info("스트림 입력: {} - {}", key, value) }
            .mapValues { value -&gt; "$value (processed)" }
            .peek { key, value -&gt; logger.info("스트림 출력: {} - {}", key, value) }
            .to(outputTopic)

        return stream
    }
}
</code></pre>
<p>이 코드는 <code>StreamsBuilder</code>를 사용하여 입력 토픽으로부터 스트림을 생성하고, 메시지를 처리한 후 출력 토픽으로 전송하는 기본적인 토폴로지를 구성합니다. <code>peek()</code> 메서드를 통해 처리 과정을 로깅하고, <code>mapValues()</code>를 사용하여 메시지 값을 변환합니다.</p>
<h2>merge() 연산: 토픽 병합하기</h2>
<p>이제 <code>merge()</code> 연산을 사용하여 두 개의 입력 토픽을 하나의 출력 토픽으로 병합하는 로직을 구현해 보겠습니다:</p>
<pre><code class="language-kotlin">@Configuration
@EnableKafkaStreams
class TopicMergeProcessor {

    private val logger = LoggerFactory.getLogger(TopicMergeProcessor::class.java)

    @Value("\${kafka.merge-input-topic-1:merge-input-topic-1}")
    private lateinit var mergeInputTopic1: String

    @Value("\${kafka.merge-input-topic-2:merge-input-topic-2}")
    private lateinit var mergeInputTopic2: String

    @Value("\${kafka.merge-output-topic-1:merge-output-topic-1}")
    private lateinit var mergeOutputTopic1: String

    @Bean
    fun topicMergeStream(streamsBuilder: StreamsBuilder): KStream&lt;String, String&gt; {
        val stringSerde = Serdes.String()
        
        // 첫 번째 입력 토픽에서 스트림 생성
        val inputStream1: KStream&lt;String, String&gt; = streamsBuilder.stream(
            mergeInputTopic1,
            Consumed.with(stringSerde, stringSerde)
        )
        
        // 두 번째 입력 토픽에서 스트림 생성
        val inputStream2: KStream&lt;String, String&gt; = streamsBuilder.stream(
            mergeInputTopic2,
            Consumed.with(stringSerde, stringSerde)
        )
        
        // 첫 번째 스트림에 처리 지연 및 메타데이터 추가
        val taggedStream1: KStream&lt;String, String&gt; = inputStream1
            .peek { key, value -&gt; 
                logger.info("입력 토픽1 처리 시작: key={}, value={}", key, value)
                Thread.sleep(500)  // 긴 처리 시간 (500ms)
                logger.info("입력 토픽1 처리 완료: key={}, value={}", key, value)
            }
            .mapValues { value -&gt; "{ \"source\": \"\({mergeInputTopic1}\", \"data\": \"\)value\" }" }
        
        // 두 번째 스트림에 짧은 처리 지연 및 메타데이터 추가
        val taggedStream2: KStream&lt;String, String&gt; = inputStream2
            .peek { key, value -&gt; 
                logger.info("입력 토픽2 처리 시작: key={}, value={}", key, value)
                Thread.sleep(50)  // 짧은 처리 시간 (50ms)
                logger.info("입력 토픽2 처리 완료: key={}, value={}", key, value)
            }
            .mapValues { value -&gt; "{ \"source\": \"\({mergeInputTopic2}\", \"data\": \"\)value\" }" }
        
        // 두 스트림 병합
        val mergedStream: KStream&lt;String, String&gt; = taggedStream1
            .merge(taggedStream2)
            .peek { key, value -&gt; logger.info("병합된 출력: key={}, value={}", key, value) }
        
        // 병합된 스트림을 출력 토픽으로 전송
        mergedStream.to(mergeOutputTopic1, Produced.with(stringSerde, stringSerde))
        
        return mergedStream
    }
}
</code></pre>
<p>이 예제에서는 두 스트림에 의도적으로 다른 처리 시간을 부여하여 병합 동작을 관찰할 수 있도록 했습니다. <code>merge()</code> 연산은 두 스트림의 모든 메시지를 도착 순서대로 하나의 스트림으로 결합합니다.</p>
<h3>테스트 결과</h3>
<p><code>merge()</code> 연산을 테스트한 결과, 다음과 같은 특징을 관찰할 수 있었습니다:</p>
<ol>
<li><p><strong>메시지 순서</strong>: 처리 시간이 짧은 토픽2의 메시지가 토픽1보다 먼저 출력 토픽에 도달</p>
</li>
<li><p><strong>처리 독립성</strong>: 각 토픽의 메시지는 서로 독립적으로 처리됨</p>
</li>
<li><p><strong>파티션 영향</strong>: 단일 파티션/스레드 환경에서는 발행 순서가 보존될 수 있지만, 멀티 파티션 환경에서는 순서가 섞임</p>
</li>
</ol>
<p>로그 출력 예시:</p>
<pre><code class="language-powershell">입력 토픽1 처리 시작: key=topic1-key-1, value=Topic1 테스트 메시지 1
입력 토픽2 처리 시작: key=topic2-key-1, value=Topic2 테스트 메시지 1
입력 토픽2 처리 완료: key=topic2-key-1, value=Topic2 테스트 메시지 1
병합된 출력: key=topic2-key-1, value={ "source": "merge-input-topic-2", "data": "Topic2 테스트 메시지 1" }
입력 토픽2 처리 시작: key=topic2-key-2, value=Topic2 테스트 메시지 2
입력 토픽2 처리 완료: key=topic2-key-2, value=Topic2 테스트 메시지 2
병합된 출력: key=topic2-key-2, value={ "source": "merge-input-topic-2", "data": "Topic2 테스트 메시지 2" }
입력 토픽1 처리 완료: key=topic1-key-1, value=Topic1 테스트 메시지 1
병합된 출력: key=topic1-key-1, value={ "source": "merge-input-topic-1", "data": "Topic1 테스트 메시지 1" }
</code></pre>
<h2>join() 연산: 키 기반 조인하기</h2>
<p><code>join()</code> 연산은 두 스트림에서 동일한 키를 가진 메시지를 결합합니다. 다음은 시간 윈도우 내에서 키 기반 조인을 수행하는 예제입니다:</p>
<pre><code class="language-kotlin">@Configuration
@EnableKafkaStreams
class JoinStreamsProcessor {

    private val logger = LoggerFactory.getLogger(JoinStreamsProcessor::class.java)

    @Value("\${kafka.join-input-topic-1:join-input-topic-1}")
    private lateinit var joinInputTopic1: String

    @Value("\${kafka.join-input-topic-2:join-input-topic-2}")
    private lateinit var joinInputTopic2: String

    @Value("\${kafka.join-output-topic:join-output-topic}")
    private lateinit var joinOutputTopic: String

    @Bean
    fun joinStream(streamsBuilder: StreamsBuilder): KStream&lt;String, String&gt; {
        val stringSerde = Serdes.String()
        
        // 첫 번째 입력 토픽에서 스트림 생성
        val stream1: KStream&lt;String, String&gt; = streamsBuilder.stream(
            joinInputTopic1,
            Consumed.with(stringSerde, stringSerde)
        )
        
        // 두 번째 입력 토픽에서 스트림 생성
        val stream2: KStream&lt;String, String&gt; = streamsBuilder.stream(
            joinInputTopic2,
            Consumed.with(stringSerde, stringSerde)
        )
        
        // 로깅 추가
        val loggedStream1 = stream1.peek { key, value -&gt; 
            logger.info("Join 입력 토픽1 수신: key={}, value={}", key, value)
        }
        
        val loggedStream2 = stream2.peek { key, value -&gt; 
            logger.info("Join 입력 토픽2 수신: key={}, value={}", key, value)
        }
        
        // 윈도우 조인 설정 (5초 내에 도착한 메시지 결합)
        val joinWindow = JoinWindows.ofTimeDifferenceWithNoGrace(Duration.ofSeconds(5))
        
        // 스트림 조인 수행 (동일한 키를 가진 메시지 결합)
        val joinedStream: KStream&lt;String, String&gt; = loggedStream1.join(
            loggedStream2,
            { value1, value2 -&gt; 
                """{"topic1": "\(value1", "topic2": "\)value2", "joinTime": "${System.currentTimeMillis()}"}"""
            },
            joinWindow,
            StreamJoined.with(stringSerde, stringSerde, stringSerde)
        )
        
        // 조인 결과 로깅
        val resultStream = joinedStream.peek { key, value -&gt; 
            logger.info("Join 결과: key={}, value={}", key, value)
        }
        
        // 조인된 스트림을 출력 토픽으로 전송
        resultStream.to(joinOutputTopic, Produced.with(stringSerde, stringSerde))
        
        return resultStream
    }
}
</code></pre>
<p>이 코드는 5초 시간 윈도우 내에서 동일한 키를 가진 두 토픽의 메시지를 조인합니다. 조인 결과로 두 메시지의 값이 결합된 새로운 JSON 형식의 메시지가 생성됩니다.</p>
<h3>윈도우 조인 테스트</h3>
<p>윈도우 기반 조인을 테스트하기 위해 다음과 같은 시나리오를 구현했습니다:</p>
<ol>
<li><p><strong>동일한 키, 윈도우 내</strong>: 같은 키로 두 토픽에 2초 간격으로 메시지 발행 (조인됨)</p>
</li>
<li><p><strong>동일한 키, 윈도우 밖</strong>: 같은 키로 두 토픽에 6초 간격으로 메시지 발행 (조인되지 않음)</p>
</li>
<li><p><strong>다른 키</strong>: 서로 다른 키로 메시지 발행 (조인되지 않음)</p>
</li>
</ol>
<pre><code class="language-kotlin">@GetMapping("/test-windowed")
fun testWindowedJoin(): Map&lt;String, String&gt; {
    // 동일한 키를 사용하지만 시간차를 두고 발행 (2초 간격, 윈도우 내)
    for (i in 1..3) {
        val key = "windowed-key-$i"
        joinProducerService.sendToTopic1(key, "Join 토픽1 윈도우 테스트 메시지 $i")
        
        // 2초 후 발행 (5초 윈도우 내에서 Join 됨)
        executor.schedule({
            joinProducerService.sendToTopic2(key, "Join 토픽2 윈도우 테스트 메시지 $i")
        }, 2, TimeUnit.SECONDS)
    }
    
    // 윈도우 범위를 벗어나는 케이스 (6초 후 발행)
    val lateKey = "late-key"
    joinProducerService.sendToTopic1(lateKey, "Join 토픽1 지연 테스트 메시지")
    
    // 6초 후 발행 (5초 윈도우를 벗어남)
    executor.schedule({
        joinProducerService.sendToTopic2(lateKey, "Join 토픽2 지연 테스트 메시지")
    }, 6, TimeUnit.SECONDS)
    
    // 서로 다른 키 테스트 (Join 되지 않음)
    joinProducerService.sendToTopic1("different-key-1", "Join 토픽1 다른 키 테스트")
    joinProducerService.sendToTopic2("different-key-2", "Join 토픽2 다른 키 테스트")
    
    return mapOf("result" to "Join 윈도우 테스트가 시작되었습니다. 약 6초간 메시지가 순차적으로 발행됩니다.")
}
</code></pre>
<h3>테스트 결과</h3>
<p><code>join()</code> 연산을 테스트한 결과, 다음과 같은 특징을 관찰할 수 있었습니다:</p>
<ol>
<li><p><strong>키 기반 조인</strong>: 동일한 키를 가진 메시지만 조인됨</p>
</li>
<li><p><strong>시간 윈도우</strong>: 5초 이내에 도착한 메시지만 조인됨 (윈도우 밖 메시지는 조인되지 않음)</p>
</li>
<li><p><strong>상태 저장</strong>: Join 연산은 스테이트풀 연산으로, 내부적으로 상태를 유지함</p>
</li>
</ol>
<p>로그 출력 예시:</p>
<pre><code class="language-powershell">Join 입력 토픽1 수신: key=windowed-key-1, value=Join 토픽1 윈도우 테스트 메시지 1
Join 입력 토픽2 수신: key=windowed-key-1, value=Join 토픽2 윈도우 테스트 메시지 1
Join 결과: key=windowed-key-1, value={"topic1": "Join 토픽1 윈도우 테스트 메시지 1", "topic2": "Join 토픽2 윈도우 테스트 메시지 1", "joinTime": "1621487654321"}
Join 입력 토픽1 수신: key=late-key, value=Join 토픽1 지연 테스트 메시지
Join 입력 토픽2 수신: key=late-key, value=Join 토픽2 지연 테스트 메시지
// 주의: late-key는 6초 간격으로 발행되어 조인 결과가 없음
Join 입력 토픽1 수신: key=different-key-1, value=Join 토픽1 다른 키 테스트
Join 입력 토픽2 수신: key=different-key-2, value=Join 토픽2 다른 키 테스트
// 주의: 다른 키로 발행되어 조인 결과가 없음
</code></pre>
<h2>파티션과 병렬 처리</h2>
<p>Kafka는 토픽을 파티션으로 분할하여 병렬 처리를 지원합니다. 파티션 수와 스레드 수를 조정하여 처리 성능을 최적화할 수 있습니다.</p>
<h3>파티션 설정</h3>
<pre><code class="language-kotlin">@Bean
fun joinInputTopic1(): NewTopic {
    return TopicBuilder.name(joinInputTopic1)
        .partitions(3)  // 파티션 수를 3으로 설정
        .replicas(1)
        .build()
}
</code></pre>
<h3>스레드 설정</h3>
<pre><code class="language-yaml">spring:
  kafka:
    streams:
      properties:
        num.stream.threads: 3  # Kafka Streams 처리 스레드 수
</code></pre>
<h3>컨슈머 동시성 설정</h3>
<pre><code class="language-kotlin">@KafkaListener(
    topics = ["\${kafka.join-output-topic:join-output-topic}"],
    groupId = "join-consumer-group",
    concurrency = "3"  // 각 리스너마다 3개의 스레드 사용
)
</code></pre>
<p>테스트 결과, 파티션 수와 컨슈머 동시성을 함께 증가시켰을 때 처리량이 크게 향상되는 것을 확인할 수 있었습니다. 특히 <code>merge()</code> 연산의 경우 병렬 처리 효과가 더 두드러졌습니다.</p>
<h2>시간 윈도우 기반 처리</h2>
<p>Kafka Streams는 시간 윈도우 기반 처리를 지원합니다. 이는 특히 <code>join()</code> 연산에서 유용하게 사용됩니다.</p>
<pre><code class="language-kotlin">// 5초 시간 윈도우 내에서 조인
val joinWindow = JoinWindows.ofTimeDifferenceWithNoGrace(Duration.ofSeconds(5))
</code></pre>
<p>시간 윈도우 설정을 통해 다양한 시나리오를 구현할 수 있습니다:</p>
<ol>
<li><p><strong>텀블링 윈도우(Tumbling Window)</strong>: 고정 크기, 겹치지 않는 윈도우</p>
</li>
<li><p><strong>호핑 윈도우(Hopping Window)</strong>: 고정 크기, 겹치는 윈도우</p>
</li>
<li><p><strong>슬라이딩 윈도우(Sliding Window)</strong>: 동적 크기, 연속적으로 이동하는 윈도우</p>
</li>
<li><p><strong>세션 윈도우(Session Window)</strong>: 비활동 간격으로 구분되는 윈도우</p>
</li>
</ol>
<p>시간 윈도우 기반 처리는 시간적 연관성이 중요한 이벤트를 처리할 때 유용합니다. 예를 들어, 사용자 행동 분석, 이상 탐지, 시계열 집계 등에 활용될 수 있습니다.</p>
<h2>성능 테스트 및 결과 분석</h2>
<p><code>merge()</code>와 <code>join()</code> 연산의 성능을 비교하기 위해 다양한 조건에서 테스트를 수행했습니다.</p>
<h3>테스트 환경</h3>
<ul>
<li><p>메시지 수: 각 토픽당 10,000개</p>
</li>
<li><p>파티션 수: 1, 3, 6개</p>
</li>
<li><p>스레드 수: 1, 3, 6개</p>
</li>
<li><p>키 분포: 균등 분포 vs 치우친 분포</p>
</li>
</ul>
<h3>주요 결과</h3>
<ol>
<li><p><strong>처리량(Throughput)</strong></p>
<ul>
<li><p><code>merge()</code>: 평균 15,000 msgs/sec</p>
</li>
<li><p><code>join()</code>: 평균 7,500 msgs/sec (동일한 키 분포 가정)</p>
</li>
</ul>
</li>
<li><p><strong>지연 시간(Latency)</strong></p>
<ul>
<li><p><code>merge()</code>: 평균 10ms</p>
</li>
<li><p><code>join()</code>: 평균 25ms</p>
</li>
</ul>
</li>
<li><p><strong>메모리 사용량</strong></p>
<ul>
<li><p><code>merge()</code>: 낮음 (스테이트리스)</p>
</li>
<li><p><code>join()</code>: 높음 (스테이트풀)</p>
</li>
</ul>
</li>
<li><p><strong>파티션 확장성</strong></p>
<ul>
<li><p><code>merge()</code>: 파티션 수에 거의 선형적으로 성능 증가</p>
</li>
<li><p><code>join()</code>: 파티션 증가에 따른 성능 향상이 제한적</p>
</li>
</ul>
</li>
</ol>
<h3>분석</h3>
<p>성능 테스트 결과, <code>merge()</code> 연산은 단순히 두 스트림을 결합하는 스테이트리스 연산이기 때문에 더 높은 처리량과 낮은 지연 시간을 보였습니다. 반면 <code>join()</code> 연산은 상태를 유지하고 윈도우 내에서 메시지를 조인해야 하기 때문에 더 많은 리소스를 소비하고 처리 속도가 느렸습니다.</p>
<p>특히 키 분포가 치우친 경우(hot key), <code>join()</code> 연산의 성능이 크게 저하되는 것을 관찰할 수 있었습니다. 이는 특정 파티션에 부하가 집중되기 때문입니다.</p>
<h2>결론</h2>
<p>Kafka Streams의 <code>merge()</code>와 <code>join()</code> 연산은 각각 다른 특성과 사용 사례를 가지고 있습니다.</p>
<h3>merge() 연산 요약</h3>
<ul>
<li><p><strong>특징</strong>: 키나 값에 관계없이 모든 메시지를 하나의 스트림으로 병합</p>
</li>
<li><p><strong>성능</strong>: 높은 처리량, 낮은 지연 시간, 낮은 리소스 사용량</p>
</li>
<li><p><strong>사용 사례</strong>: 단순 로깅, 모니터링, 데이터 수집</p>
</li>
</ul>
<h3>join() 연산 요약</h3>
<ul>
<li><p><strong>특징</strong>: 동일한 키를 가진 메시지만 조인, 시간 윈도우 적용 가능</p>
</li>
<li><p><strong>성능</strong>: 중간 처리량, 중간 지연 시간, 높은 리소스 사용량</p>
</li>
<li><p><strong>사용 사례</strong>: 트랜잭션 처리, 이벤트 상관관계 분석, 데이터 보강</p>
</li>
</ul>
<h3>선택 가이드</h3>
<ol>
<li><p><strong>단순히 여러 소스의 데이터를 결합</strong>하고 싶다면 → <code>merge()</code></p>
</li>
<li><p><strong>관련 이벤트를 키 기반으로 결합</strong>하고 싶다면 → <code>join()</code></p>
</li>
<li><p><strong>처리량이 중요</strong>하다면 → <code>merge()</code></p>
</li>
<li><p><strong>데이터 일관성과 관계가 중요</strong>하다면 → <code>join()</code></p>
</li>
</ol>
<p>이번 블로그에서는 Kafka Streams API의 <code>merge()</code>와 <code>join()</code> 연산의 동작 방식과 성능 특성을 살펴보았습니다. 각 연산의 장단점과 적합한 사용 사례를 이해함으로써, 실시간 데이터 처리 애플리케이션을 더 효과적으로 설계할 수 있기를 바랍니다.</p>
<p>전체 코드와 더 자세한 내용은 <a href="https://github.com/yourusername/kafka-stream-example">GitHub 저장소</a>에서 확인할 수 있습니다.</p>
]]></content:encoded></item><item><title><![CDATA[CreateTopics result(s): CreatableTopic(name='__consumer_offsets', numPartitions=50, replicationFactor=3,]]></title><description><![CDATA[현상

카프카 메세지 발행 후 컨슘이 안됨

consume 로직이 파티션 할당부터 진행되지 않음

카프카 pod 오류 로그가 올라오고 있었음


[2025-05-18 11:17:18,121] INFO [Controller 1] CreateTopics result(s): CreatableTopic(name='__consumer_offsets', numPartitions=50, replicationFactor=3, assignments=[], co...]]></description><link>https://blog.hyunjun.org/createtopics-results-creatabletopicnameconsumeroffsets-numpartitions50-replicationfactor3</link><guid isPermaLink="true">https://blog.hyunjun.org/createtopics-results-creatabletopicnameconsumeroffsets-numpartitions50-replicationfactor3</guid><category><![CDATA[kafka]]></category><dc:creator><![CDATA[조현준]]></dc:creator><pubDate>Sun, 18 May 2025 11:27:43 GMT</pubDate><content:encoded><![CDATA[<h2 id="heading-7zie7iob">현상</h2>
<ul>
<li><p>카프카 메세지 발행 후 컨슘이 안됨</p>
</li>
<li><p>consume 로직이 파티션 할당부터 진행되지 않음</p>
</li>
<li><p>카프카 pod 오류 로그가 올라오고 있었음</p>
</li>
</ul>
<pre><code class="lang-powershell">[<span class="hljs-number">2025</span>-<span class="hljs-number">05</span>-<span class="hljs-number">18</span> <span class="hljs-number">11</span>:<span class="hljs-number">17</span>:<span class="hljs-number">18</span>,<span class="hljs-number">121</span>] INFO [<span class="hljs-type">Controller</span> <span class="hljs-number">1</span>] CreateTopics result(s): CreatableTopic(name=<span class="hljs-string">'__consumer_offsets'</span>, numPartitions=<span class="hljs-number">50</span>, replicationFactor=<span class="hljs-number">3</span>, assignments=[], configs=[<span class="hljs-type">CreateableTopicConfig</span>(<span class="hljs-type">name</span>=<span class="hljs-string">'compression.type'</span>, <span class="hljs-type">value</span>=<span class="hljs-string">'producer'</span>), <span class="hljs-type">CreateableTopicConfig</span>(<span class="hljs-type">name</span>=<span class="hljs-string">'cleanup.policy'</span>, <span class="hljs-type">value</span>=<span class="hljs-string">'compact'</span>), <span class="hljs-type">CreateableTopicConfig</span>(<span class="hljs-type">name</span>=<span class="hljs-string">'segment.bytes'</span>, <span class="hljs-type">value</span>=<span class="hljs-string">'104857600'</span>)]): INVALID_REPLICATION_FACTOR (Unable to replicate the partition <span class="hljs-number">3</span> time(s): The target replication factor of <span class="hljs-number">3</span> cannot be reached because only <span class="hljs-number">1</span> broker(s) are registered.) (org.apache.kafka.controller.ReplicationControlManager)
</code></pre>
<h2 id="heading-7juq7j24">원인</h2>
<p>로컬에서 테스트 중이라서 싱글 노드로 카프카 클러스터를 구성했는데 카프카 내부 토픽 중에서 __consumer_offsets에 대해서느 복제계수를 3으로 설정해서 생성하려고 하니 오류가 발생함</p>
<h2 id="heading-7zw06rkw67cp67kv">해결방법</h2>
<p>카프카 자체의 복제계수를 1로 설정해서 재시작 진행</p>
<p>로컬 카프카는 docker compose 를 이용해서 구성하고 있어서 docker compose에 복제 계수 환경 변수를 추가함</p>
<ul>
<li>docker-compose.yml</li>
</ul>
<pre><code class="lang-yaml"><span class="hljs-attr">version:</span> <span class="hljs-string">'3'</span>
<span class="hljs-attr">services:</span>
  <span class="hljs-attr">kafka:</span>
    <span class="hljs-attr">image:</span> <span class="hljs-string">confluentinc/cp-kafka:7.4.0</span>
    <span class="hljs-attr">container_name:</span> <span class="hljs-string">kafka</span>
    <span class="hljs-attr">ports:</span>
      <span class="hljs-bullet">-</span> <span class="hljs-string">"9092:9092"</span>
    <span class="hljs-attr">environment:</span>
      <span class="hljs-attr">KAFKA_NODE_ID:</span> <span class="hljs-number">1</span>
      <span class="hljs-attr">KAFKA_PROCESS_ROLES:</span> <span class="hljs-string">'broker,controller'</span>
      <span class="hljs-attr">KAFKA_CONTROLLER_QUORUM_VOTERS:</span> <span class="hljs-string">'1@kafka:29093'</span>
      <span class="hljs-attr">KAFKA_LISTENERS:</span> <span class="hljs-string">'PLAINTEXT://kafka:29092,CONTROLLER://kafka:29093,PLAINTEXT_HOST://0.0.0.0:9092'</span>
      <span class="hljs-attr">KAFKA_ADVERTISED_LISTENERS:</span> <span class="hljs-string">'PLAINTEXT://kafka:29092,PLAINTEXT_HOST://localhost:9092'</span>
      <span class="hljs-attr">KAFKA_LISTENER_SECURITY_PROTOCOL_MAP:</span> <span class="hljs-string">'CONTROLLER:PLAINTEXT,PLAINTEXT:PLAINTEXT,PLAINTEXT_HOST:PLAINTEXT'</span>
      <span class="hljs-attr">KAFKA_CONTROLLER_LISTENER_NAMES:</span> <span class="hljs-string">'CONTROLLER'</span>
      <span class="hljs-attr">KAFKA_INTER_BROKER_LISTENER_NAME:</span> <span class="hljs-string">'PLAINTEXT'</span>
      <span class="hljs-attr">CLUSTER_ID:</span> <span class="hljs-string">'MkU3OEVBNTcwNTJENDM2Qk'</span>
      <span class="hljs-attr">KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR:</span> <span class="hljs-number">1</span>
    <span class="hljs-attr">volumes:</span>
      <span class="hljs-bullet">-</span> <span class="hljs-string">./kafka-data:/var/lib/kafka/data</span>
    <span class="hljs-attr">networks:</span>
      <span class="hljs-bullet">-</span> <span class="hljs-string">kafka-net</span>

  <span class="hljs-attr">kafka-ui:</span>
    <span class="hljs-attr">image:</span> <span class="hljs-string">provectuslabs/kafka-ui:latest</span>
    <span class="hljs-attr">container_name:</span> <span class="hljs-string">kafka-ui</span>
    <span class="hljs-attr">depends_on:</span>
      <span class="hljs-bullet">-</span> <span class="hljs-string">kafka</span>
    <span class="hljs-attr">ports:</span>
      <span class="hljs-bullet">-</span> <span class="hljs-string">"8080:8080"</span>
    <span class="hljs-attr">environment:</span>
      <span class="hljs-attr">KAFKA_CLUSTERS_0_NAME:</span> <span class="hljs-string">local-kraft</span>
      <span class="hljs-attr">KAFKA_CLUSTERS_0_BOOTSTRAPSERVERS:</span> <span class="hljs-string">kafka:29092</span>
    <span class="hljs-attr">networks:</span>
      <span class="hljs-bullet">-</span> <span class="hljs-string">kafka-net</span>

<span class="hljs-attr">networks:</span>
  <span class="hljs-attr">kafka-net:</span>
    <span class="hljs-attr">driver:</span> <span class="hljs-string">bridge</span>
</code></pre>
]]></content:encoded></item><item><title><![CDATA[Redis Cluster 세팅 및 Application 구현해보기]]></title><description><![CDATA[1. Redis Cluster란 무엇인가?
Redis Cluster는 Redis의 분산 구현으로, 여러 노드에 데이터를 샤딩(분산 저장)하는 고가용성 솔루션입니다. 일반 Redis와 달리 데이터가 여러 노드에 자동으로 분산되어 저장되며, 이를 통해 다음과 같은 이점을 제공합니다:

데이터 샤딩: 데이터를 여러 노드에 자동으로 분산 저장

고가용성: 일부 노드에 장애가 발생해도 작업 계속 가능

수평적 확장성: 부하 증가 시 노드를 추가하여 처리...]]></description><link>https://blog.hyunjun.org/redis-cluster-application</link><guid isPermaLink="true">https://blog.hyunjun.org/redis-cluster-application</guid><category><![CDATA[Redis]]></category><category><![CDATA[redis cluster]]></category><dc:creator><![CDATA[조현준]]></dc:creator><pubDate>Tue, 06 May 2025 06:51:08 GMT</pubDate><enclosure url="https://cdn.hashnode.com/res/hashnode/image/upload/v1746569965470/a982a291-8ce5-481c-aba9-e7ccd1dfffdf.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<h2 id="heading-1-redis-cluster">1. Redis Cluster란 무엇인가?</h2>
<p>Redis Cluster는 Redis의 분산 구현으로, 여러 노드에 데이터를 샤딩(분산 저장)하는 고가용성 솔루션입니다. 일반 Redis와 달리 데이터가 여러 노드에 자동으로 분산되어 저장되며, 이를 통해 다음과 같은 이점을 제공합니다:</p>
<ul>
<li><p><strong>데이터 샤딩</strong>: 데이터를 여러 노드에 자동으로 분산 저장</p>
</li>
<li><p><strong>고가용성</strong>: 일부 노드에 장애가 발생해도 작업 계속 가능</p>
</li>
<li><p><strong>수평적 확장성</strong>: 부하 증가 시 노드를 추가하여 처리 능력 향상</p>
</li>
</ul>
<p>Redis Cluster는 특히 단일 Redis 인스턴스의 메모리 한계를 초과하는 대규모 데이터셋이나 높은 처리량이 필요한 환경에서 유용합니다.</p>
<h2 id="heading-2-redis-cluster">2. Redis Cluster 구성 과정</h2>
<p>Redis Cluster를 구성하는 일반적인 방법은 Docker Compose를 사용하는 것입니다. 다음은 3개의 마스터 노드와 3개의 슬레이브 노드로 구성된 Redis Cluster를 설정하는 방법입니다.</p>
<h3 id="heading-21-docker-compose">2.1. Docker Compose 구성</h3>
<pre><code class="lang-yaml"><span class="hljs-attr">version:</span> <span class="hljs-string">'3.8'</span>

<span class="hljs-attr">services:</span>
  <span class="hljs-attr">redis-node-1:</span>
    <span class="hljs-attr">image:</span> <span class="hljs-string">redis:latest</span>
    <span class="hljs-attr">container_name:</span> <span class="hljs-string">redis-node-1</span>
    <span class="hljs-attr">ports:</span>
      <span class="hljs-bullet">-</span> <span class="hljs-string">"6379:6379"</span>
    <span class="hljs-attr">volumes:</span>
      <span class="hljs-bullet">-</span> <span class="hljs-string">./redis-node-1.conf:/usr/local/etc/redis/redis.conf</span>
      <span class="hljs-bullet">-</span> <span class="hljs-string">redis-node-1-data:/data</span>
    <span class="hljs-attr">command:</span> <span class="hljs-string">redis-server</span> <span class="hljs-string">/usr/local/etc/redis/redis.conf</span>
    <span class="hljs-attr">networks:</span>
      <span class="hljs-bullet">-</span> <span class="hljs-string">redis-net</span>

  <span class="hljs-attr">redis-node-2:</span>
    <span class="hljs-attr">image:</span> <span class="hljs-string">redis:latest</span>
    <span class="hljs-attr">container_name:</span> <span class="hljs-string">redis-node-2</span>
    <span class="hljs-attr">ports:</span>
      <span class="hljs-bullet">-</span> <span class="hljs-string">"6380:6380"</span>
    <span class="hljs-attr">volumes:</span>
      <span class="hljs-bullet">-</span> <span class="hljs-string">./redis-node-2.conf:/usr/local/etc/redis/redis.conf</span>
      <span class="hljs-bullet">-</span> <span class="hljs-string">redis-node-2-data:/data</span>
    <span class="hljs-attr">command:</span> <span class="hljs-string">redis-server</span> <span class="hljs-string">/usr/local/etc/redis/redis.conf</span>
    <span class="hljs-attr">networks:</span>
      <span class="hljs-bullet">-</span> <span class="hljs-string">redis-net</span>

  <span class="hljs-comment"># 나머지 노드 (3-6) 구성...</span>

  <span class="hljs-attr">redis-cluster-init:</span>
    <span class="hljs-attr">image:</span> <span class="hljs-string">redis:latest</span>
    <span class="hljs-attr">container_name:</span> <span class="hljs-string">redis-cluster-init</span>
    <span class="hljs-attr">command:</span> <span class="hljs-string">&gt;
      bash -c "
        sleep 20 &amp;&amp;
        echo 'Creating Redis Cluster...' &amp;&amp;
        (echo 'yes' | redis-cli -a redisauth --cluster create redis-node-1:6379 redis-node-2:6380 redis-node-3:6381 --cluster-replicas 0) &amp;&amp;
        echo 'Adding slave nodes...' &amp;&amp;
        sleep 5 &amp;&amp;
        redis-cli -a redisauth --cluster add-node redis-node-4:6382 redis-node-1:6379 --cluster-slave &amp;&amp;
        sleep 5 &amp;&amp;
        redis-cli -a redisauth --cluster add-node redis-node-5:6383 redis-node-2:6380 --cluster-slave &amp;&amp;
        sleep 5 &amp;&amp;
        redis-cli -a redisauth --cluster add-node redis-node-6:6384 redis-node-3:6381 --cluster-slave &amp;&amp;
        echo 'Redis Cluster setup completed successfully!'
      "
</span>    <span class="hljs-attr">networks:</span>
      <span class="hljs-bullet">-</span> <span class="hljs-string">redis-net</span>
    <span class="hljs-attr">depends_on:</span>
      <span class="hljs-bullet">-</span> <span class="hljs-string">redis-node-1</span>
      <span class="hljs-bullet">-</span> <span class="hljs-string">redis-node-2</span>
      <span class="hljs-bullet">-</span> <span class="hljs-string">redis-node-3</span>
      <span class="hljs-bullet">-</span> <span class="hljs-string">redis-node-4</span>
      <span class="hljs-bullet">-</span> <span class="hljs-string">redis-node-5</span>
      <span class="hljs-bullet">-</span> <span class="hljs-string">redis-node-6</span>

<span class="hljs-attr">networks:</span>
  <span class="hljs-attr">redis-net:</span>
    <span class="hljs-attr">driver:</span> <span class="hljs-string">bridge</span>

<span class="hljs-attr">volumes:</span>
  <span class="hljs-attr">redis-node-1-data:</span>
  <span class="hljs-attr">redis-node-2-data:</span>
  <span class="hljs-attr">redis-node-3-data:</span>
  <span class="hljs-attr">redis-node-4-data:</span>
  <span class="hljs-attr">redis-node-5-data:</span>
  <span class="hljs-attr">redis-node-6-data:</span>
</code></pre>
<h3 id="heading-22-redis">2.2. Redis 노드 구성 파일</h3>
<p>각 Redis 노드는 자체 설정 파일이 필요합니다. 다음은 마스터 노드의 구성 예시입니다:</p>
<pre><code class="lang-bash">port 6379
dir /data
<span class="hljs-built_in">bind</span> 0.0.0.0
protected-mode no
appendonly yes
requirepass redisauth
masterauth redisauth
cluster-enabled yes
cluster-config-file nodes.conf
cluster-node-timeout 5000
</code></pre>
<p>주요 설정:</p>
<ul>
<li><p><code>cluster-enabled yes</code>: 클러스터 모드 활성화</p>
</li>
<li><p><code>requirepass</code> 및 <code>masterauth</code>: 인증 설정</p>
</li>
<li><p><code>cluster-node-timeout</code>: 노드 장애 감지 시간</p>
</li>
</ul>
<h3 id="heading-23">2.3. 클러스터 초기화</h3>
<p>클러스터 초기화는 <code>redis-cluster-init</code> 서비스에서 이루어집니다. 이 서비스는:</p>
<ol>
<li><p>마스터 노드(1-3)를 사용하여 클러스터 생성</p>
<ol>
<li><p>마스터 1 - 슬레이브 4</p>
</li>
<li><p>마스터 2 - 슬레이브 5</p>
</li>
<li><p>마스터 3 - 슬레이브 6</p>
</li>
</ol>
</li>
<li><p>각 마스터 노드에 슬레이브 노드 연결</p>
</li>
<li><p>클러스터 구성 완료</p>
</li>
</ol>
<h3 id="heading-redis-cluster">Redis Cluster 아키텍처</h3>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1746569450843/9c1117b2-2014-4e75-8d04-7c2ca665a3af.png" alt class="image--center mx-auto" /></p>
<p>지금까지한 redis-cluster를 실행하면 다음과 같은 형태로 시스템이 구성될 것될 것입니다.</p>
<p>3개의 마스터 노드, 3개의 슬레이브 노드를 가지고 있으며 마스터 노드는 각 마스터 노드끼리 슬롯을 3등분하여서 사딩하고 있습니다.</p>
<p>만약 이런 상황에서 1개의 마스터 노드를 추가적으로 할당하면 어떻게 될까요?</p>
<p>그러면 다음과 같은 일련의 과정을 거치게 될 것 입니다.</p>
<ol>
<li><p>새롭게 추가된 마스터 노드에 슬롯 할당</p>
</li>
<li><p>새롭게 할당된 슬롯에 대해서 다른 노드들로 부터 데이터 복제</p>
</li>
<li><p>slave 노드 할당</p>
</li>
</ol>
<p>이 내용에 대해서는 별도로 내용을 다루도록 하겠습니다.</p>
<h2 id="heading-3-redis-cluster">3. Redis Cluster 애플리케이션 구성</h2>
<p>Spring Boot에서 Redis Cluster를 연동하는 방법을 알아보겠습니다.</p>
<h3 id="heading-31-buildgradlekts">3.1. 프로젝트 의존성 설정 (build.gradle.kts)</h3>
<pre><code class="lang-kotlin">plugins {
    kotlin(<span class="hljs-string">"jvm"</span>) version <span class="hljs-string">"1.9.25"</span>
    kotlin(<span class="hljs-string">"plugin.spring"</span>) version <span class="hljs-string">"1.9.25"</span>
    id(<span class="hljs-string">"org.springframework.boot"</span>) version <span class="hljs-string">"3.4.5"</span>
    id(<span class="hljs-string">"io.spring.dependency-management"</span>) version <span class="hljs-string">"1.1.7"</span>
}

dependencies {
    implementation(<span class="hljs-string">"org.springframework.boot:spring-boot-starter-data-redis"</span>)
    implementation(<span class="hljs-string">"org.springframework.boot:spring-boot-starter-web"</span>)
    implementation(<span class="hljs-string">"com.fasterxml.jackson.module:jackson-module-kotlin"</span>)
    implementation(<span class="hljs-string">"org.jetbrains.kotlin:kotlin-reflect"</span>)
    testImplementation(<span class="hljs-string">"org.springframework.boot:spring-boot-starter-test"</span>)
}
</code></pre>
<h3 id="heading-32-applicationyml">3.2. 애플리케이션 설정 (application.yml)</h3>
<pre><code class="lang-yaml"><span class="hljs-attr">spring:</span>
  <span class="hljs-attr">application:</span>
    <span class="hljs-attr">name:</span> <span class="hljs-string">redis-cluster</span>
  <span class="hljs-attr">redis:</span>
    <span class="hljs-attr">cluster:</span>
      <span class="hljs-attr">nodes:</span> <span class="hljs-string">redis-node-1:6379,redis-node-2:6380,redis-node-3:6381,redis-node-4:6382,redis-node-5:6383,redis-node-6:6384</span>
    <span class="hljs-attr">password:</span> <span class="hljs-string">redisauth</span>
    <span class="hljs-attr">timeout:</span> <span class="hljs-number">60000</span>

<span class="hljs-attr">server:</span>
  <span class="hljs-attr">port:</span> <span class="hljs-number">8080</span>
</code></pre>
<h3 id="heading-33-redis-redisconfigkt">3.3. Redis 연결 설정 (RedisConfig.kt)</h3>
<pre><code class="lang-kotlin"><span class="hljs-meta">@Configuration</span>
<span class="hljs-class"><span class="hljs-keyword">class</span> <span class="hljs-title">RedisConfig</span> </span>{

    <span class="hljs-meta">@Value(<span class="hljs-meta-string">"\${spring.redis.cluster.nodes}"</span>)</span>
    <span class="hljs-keyword">private</span> <span class="hljs-keyword">lateinit</span> <span class="hljs-keyword">var</span> clusterNodes: List&lt;String&gt;

    <span class="hljs-meta">@Value(<span class="hljs-meta-string">"\${spring.redis.password}"</span>)</span>
    <span class="hljs-keyword">private</span> <span class="hljs-keyword">lateinit</span> <span class="hljs-keyword">var</span> password: String

    <span class="hljs-meta">@Bean</span>
    <span class="hljs-function"><span class="hljs-keyword">fun</span> <span class="hljs-title">redisConnectionFactory</span><span class="hljs-params">()</span></span>: RedisConnectionFactory {
        <span class="hljs-keyword">val</span> clusterConfig = RedisClusterConfiguration(clusterNodes)
        clusterConfig.setPassword(password)
        <span class="hljs-keyword">return</span> LettuceConnectionFactory(clusterConfig)
    }

    <span class="hljs-meta">@Bean</span>
    <span class="hljs-function"><span class="hljs-keyword">fun</span> <span class="hljs-title">redisTemplate</span><span class="hljs-params">()</span></span>: RedisTemplate&lt;String, Any&gt; {
        <span class="hljs-keyword">val</span> template = RedisTemplate&lt;String, Any&gt;()
        template.connectionFactory = redisConnectionFactory()
        template.keySerializer = StringRedisSerializer()
        template.valueSerializer = StringRedisSerializer()
        <span class="hljs-keyword">return</span> template
    }
}
</code></pre>
<h3 id="heading-34-redis-rediscontrollerkt">3.4. Redis 컨트롤러 (RedisController.kt)</h3>
<pre><code class="lang-kotlin"><span class="hljs-meta">@RestController</span>
<span class="hljs-meta">@RequestMapping(<span class="hljs-meta-string">"/api/redis"</span>)</span>
<span class="hljs-class"><span class="hljs-keyword">class</span> <span class="hljs-title">RedisController</span></span>(<span class="hljs-keyword">private</span> <span class="hljs-keyword">val</span> redisTemplate: RedisTemplate&lt;String, Any&gt;) {

    <span class="hljs-meta">@GetMapping(<span class="hljs-meta-string">"/get/{key}"</span>)</span>
    <span class="hljs-function"><span class="hljs-keyword">fun</span> <span class="hljs-title">getValue</span><span class="hljs-params">(<span class="hljs-meta">@PathVariable</span> key: <span class="hljs-type">String</span>)</span></span>: String? {
        <span class="hljs-keyword">return</span> redisTemplate.opsForValue().<span class="hljs-keyword">get</span>(key)?.toString()
    }

    <span class="hljs-meta">@PostMapping(<span class="hljs-meta-string">"/set"</span>)</span>
    <span class="hljs-function"><span class="hljs-keyword">fun</span> <span class="hljs-title">setValue</span><span class="hljs-params">(<span class="hljs-meta">@RequestParam</span> key: <span class="hljs-type">String</span>, <span class="hljs-meta">@RequestParam</span> value: <span class="hljs-type">String</span>)</span></span>: String {
        redisTemplate.opsForValue().<span class="hljs-keyword">set</span>(key, value)
        <span class="hljs-keyword">return</span> <span class="hljs-string">"Value set successfully"</span>
    }

    <span class="hljs-meta">@GetMapping(<span class="hljs-meta-string">"/test"</span>)</span>
    <span class="hljs-function"><span class="hljs-keyword">fun</span> <span class="hljs-title">testConnection</span><span class="hljs-params">()</span></span>: String {
        <span class="hljs-keyword">return</span> <span class="hljs-keyword">try</span> {
            <span class="hljs-keyword">val</span> testKey = <span class="hljs-string">"test-connection"</span>
            <span class="hljs-keyword">val</span> testValue = <span class="hljs-string">"Connection Successful at <span class="hljs-subst">${java.time.LocalDateTime.now()}</span>"</span>
            redisTemplate.opsForValue().<span class="hljs-keyword">set</span>(testKey, testValue)
            <span class="hljs-string">"Redis Cluster Connection Test: SUCCESS - Value set: <span class="hljs-variable">$testValue</span>"</span>
        } <span class="hljs-keyword">catch</span> (e: Exception) {
            <span class="hljs-string">"Redis Cluster Connection Test: FAILED - <span class="hljs-subst">${e.message}</span>"</span>
        }
    }
}
</code></pre>
<h2 id="heading-4-redis-sentinel-redis-cluster">4. Redis Sentinel과 Redis Cluster의 차이점 분석</h2>
<h3 id="heading-41">4.1. 주요 목적</h3>
<p><strong>Redis Sentinel</strong>:</p>
<ul>
<li><p>주 목적: 고가용성(HA) 제공</p>
</li>
<li><p>데이터 샤딩 없음(모든 데이터는 마스터에 저장)</p>
</li>
<li><p>마스터 노드 장애 시 자동 장애 복구</p>
</li>
</ul>
<p><strong>Redis Cluster</strong>:</p>
<ul>
<li><p>주 목적: 데이터 샤딩 + 고가용성</p>
</li>
<li><p>데이터를 여러 노드에 분산하여 수평적 확장성 제공</p>
</li>
<li><p>노드 장애 시 클러스터 재구성</p>
</li>
</ul>
<h3 id="heading-42">4.2. 아키텍처 비교</h3>
<p><strong>Redis Sentinel</strong>:</p>
<ul>
<li><p>구성: 1 마스터 + N 슬레이브 + 센티널 모니터링 노드</p>
</li>
<li><p>모든 데이터는 마스터에 기록되고 슬레이브에 복제됨</p>
</li>
<li><p>센티널 노드는 마스터 상태를 모니터링하고 장애 발생 시 슬레이브를 마스터로 승격</p>
</li>
</ul>
<p><strong>Redis Cluster</strong>:</p>
<ul>
<li><p>구성: 최소 3개의 마스터 노드 + 각 마스터당 슬레이브 노드</p>
</li>
<li><p>데이터는 여러 마스터 노드에 분산 저장(샤딩)</p>
</li>
<li><p>각 마스터는 자신의 슬레이브와 함께 작동</p>
</li>
</ul>
<h3 id="heading-43">4.3. 사용 사례</h3>
<p><strong>Redis Sentinel</strong>:</p>
<ul>
<li><p>작은 규모의 데이터셋(단일 Redis 인스턴스에 맞는 경우)</p>
</li>
<li><p>고가용성이 주요 관심사인 경우</p>
</li>
<li><p>더 단순한 아키텍처를 선호하는 경우</p>
</li>
</ul>
<p><strong>Redis Cluster</strong>:</p>
<ul>
<li><p>대규모 데이터셋(단일 Redis 인스턴스의 메모리 한계를 초과)</p>
</li>
<li><p>높은 처리량이 필요한 경우</p>
</li>
<li><p>수평적 확장성이 필요한 경우</p>
</li>
</ul>
<h3 id="heading-44">4.4. 구성 복잡성</h3>
<p><strong>Redis Sentinel</strong>:</p>
<ul>
<li><p>더 간단한 설정(상대적으로)</p>
</li>
<li><p>최소 3개의 노드(1 마스터 + 2 슬레이브 + 센티널)</p>
</li>
</ul>
<p><strong>Redis Cluster</strong>:</p>
<ul>
<li><p>더 복잡한 설정 및 관리</p>
</li>
<li><p>최소 6개의 노드(3 마스터 + 3 슬레이브)</p>
</li>
</ul>
<h2 id="heading-5">5. 느낀점</h2>
<p>Redis Cluster와 Spring Boot를 연동하면서 몇 가지 중요한 점을 발견했습니다:</p>
<h3 id="heading-51">5.1. 고가용성과 확장성의 균형</h3>
<p>Redis Cluster는 강력한 확장성과 고가용성을 제공하지만, 그만큼 구성과 관리가 복잡합니다. 특히 작은 규모의 프로젝트에서는 Redis Sentinel이 더 단순하고 효과적일 수 있습니다. 프로젝트의 요구사항과 예상 데이터 볼륨을 고려하여 적절한 솔루션을 선택해야 합니다.</p>
<h3 id="heading-52">5.2. 클러스터 초기화의 중요성</h3>
<p>Redis Cluster 설정에서 클러스터 초기화는 매우 중요한 단계입니다. 노드 간의 올바른 관계 설정, 마스터-슬레이브 연결 등이 정확히 이루어져야 클러스터가 안정적으로 작동합니다. Docker Compose를 사용하면 이 과정을 자동화할 수 있어 편리합니다.</p>
<h3 id="heading-53-spring-data-redis">5.3. Spring Data Redis의 추상화 레이어</h3>
<p>Spring Data Redis는 Redis Cluster 연동을 위한 강력한 추상화 레이어를 제공합니다. <code>RedisClusterConfiguration</code>과 <code>LettuceConnectionFactory</code>를 사용하면 복잡한 클러스터 구성을 간단하게 처리할 수 있습니다. 이는 개발자가 Redis의 복잡한 내부 동작보다 비즈니스 로직에 집중할 수 있게 해줍니다.</p>
<h3 id="heading-54">5.4. 프로덕션 고려사항</h3>
<p>실제 프로덕션 환경에서는 추가적인 고려사항이 필요합니다:</p>
<ul>
<li><p>보안: 강력한 비밀번호 설정, 네트워크 격리</p>
</li>
<li><p>모니터링: 노드 상태, 메모리 사용량, 연결 수 등 모니터링</p>
</li>
<li><p>백업 전략: 데이터 손실 방지를 위한 정기적인 백업</p>
</li>
<li><p>성능 튜닝: 워크로드에 맞는 Redis 설정 최적화</p>
</li>
</ul>
<p>Redis Cluster는 대규모 데이터셋과 높은 처리량이 필요한 시스템에 적합한 강력한 솔루션입니다. Spring Boot와의 통합을 통해 확장 가능하고 고가용성을 갖춘 캐싱 및 데이터 저장 솔루션을 구축할 수 있습니다.</p>
<h2 id="heading-66ei66y066as">마무리</h2>
<p>Redis Cluster와 Redis Sentinel은 각각 다른 목적과 강점을 가진 Redis의 고가용성 솔루션입니다. 프로젝트의 규모, 데이터 볼륨, 확장성 요구사항 등을 고려하여 적절한 솔루션을 선택하는 것이 중요합니다. Spring Boot와의 통합을 통해 이러한 솔루션을 쉽게 구현하고 관리할 수 있어, 견고하고 확장 가능한 애플리케이션을 개발할 수 있습니다.</p>
<h2 id="heading-7lc46rogioyekoujja">참고 자료</h2>
<ul>
<li><p><a target="_blank" href="https://github.com/Tianea2160/spring-redis-example/tree/master/redis-cluster">Redis Cluster Github Code Exmaple</a></p>
</li>
<li><p><a target="_blank" href="https://co-de.tistory.com/24">5분 안에 구축하는 Redis-Cluster</a></p>
</li>
</ul>
]]></content:encoded></item><item><title><![CDATA[Redis서버가 장애가 나도 사용자가 아무것도 모르도록 만들어보자]]></title><description><![CDATA[Redis란?
Redis(Remote Dictionary Server)는 인메모리 데이터 구조 저장소로, 다양한 데이터 구조(문자열, 해시, 리스트, 셋, 정렬된 셋 등)를 지원하는 NoSQL 데이터베이스입니다. 주요 특징으로는:

인메모리 작동 방식으로 매우 빠른 읽기/쓰기 성능 제공

데이터 영속성 지원 (RDB 스냅샷, AOF 로그)

단일 스레드 아키텍처로 원자적 작업 보장

복제, 클러스터, 센티널 등의 고가용성 기능 제공

키-값 저...]]></description><link>https://blog.hyunjun.org/redis</link><guid isPermaLink="true">https://blog.hyunjun.org/redis</guid><category><![CDATA[Redis]]></category><category><![CDATA[sentinel]]></category><dc:creator><![CDATA[조현준]]></dc:creator><pubDate>Sun, 04 May 2025 08:07:05 GMT</pubDate><content:encoded><![CDATA[<h2 id="heading-redis">Redis란?</h2>
<p>Redis(Remote Dictionary Server)는 인메모리 데이터 구조 저장소로, 다양한 데이터 구조(문자열, 해시, 리스트, 셋, 정렬된 셋 등)를 지원하는 NoSQL 데이터베이스입니다. 주요 특징으로는:</p>
<ul>
<li><p>인메모리 작동 방식으로 매우 빠른 읽기/쓰기 성능 제공</p>
</li>
<li><p>데이터 영속성 지원 (RDB 스냅샷, AOF 로그)</p>
</li>
<li><p>단일 스레드 아키텍처로 원자적 작업 보장</p>
</li>
<li><p>복제, 클러스터, 센티널 등의 고가용성 기능 제공</p>
</li>
<li><p>키-값 저장소지만, 다양한 데이터 타입을 지원해 활용도가 높음</p>
</li>
</ul>
<p>Redis는 캐싱, 세션 저장소, 메시지 브로커, 실시간 분석 등 다양한 용도로 사용됩니다.</p>
<h2 id="heading-redis-1">Redis가 장애가 났을 때 장애 복구를 위해서 할 수 있는 대응 방법</h2>
<p>Redis 장애 상황에서 복구를 위한 여러 방법이 있습니다:</p>
<ol>
<li><p><strong>복제(Replication)</strong>: 마스터-슬레이브 구조로 데이터를 여러 서버에 복제하여 마스터 서버 장애 시 슬레이브가 데이터 제공</p>
</li>
<li><p><strong>Redis Sentinel</strong>: 자동 장애 감지 및 페일오버를 통해 마스터 장애 시 슬레이브를 새 마스터로 승격</p>
</li>
<li><p><strong>Redis Cluster</strong>: 여러 노드에 데이터를 분산 저장하고 자동 장애 복구 제공</p>
</li>
<li><p><strong>데이터 지속성 설정</strong>: RDB(Redis Database Backup) 스냅샷이나 AOF(Append Only File) 로그를 통해 데이터 복구 가능</p>
</li>
<li><p><strong>백업 및 복원</strong>: 정기적인 백업을 통해 장애 발생 시 데이터 복원</p>
</li>
</ol>
<p>이 중에서 Redis Sentinel은 고가용성을 위한 가장 효과적인 솔루션 중 하나입니다.</p>
<h2 id="heading-7is87yuw64sq7j20656aioustoyxhyduoqwgd8">센티널이란 무엇인가?</h2>
<p>Redis Sentinel은 Redis의 고가용성 솔루션으로, 다음과 같은 기능을 제공합니다:</p>
<ol>
<li><p><strong>모니터링</strong>: 마스터와 슬레이브 노드가 정상적으로 작동하는지 지속적으로 감시합니다.</p>
</li>
<li><p><strong>자동 장애 감지</strong>: 마스터 노드에 문제가 발생하면 이를 감지합니다. 여러 센티널이 쿼럼을 통해 마스터 장애를 합의합니다.</p>
</li>
<li><p><strong>자동 페일오버</strong>: 마스터 노드에 장애가 발생하면 적합한 슬레이브를 선택해 새로운 마스터로 승격시키고, 다른 슬레이브들이 새 마스터를 바라보도록 재구성합니다.</p>
</li>
<li><p><strong>클라이언트 통지</strong>: 클라이언트에게 현재 마스터의 주소를 알려주어 연결 관리를 도와줍니다.</p>
</li>
<li><p><strong>구성 제공자</strong>: 클라이언트가 현재 Redis 토폴로지를 조회할 수 있는 서비스 역할을 합니다.</p>
</li>
</ol>
<p>센티널은 일반적으로 최소 3개 이상의 인스턴스로 구성하여 과반수 투표를 통한 안정적인 장애 감지를 보장합니다.</p>
<h3 id="heading-7is87yuw64sq6ro8io2btoufroykpo2esouklcdrrltsl4fsnbqg64uk66w46rcapw">센티널과 클러스터는 무엇이 다른가?</h3>
<p>클러스터는 여러 노드에 데이터를 분산해서 수평적인 확장을 할 수 있도록 하는 것이 목적입니다.</p>
<p>클러스터 내장으로 자체 자동 장애 감지가 있는 경우가 있습니다.</p>
<p>그래서 클러스터의 경우 각 마스터 노드가 대체로 전속 슬레이브 노드를 가지고 있습니다.</p>
<p>센티널의 경우 복제 환경을 기본전제로 두고 장애 상황에서 Master-Slave 간의 전환을 통해서 장애 대응을 하는 전략을 의미합니다.</p>
<p>따라서 기본적으로 Master-Slave 가 최소 1개 이상 존재해야하며 일반적으로 센티널 노드까지 일반적으로 최소 3개 이상 구성되어 있어야 합니다.</p>
<h2 id="heading-7is87yuw64sqioyepoygle2vmoqzocdsp4hsojeg7j6l7jwgiouctouztoq4sa">센티널 설정하고 직접 장애 내보기</h2>
<p>센티널을 설정하고 장애 테스트를 하는 과정은 다음과 같습니다:</p>
<ol>
<li><p><strong>Docker Compose 파일 구성</strong>:</p>
<ul>
<li><p>Redis 마스터 노드 1개</p>
</li>
<li><p>Redis 슬레이브 노드 2개</p>
</li>
<li><p>Sentinel 노드 2개</p>
</li>
<li><p>각각 고정 IP 주소 할당</p>
</li>
</ul>
</li>
<li><p><strong>센티널 설정 파일 구성</strong>:</p>
<pre><code class="lang-kotlin"> port <span class="hljs-number">26379</span>
 dir <span class="hljs-string">"/tmp"</span>
 sentinel monitor mymaster <span class="hljs-number">172.18</span><span class="hljs-number">.0</span><span class="hljs-number">.2</span> <span class="hljs-number">6379</span> <span class="hljs-number">2</span>
 sentinel down-after-milliseconds mymaster <span class="hljs-number">5000</span>
 sentinel failover-timeout mymaster <span class="hljs-number">60000</span>
 sentinel auth-pass mymaster redispassword
 sentinel known-replica mymaster <span class="hljs-number">172.18</span><span class="hljs-number">.0</span><span class="hljs-number">.5</span> <span class="hljs-number">6379</span>
 sentinel known-replica mymaster <span class="hljs-number">172.18</span><span class="hljs-number">.0</span><span class="hljs-number">.6</span> <span class="hljs-number">6379</span>
 requirepass <span class="hljs-string">"redispassword"</span>
</code></pre>
</li>
<li><p><strong>장애 테스트 시나리오</strong>:</p>
<ul>
<li><p>마스터 노드(172.18.0.2)를 강제 종료</p>
</li>
<li><p>센티널이 장애를 감지하고 슬레이브 중 하나를 마스터로 승격</p>
</li>
<li><p>새 마스터는 포트 번호 변경 없이 역할만 변경됨</p>
</li>
<li><p>다른 슬레이브는 새 마스터를 바라보도록 재구성</p>
</li>
<li><p>애플리케이션은 센티널을 통해 새 마스터 정보를 얻어 접속 계속</p>
</li>
</ul>
</li>
<li><p><strong>실제 테스트 확인</strong>:</p>
<ul>
<li><p><code>docker-compose stop redis-master</code> 명령으로 마스터 노드 중단</p>
</li>
<li><p><code>redis-cli -h 172.18.0.3 -p 26379 -a redispassword sentinel masters</code> 명령으로 새 마스터 확인</p>
</li>
<li><p>센티널 로그를 통해 페일오버 과정 확인</p>
</li>
</ul>
</li>
</ol>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1746344289567/5ca0349e-ab6b-4a44-a5bb-45cd93c8c55c.png" alt class="image--center mx-auto" /></p>
<h3 id="heading-6re466a87j2eio2gte2vtoyencdrs7tripqg7j6l7jwgioylnoucmoumroyypa">그림을 통해서 보는 장애 시나리오</h3>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1746345981769/d202c1e6-7944-47c1-b511-9706af08c0d7.png" alt class="image--center mx-auto" /></p>
<h3 id="heading-7is87ysw64sqioylpoygncdrozzqt7gg67ae7isd">센터널 실제 로그 분석</h3>
<p><strong>센티널 노드의 전체 로그</strong></p>
<pre><code class="lang-bash">1:X 04 May 2025 07:28:15.355 <span class="hljs-comment"># +sdown master mymaster 172.18.0.2 6379</span>
1:X 04 May 2025 07:28:15.447 <span class="hljs-comment"># +odown master mymaster 172.18.0.2 6379 #quorum 2/2</span>
1:X 04 May 2025 07:28:15.447 <span class="hljs-comment"># +new-epoch 1</span>
1:X 04 May 2025 07:28:15.447 <span class="hljs-comment"># +try-failover master mymaster 172.18.0.2 6379</span>
1:X 04 May 2025 07:28:15.455 <span class="hljs-comment"># Could not rename tmp config file (Device or resource busy)</span>
1:X 04 May 2025 07:28:15.455 <span class="hljs-comment"># WARNING: Sentinel was not able to save the new configuration on disk!!!: Device or resource busy</span>
1:X 04 May 2025 07:28:15.455 <span class="hljs-comment"># +vote-for-leader abadec7630202c8c8c151e5ac8e9945f13c6d066 1</span>
1:X 04 May 2025 07:28:15.462 * 6c10b56b55c5190b3b87376b575263fa1a871725 voted <span class="hljs-keyword">for</span> abadec7630202c8c8c151e5ac8e9945f13c6d066 1
1:X 04 May 2025 07:28:15.508 <span class="hljs-comment"># +elected-leader master mymaster 172.18.0.2 6379</span>
1:X 04 May 2025 07:28:15.508 <span class="hljs-comment"># +failover-state-select-slave master mymaster 172.18.0.2 6379</span>
1:X 04 May 2025 07:28:15.565 <span class="hljs-comment"># +selected-slave slave 172.18.0.5:6379 172.18.0.5 6379 @ mymaster 172.18.0.2 6379</span>
1:X 04 May 2025 07:28:15.565 * +failover-state-send-slaveof-noone slave 172.18.0.5:6379 172.18.0.5 6379 @ mymaster 172.18.0.2 6379
1:X 04 May 2025 07:28:15.656 * +failover-state-wait-promotion slave 172.18.0.5:6379 172.18.0.5 6379 @ mymaster 172.18.0.2 6379
1:X 04 May 2025 07:28:16.461 <span class="hljs-comment"># Could not rename tmp config file (Device or resource busy)</span>
1:X 04 May 2025 07:28:16.462 <span class="hljs-comment"># WARNING: Sentinel was not able to save the new configuration on disk!!!: Device or resource busy</span>
1:X 04 May 2025 07:28:16.462 <span class="hljs-comment"># +promoted-slave slave 172.18.0.5:6379 172.18.0.5 6379 @ mymaster 172.18.0.2 6379</span>
1:X 04 May 2025 07:28:16.462 <span class="hljs-comment"># +failover-state-reconf-slaves master mymaster 172.18.0.2 6379</span>
1:X 04 May 2025 07:28:16.559 * +slave-reconf-sent slave 172.18.0.6:6379 172.18.0.6 6379 @ mymaster 172.18.0.2 6379
1:X 04 May 2025 07:28:17.544 * +slave-reconf-inprog slave 172.18.0.6:6379 172.18.0.6 6379 @ mymaster 172.18.0.2 6379
1:X 04 May 2025 07:28:17.544 * +slave-reconf-done slave 172.18.0.6:6379 172.18.0.6 6379 @ mymaster 172.18.0.2 6379
1:X 04 May 2025 07:28:17.611 <span class="hljs-comment"># +failover-end master mymaster 172.18.0.2 6379</span>
1:X 04 May 2025 07:28:17.611 <span class="hljs-comment"># +switch-master mymaster 172.18.0.2 6379 172.18.0.5 6379</span>
1:X 04 May 2025 07:28:17.611 * +slave slave 172.18.0.6:6379 172.18.0.6 6379 @ mymaster 172.18.0.5 6379
1:X 04 May 2025 07:28:17.611 * +slave slave 172.18.0.2:6379 172.18.0.2 6379 @ mymaster 172.18.0.5 6379
1:X 04 May 2025 07:28:17.615 <span class="hljs-comment"># Could not rename tmp config file (Device or resource busy)</span>
1:X 04 May 2025 07:28:17.615 <span class="hljs-comment"># WARNING: Sentinel was not able to save the new configuration on disk!!!: Device or resource busy</span>
1:X 04 May 2025 07:28:22.676 <span class="hljs-comment"># +sdown slave 172.18.0.2:6379 172.18.0.2 6379 @ mymaster 172.18.0.5 6379</span>
1:X 04 May 2025 07:29:31.905 <span class="hljs-comment"># -sdown slave 172.18.0.2:6379 172.18.0.2 6379 @ mymaster 172.18.0.5 6379</span>
1:X 04 May 2025 07:29:41.827 * +convert-to-slave slave 172.18.0.2:6379 172.18.0.2 6379 @ mymaster 172.18.0.5 6379
</code></pre>
<ol>
<li>마스터 노드가 모종의 이유(그것은 나)로 죽었고 fail-over 전략을 시도하는 것을 확인할 수 있습니다</li>
</ol>
<pre><code class="lang-bash">1:X 04 May 2025 07:28:15.355 <span class="hljs-comment"># +sdown master mymaster 172.18.0.2 6379</span>
1:X 04 May 2025 07:28:15.447 <span class="hljs-comment"># +odown master mymaster 172.18.0.2 6379 #quorum 2/2</span>
1:X 04 May 2025 07:28:15.447 <span class="hljs-comment"># +new-epoch 1</span>
1:X 04 May 2025 07:28:15.447 <span class="hljs-comment"># +try-failover master mymaster 172.18.0.2 6379</span>
</code></pre>
<ol start="2">
<li>슬레이브 노드 중 하나가 센티널에 의해서 마스터 노드로 승격될 대상으로 결정된 것을 볼 수 있습니다</li>
</ol>
<pre><code class="lang-bash">1:X 04 May 2025 07:28:15.508 <span class="hljs-comment"># +elected-leader master mymaster 172.18.0.2 6379</span>
1:X 04 May 2025 07:28:15.508 <span class="hljs-comment"># +failover-state-select-slave master mymaster 172.18.0.2 6379</span>
1:X 04 May 2025 07:28:15.565 <span class="hljs-comment"># +selected-slave slave 172.18.0.5:6379 172.18.0.5 6379 @ mymaster 172.18.0.2 6379</span>
</code></pre>
<ol start="3">
<li>다시 살아난 마스터노드는 이후에 슬레이브 노드로 좌천되는 것도 볼 수 있습니다</li>
</ol>
<pre><code class="lang-bash">1:X 04 May 2025 07:29:41.827 * +convert-to-slave slave 172.18.0.2:6379 172.18.0.2 6379 @ mymaster 172.18.0.5 6379
</code></pre>
<h2 id="heading-6rkw66ggioyaloyvvq">결론 요약</h2>
<ul>
<li><p>Redis는 메모리 기반의 Key-Value 저장소이다</p>
</li>
<li><p>Redis 장애 대응을 위한 준비로 크게 아래의 것들을 할 수 있습니다</p>
<ul>
<li><p>Cluster Setting</p>
</li>
<li><p>Redis Sentinel</p>
</li>
<li><p>Snapshot Backup</p>
</li>
</ul>
</li>
<li><p>Redis Sentinel은 최소 1개의 Master, Slave 노드가 존재해야하만 사용할 수 있고 Application 단에서도 장애 상황에 변경되는 마스터 노드를 확인하기 위한 대비가 application 단에서 되어 있어야합니다.</p>
</li>
<li><p>Redis Sentinel는 특정 마스터 노드가 문제가 발생하였을 때 센티널들끼리 투표를 통해서 슬레이브 노드를 마스터 노드로 승격시키고 기존 마스터 노드가 회복되면 슬레이브 노드로 강등시킵니다.</p>
</li>
</ul>
<h2 id="heading-64qq64ka7kcq">느낀점</h2>
<p>Redis Sentinel을 통한 고가용성 구성에서 배운 중요한 점들:</p>
<ol>
<li><p><strong>무중단 서비스의 중요성</strong>: Redis와 같은 핵심 인프라 서비스는 장애 시에도 서비스 중단 없이 계속 작동해야 함을 실감했습니다.</p>
</li>
<li><p><strong>자동화된 장애 복구</strong>: 센티널을 통해 사람의 개입 없이 자동으로 마스터 노드 장애를 감지하고 복구하는 메커니즘의 효율성을 경험했습니다.</p>
</li>
<li><p><strong>애플리케이션 설계 고려사항</strong>: 고가용성 인프라를 활용하기 위해서는 애플리케이션도 적절히 설계되어야 함을 알게 되었습니다. 특히 Sentinel 클라이언트 라이브러리를 사용하여 마스터 노드 변경에 대응하는 방법이 중요합니다.</p>
</li>
<li><p><strong>포트 번호 유지와 역할 변경</strong>: 페일오버 시 IP와 포트는 그대로 유지되고 노드의 역할만 변경된다는 것이 매우 직관적이고 효과적인 설계임을 알게 되었습니다.</p>
</li>
<li><p><strong>분산 시스템 이해 증진</strong>: 마스터-슬레이브 아키텍처와 센티널을 통한 고가용성 구성을 통해 분산 시스템에 대한 이해가 깊어졌습니다.</p>
</li>
</ol>
<p>이러한 경험을 통해 프로덕션 환경에서 안정적인 서비스를 제공하기 위한 인프라 구성의 중요성과 그 구현 방법에 대해 실질적인 지식을 얻게 되었습니다.</p>
<h2 id="heading-7lc46rogioyekoujja">참고 자료</h2>
<ul>
<li><p><a target="_blank" href="https://github.com/Tianea2160/spring-redis-example">실습 코드 Github REPO</a></p>
</li>
<li><p><a target="_blank" href="https://coding-review.tistory.com/533">Redis Sentinel 도커 배포하기</a></p>
</li>
</ul>
]]></content:encoded></item><item><title><![CDATA[[LeetCode] 236. Lowest Common Ancestor of a Binary Tree]]></title><description><![CDATA[Link : 236. Lowest Common Ancestor of a Binary Tree
문제 설명

Given a binary tree, find the lowest common ancestor (LCA) of two given nodes in the tree.
According to the definition of LCA on Wikipedia: “The lowest common ancestor is defined between two ...]]></description><link>https://blog.hyunjun.org/leetcode-236-lowest-common-ancestor-of-a-binary-tree</link><guid isPermaLink="true">https://blog.hyunjun.org/leetcode-236-lowest-common-ancestor-of-a-binary-tree</guid><category><![CDATA[leetcode]]></category><category><![CDATA[Binary Search Algorithm]]></category><dc:creator><![CDATA[조현준]]></dc:creator><pubDate>Sun, 04 May 2025 05:03:18 GMT</pubDate><enclosure url="https://cdn.hashnode.com/res/hashnode/image/upload/v1746334976319/f7545268-753c-4582-927c-04fb0ffd63dd.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>Link : <a target="_blank" href="https://leetcode.com/problems/lowest-common-ancestor-of-a-binary-tree/description/?envType=study-plan-v2&amp;envId=leetcode-75">236. Lowest Common Ancestor of a Binary Tree</a></p>
<h2 id="heading-66y47kccioyepouqhq">문제 설명</h2>
<blockquote>
<p>Given a binary tree, find the lowest common ancestor (LCA) of two given nodes in the tree.</p>
<p>According to the <a target="_blank" href="https://en.wikipedia.org/wiki/Lowest_common_ancestor">definition of LCA on Wikipedia</a>: “The lowest common ancestor is defined between two nodes <code>p</code> and <code>q</code> as the lowest node in <code>T</code> that has both <code>p</code> and <code>q</code> as descendants (where we allow <strong>a node to be a descendant of itself</strong>).”</p>
<p><strong>Example 1:</strong></p>
<p><img src="https://assets.leetcode.com/uploads/2018/12/14/binarytree.png" alt /></p>
<pre><code class="lang-json">Input: root = [<span class="hljs-number">3</span>,<span class="hljs-number">5</span>,<span class="hljs-number">1</span>,<span class="hljs-number">6</span>,<span class="hljs-number">2</span>,<span class="hljs-number">0</span>,<span class="hljs-number">8</span>,<span class="hljs-literal">null</span>,<span class="hljs-literal">null</span>,<span class="hljs-number">7</span>,<span class="hljs-number">4</span>], p = <span class="hljs-number">5</span>, q = <span class="hljs-number">1</span>
Output: <span class="hljs-number">3</span>
Explanation: The LCA of nodes <span class="hljs-number">5</span> and <span class="hljs-number">1</span> is <span class="hljs-number">3.</span>
</code></pre>
<p><strong>Example 2:</strong></p>
<p><img src="https://assets.leetcode.com/uploads/2018/12/14/binarytree.png" alt /></p>
<pre><code class="lang-json">Input: root = [<span class="hljs-number">3</span>,<span class="hljs-number">5</span>,<span class="hljs-number">1</span>,<span class="hljs-number">6</span>,<span class="hljs-number">2</span>,<span class="hljs-number">0</span>,<span class="hljs-number">8</span>,<span class="hljs-literal">null</span>,<span class="hljs-literal">null</span>,<span class="hljs-number">7</span>,<span class="hljs-number">4</span>], p = <span class="hljs-number">5</span>, q = <span class="hljs-number">4</span>
Output: <span class="hljs-number">5</span>
Explanation: The LCA of nodes <span class="hljs-number">5</span> and <span class="hljs-number">4</span> is <span class="hljs-number">5</span>, since a node can be a descendant of itself according to the LCA definition.
</code></pre>
<p><strong>Example 3:</strong></p>
<pre><code class="lang-json">Input: root = [<span class="hljs-number">1</span>,<span class="hljs-number">2</span>], p = <span class="hljs-number">1</span>, q = <span class="hljs-number">2</span>
Output: <span class="hljs-number">1</span>
</code></pre>
<p><strong>Constraints:</strong></p>
<ul>
<li><p>The number of nodes in the tree is in the range <code>[2, 10&lt;sup&gt;5&lt;/sup&gt;]</code>.</p>
</li>
<li><p><code>-10&lt;sup&gt;9&lt;/sup&gt; &lt;= Node.val &lt;= 10&lt;sup&gt;9&lt;/sup&gt;</code></p>
</li>
<li><p>All <code>Node.val</code> are <strong>unique</strong>.</p>
</li>
<li><p><code>p != q</code></p>
</li>
<li><p><code>p</code> and <code>q</code> will exist in the tree.</p>
</li>
</ul>
</blockquote>
<h2 id="heading-66y47kcciou2hoyenq">문제 분석</h2>
<p>글의 내용이 많기는 하지만 말하고자 하는 요점은 아래와 같습니다.</p>
<blockquote>
<p>“2개의 노드가 만날 수 있는 가장 가까운 상위노드는 어디인가?”</p>
</blockquote>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1746334653664/be46f8a8-855c-407c-8454-15066aa9618a.png" alt class="image--center mx-auto" /></p>
<p>위 그림과 같이 2개의 노드를 타고서 위로 올라갈때 만날 수 있는 가장 가까운 노드를 찾으면 되는 것이죠</p>
<h2 id="heading-7zw06rkwiouwqeyvia">해결 방안</h2>
<p>그래서 저는 다음과 같이 문제를 해결하려고 했습니다.</p>
<ol>
<li><p>p, q node에 대해서 root 에서 부터 path 배열을 각각 구한다</p>
</li>
<li><p>2개의 path 정보 중에서 곂치는 노드 중 가장 낮은 위치에 있는 노드를 찾는다</p>
</li>
</ol>
<p>path 배열을 구하기 위해서 재귀를 이용해서 문제를 풀었습니다.</p>
<h3 id="heading-7zw064u1ioy9loutna">해답 코드</h3>
<pre><code class="lang-kotlin"><span class="hljs-class"><span class="hljs-keyword">class</span> <span class="hljs-title">Solution</span> </span>{
    <span class="hljs-function"><span class="hljs-keyword">fun</span> <span class="hljs-title">lowestCommonAncestor</span><span class="hljs-params">(root: <span class="hljs-type">TreeNode</span>?, p: <span class="hljs-type">TreeNode</span>?, q: <span class="hljs-type">TreeNode</span>?)</span></span>: TreeNode? {
        <span class="hljs-keyword">val</span> pathToP = ArrayList&lt;TreeNode&gt;()
        <span class="hljs-keyword">val</span> pathToQ = ArrayList&lt;TreeNode&gt;()

        findPath(root, p, pathToP)
        findPath(root, q, pathToQ)

        <span class="hljs-keyword">var</span> lca: TreeNode? = <span class="hljs-literal">null</span>

        <span class="hljs-keyword">var</span> i = <span class="hljs-number">0</span>
        <span class="hljs-keyword">while</span> (i &lt; pathToP.size &amp;&amp; i &lt; pathToQ.size &amp;&amp; pathToP[i] === pathToQ[i]) {
            lca = pathToP[i]
            i++
        }

        <span class="hljs-keyword">return</span> lca
    }

    <span class="hljs-keyword">private</span> <span class="hljs-function"><span class="hljs-keyword">fun</span> <span class="hljs-title">findPath</span><span class="hljs-params">(root: <span class="hljs-type">TreeNode</span>?, target: <span class="hljs-type">TreeNode</span>?, path: <span class="hljs-type">ArrayList</span>&lt;<span class="hljs-type">TreeNode</span>&gt;)</span></span>: <span class="hljs-built_in">Boolean</span> {
        <span class="hljs-keyword">if</span> (root == <span class="hljs-literal">null</span>) <span class="hljs-keyword">return</span> <span class="hljs-literal">false</span>

        path.add(root)

        <span class="hljs-keyword">if</span> (root === target) <span class="hljs-keyword">return</span> <span class="hljs-literal">true</span>

        <span class="hljs-keyword">if</span> (findPath(root.left, target, path) || findPath(root.right, target, path)) {
            <span class="hljs-keyword">return</span> <span class="hljs-literal">true</span>
        }

        path.removeAt(path.size - <span class="hljs-number">1</span>)
        <span class="hljs-keyword">return</span> <span class="hljs-literal">false</span>
    }
}

<span class="hljs-class"><span class="hljs-keyword">class</span> <span class="hljs-title">TreeNode</span></span>(<span class="hljs-keyword">var</span> `<span class="hljs-keyword">val</span>`: <span class="hljs-built_in">Int</span> = <span class="hljs-number">0</span>) {
    <span class="hljs-keyword">var</span> left: TreeNode? = <span class="hljs-literal">null</span>
    <span class="hljs-keyword">var</span> right: TreeNode? = <span class="hljs-literal">null</span>
}
</code></pre>
<h2 id="heading-64qq64ka7kcq">느낀점</h2>
<p>그래프 문제여서 처음부터 막상 겁을 먹었던 것 같습니다. 하지만 문제를 풀때에 큰 틀에서 우선 접근하고 그다음에 구현에 대해서 신경쓰니깐 그다지 어렵지 않았던 것 같습니다. 특히 AI를 사용해서도 문제를 풀어보고 있는데 정답을 바로 달라고 하는 것이 아니라 먼저 큰 틀을 생각해서 가장 이상적인 순서도를 제시하고 문제를 푸니 문제 해결 속도와 디버깅이 빨라서 생산성이 높아진것도 느낄 수 있었습니다.</p>
]]></content:encoded></item><item><title><![CDATA[Elasticsearch를 사용해서 추천 결과를 저장하고 쉽게 볼 수 있도록 만들어보자]]></title><description><![CDATA[프로젝트의 특성상 3차원 빈 패킹 알고리즘을 통해 생성되는 결과에는 많은 복잡한 데이터들이 포함되어 있습니다. 각 아이템의 위치, 회전 정보, 선택된 박스, 그리고 무엇보다 중요한 제약 조건 위반 점수 등의 상세한 정보들을 효과적으로 저장하고 조회해야 했습니다.
왜 Elasticsearch를 선택했는가?
1. 복잡한 점수 구조의 저장 및 분석
프로젝트에서는 OptaPlanner의 BendableScore를 사용하여 하드/소프트 제약 조건을 구분...]]></description><link>https://blog.hyunjun.org/elasticsearch</link><guid isPermaLink="true">https://blog.hyunjun.org/elasticsearch</guid><category><![CDATA[elasticsearch]]></category><category><![CDATA[kibana]]></category><dc:creator><![CDATA[조현준]]></dc:creator><pubDate>Sat, 03 May 2025 14:48:44 GMT</pubDate><content:encoded><![CDATA[<p>프로젝트의 특성상 3차원 빈 패킹 알고리즘을 통해 생성되는 결과에는 많은 복잡한 데이터들이 포함되어 있습니다. 각 아이템의 위치, 회전 정보, 선택된 박스, 그리고 무엇보다 중요한 제약 조건 위반 점수 등의 상세한 정보들을 효과적으로 저장하고 조회해야 했습니다.</p>
<h2 id="heading-elasticsearch">왜 Elasticsearch를 선택했는가?</h2>
<h3 id="heading-1">1. 복잡한 점수 구조의 저장 및 분석</h3>
<p>프로젝트에서는 OptaPlanner의 BendableScore를 사용하여 하드/소프트 제약 조건을 구분하고 있습니다. 각 제약 조건별로 상세한 점수가 기록되는데, 이러한 중첩된 데이터 구조를 Elasticsearch의 Nested 타입으로 효과적으로 저장할 수 있습니다.</p>
<pre><code class="lang-kotlin"><span class="hljs-keyword">data</span> <span class="hljs-class"><span class="hljs-keyword">class</span> <span class="hljs-title">ScoreCoordinate</span></span>(
    <span class="hljs-keyword">val</span> level: <span class="hljs-built_in">Int</span>,
    <span class="hljs-keyword">val</span> isHard: <span class="hljs-built_in">Boolean</span>,
    <span class="hljs-keyword">val</span> score: <span class="hljs-built_in">Int</span>,
    <span class="hljs-keyword">val</span> constraintName: String,
    <span class="hljs-keyword">val</span> constraintDescription: String
)
</code></pre>
<h3 id="heading-2">2. 시계열 데이터의 효율적 관리</h3>
<p>빈 패킹 작업의 히스토리를 날짜별로 관리하고, 최적화 성능의 추이를 분석해야 했습니다. Elasticsearch는 날짜 기반 인덱싱과 Index Lifecycle Management를 통해 이러한 요구사항을 효과적으로 처리할 수 있습니다.</p>
<h3 id="heading-3">3. 실시간 분석과 시각화</h3>
<p>Grafana 대시보드와 통합하여 빈 패킹 성능 지표를 실시간으로 모니터링할 수 있고, 다양한 제약 조건별 점수 분포를 시각화할 수 있습니다.</p>
<h2 id="heading-elasticsearch-1">Elasticsearch 적용 과정</h2>
<h3 id="heading-1-1">1. 프로젝트 의존성 설정</h3>
<p>Spring Boot 3.4.4와 호환되는 Elasticsearch 의존성을 추가하고, 설정 클래스를 작성합니다.</p>
<p>ES, Kibana는 docker compose를 이용해서 컨테이너를 띄웠습니다</p>
<p><strong>YAML</strong></p>
<pre><code class="lang-yaml"><span class="hljs-attr">services:</span>
  <span class="hljs-attr">elasticsearch:</span>
    <span class="hljs-attr">image:</span> <span class="hljs-string">docker.elastic.co/elasticsearch/elasticsearch:8.11.3</span>
    <span class="hljs-attr">environment:</span>
      <span class="hljs-bullet">-</span> <span class="hljs-string">discovery.type=single-node</span>
      <span class="hljs-bullet">-</span> <span class="hljs-string">xpack.security.enabled=false</span>
    <span class="hljs-attr">ports:</span>
      <span class="hljs-bullet">-</span> <span class="hljs-string">"9200:9200"</span>

  <span class="hljs-attr">kibana:</span>
    <span class="hljs-attr">image:</span> <span class="hljs-string">docker.elastic.co/kibana/kibana:8.11.3</span>
    <span class="hljs-attr">ports:</span>
      <span class="hljs-bullet">-</span> <span class="hljs-string">"5601:5601"</span>
</code></pre>
<p><strong>Kotlin</strong></p>
<pre><code class="lang-kotlin">
dependencies {
    implementation(<span class="hljs-string">"org.springframework.boot:spring-boot-starter-data-elasticsearch"</span>)
}
</code></pre>
<p>application.yml에서 프로필별 Elasticsearch 연결 설정을 구성합니다:</p>
<pre><code class="lang-yaml"><span class="hljs-attr">spring:</span>
  <span class="hljs-attr">elasticsearch:</span>
    <span class="hljs-attr">uris:</span> <span class="hljs-string">${ELASTICSEARCH_URI:http://localhost:9200}</span>
    <span class="hljs-attr">username:</span> <span class="hljs-string">${ELASTICSEARCH_USERNAME:elastic}</span>
    <span class="hljs-attr">password:</span> <span class="hljs-string">${ELASTICSEARCH_PASSWORD:changeme}</span>
</code></pre>
<h3 id="heading-2-document">2. Document 객체 생성</h3>
<p>빈 패킹 결과와 점수 상세 정보를 저장할 Document 클래스를 생성합니다:</p>
<pre><code class="lang-kotlin"><span class="hljs-meta">@Document(indexName = <span class="hljs-meta-string">"bin-pack-recommend-result"</span>)</span>
<span class="hljs-keyword">data</span> <span class="hljs-class"><span class="hljs-keyword">class</span> <span class="hljs-title">BinPackRecommendResult</span></span>(
    <span class="hljs-meta">@Id</span> <span class="hljs-keyword">val</span> id: String? = <span class="hljs-literal">null</span>,
    <span class="hljs-meta">@Field(type = FieldType.Long)</span> <span class="hljs-keyword">val</span> solutionId: <span class="hljs-built_in">Long</span>,
    <span class="hljs-meta">@Field(type = FieldType.Nested)</span> <span class="hljs-keyword">val</span> scoreCoordinates: List&lt;ScoreCoordinate&gt;,
    <span class="hljs-meta">@Field(type = FieldType.Date)</span> <span class="hljs-keyword">val</span> createdAt: OffsetDateTime,
    <span class="hljs-meta">@Field(type = FieldType.Nested)</span> <span class="hljs-keyword">val</span> assignments: List&lt;AssignmentDetail&gt;
)
</code></pre>
<h3 id="heading-3-1">3. 날짜별 인덱스 생성 구현</h3>
<p>매일 새로운 인덱스가 생성되도록 커스텀 리포지토리를 구현합니다:</p>
<pre><code class="lang-kotlin"><span class="hljs-keyword">object</span> IndexNameGenerator {
    <span class="hljs-keyword">private</span> <span class="hljs-keyword">const</span> <span class="hljs-keyword">val</span> BASE_INDEX_NAME = <span class="hljs-string">"bin-pack-recommend-result"</span>
    <span class="hljs-keyword">private</span> <span class="hljs-keyword">val</span> DATE_FORMATTER = DateTimeFormatter.ofPattern(<span class="hljs-string">"yyyy-MM-dd"</span>)

    <span class="hljs-function"><span class="hljs-keyword">fun</span> <span class="hljs-title">generateIndexName</span><span class="hljs-params">(date: <span class="hljs-type">LocalDate</span> = LocalDate.now()</span></span>): String {
        <span class="hljs-keyword">return</span> <span class="hljs-string">"<span class="hljs-subst">${BASE_INDEX_NAME}</span>-<span class="hljs-subst">${date.format(DATE_FORMATTER)}</span>"</span>
    }
}

<span class="hljs-class"><span class="hljs-keyword">class</span> <span class="hljs-title">BinPackRecommendResultCustomRepositoryImpl</span></span>(
    <span class="hljs-keyword">private</span> <span class="hljs-keyword">val</span> operations: ElasticsearchOperations
) : BinPackRecommendResultCustomRepository {

    <span class="hljs-keyword">override</span> <span class="hljs-function"><span class="hljs-keyword">fun</span> <span class="hljs-title">saveWithDateIndex</span><span class="hljs-params">(document: <span class="hljs-type">BinPackRecommendResult</span>)</span></span> {
        <span class="hljs-keyword">val</span> indexName = IndexNameGenerator.generateIndexName()
        <span class="hljs-keyword">val</span> query = IndexQueryBuilder()
            .withId(document.id.toString())
            .withObject(document)
            .build()

        operations.index(query, IndexCoordinates.of(indexName))
    }
}
</code></pre>
<h3 id="heading-4-ilm-ttl">4. ILM 설정을 통한 인덱스 TTL 설정</h3>
<p>인덱스가 자동으로 순환되도록 설정합니다. 이 프로젝트에서는 Elasticsearch 설정파일을 통해 구현할 예정이며, 현재는 날짜별 인덱스 구조를 먼저 구축했습니다. ILM 설정은 다음과 같이 적용할 계획입니다:</p>
<pre><code class="lang-json">{
  <span class="hljs-attr">"policy"</span>: {
    <span class="hljs-attr">"phases"</span>: {
      <span class="hljs-attr">"hot"</span>: {
        <span class="hljs-attr">"actions"</span>: {
          <span class="hljs-attr">"rollover"</span>: {
            <span class="hljs-attr">"max_age"</span>: <span class="hljs-string">"30d"</span>,
            <span class="hljs-attr">"max_size"</span>: <span class="hljs-string">"50GB"</span>
          }
        }
      },
      <span class="hljs-attr">"delete"</span>: {
        <span class="hljs-attr">"min_age"</span>: <span class="hljs-string">"90d"</span>,
        <span class="hljs-attr">"actions"</span>: {
          <span class="hljs-attr">"delete"</span>: {}
        }
      }
    }
  }
}
</code></pre>
<h3 id="heading-5">5. 적용 결과 확인</h3>
<p>이벤트 기반 아키텍처를 통해 PostgreSQL에 먼저 저장된 후, 비동기로 Elasticsearch에도 저장합니다:</p>
<pre><code class="lang-kotlin"><span class="hljs-meta">@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)</span>
<span class="hljs-function"><span class="hljs-keyword">fun</span> <span class="hljs-title">handleRecommendResultSaved</span><span class="hljs-params">(event: <span class="hljs-type">RecommendResultSavedEvent</span>)</span></span> {
    <span class="hljs-keyword">try</span> {
        <span class="hljs-keyword">val</span> documentToSave = BinPackRecommendResult.from(
            result = event.recommendResult,
            skus = skuDocuments,
            scoreDescription = event.scoreDescription,
            scoreCoordinates = event.scoreCoordinates,
            assignments = event.assignments
        )

        binPackRecommendResultRepository.saveWithDateIndex(documentToSave)
        logger.info(<span class="hljs-string">"Successfully saved recommend result <span class="hljs-subst">${event.recommendResult.id}</span> to Elasticsearch"</span>)
    } <span class="hljs-keyword">catch</span> (e: Exception) {
        logger.error(<span class="hljs-string">"Failed to save recommend result <span class="hljs-subst">${event.recommendResult.id}</span> to Elasticsearch"</span>, e)
    }
}
</code></pre>
<p>도커 환경에서 실행하면 다음과 같이 Elasticsearch와 Kibana가 함께 실행되어 데이터를 확인할 수 있습니다:</p>
<h4 id="heading-kibana">Kibana에서 추천 결과 확인</h4>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1746283631309/e8989b3d-b126-4e58-b7be-1c0e2524492b.png" alt /></p>
<h2 id="heading-64qq64ka7kcq">느낀점</h2>
<ol>
<li><p><strong>Elasticsearch의 유연성</strong>: JSON 형태의 복잡한 중첩 데이터 구조를 자연스럽게 저장하고 쿼리할 수 있어 OptaPlanner의 상세한 점수 정보를 효과적으로 관리할 수 있었습니다.</p>
</li>
<li><p><strong>이벤트 기반 아키텍처의 장점</strong>: Spring의 <code>@TransactionalEventListener</code>를 활용하여 PostgreSQL 저장이 성공한 후에 Elasticsearch에 저장하는 구조로, 데이터 일관성을 보장하면서도 성능 저하를 최소화할 수 있었습니다.</p>
</li>
<li><p><strong>인덱스 관리의 중요성</strong>: 날짜별 인덱스 분리를 통해 오래된 데이터의 효율적인 삭제와 검색 성능 향상을 동시에 달성할 수 있었습니다. 아직 ILM 설정은 구현 예정이지만, 장기적인 데이터 관리 전략이 중요함을 깨달았습니다.</p>
</li>
</ol>
<p>이 과정을 통해 Elasticsearch가 단순한 검색 엔진이 아닌, 복잡한 분석 데이터의 저장소로서도 매우 효과적임을 확인할 수 있었습니다. 특히 최적화 알고리즘의 결과 분석에 필요한 다차원 데이터를 효과적으로 다룰 수 있는 강력한 도구임을 실감했습니다.</p>
]]></content:encoded></item><item><title><![CDATA[유전 알고리즘을 활용한 주문 최적화 시스템 구현하기]]></title><description><![CDATA[안녕하세요! 오늘은 제가 직접 구현한 유전 알고리즘(Genetic Algorithm)을 통해 주문 최적화 문제를 해결하는 과정을 공유하려 합니다. 이 글에서는 코틀린(Kotlin)으로 작성된 유전 알고리즘의 구현 방법과 그 응용에 대해 알아보겠습니다.
유전 알고리즘이란?
유전 알고리즘은 자연 선택과 유전학의 원리에서 영감을 받은 최적화 알고리즘입니다. 복잡한 문제에 대한 해결책을 찾기 위해 유전적 진화의 과정을 시뮬레이션합니다. 간단히 말해, ...]]></description><link>https://blog.hyunjun.org/generation-algorithm</link><guid isPermaLink="true">https://blog.hyunjun.org/generation-algorithm</guid><category><![CDATA[generation algorithm]]></category><category><![CDATA[algorithms]]></category><dc:creator><![CDATA[조현준]]></dc:creator><pubDate>Mon, 28 Apr 2025 15:24:23 GMT</pubDate><enclosure url="https://cdn.hashnode.com/res/hashnode/image/upload/v1746335549261/a2866880-b9de-42c4-b309-182107aec286.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>안녕하세요! 오늘은 제가 직접 구현한 유전 알고리즘(Genetic Algorithm)을 통해 주문 최적화 문제를 해결하는 과정을 공유하려 합니다. 이 글에서는 코틀린(Kotlin)으로 작성된 유전 알고리즘의 구현 방법과 그 응용에 대해 알아보겠습니다.</p>
<h2 id="heading-7jyg7kceioyvjoqzooumroymmoydtouegd8">유전 알고리즘이란?</h2>
<p>유전 알고리즘은 자연 선택과 유전학의 원리에서 영감을 받은 최적화 알고리즘입니다. 복잡한 문제에 대한 해결책을 찾기 위해 유전적 진화의 과정을 시뮬레이션합니다. 간단히 말해, 여러 해결책(개체)들이 세대를 거듭하며 더 좋은 해결책으로 진화해가는 과정입니다.</p>
<h2 id="heading-66y47kccioygleydmdog7ko866y4ioy1noygge2zla">문제 정의: 주문 최적화</h2>
<p>이번 프로젝트에서는 주문(Order)과 상품(SKU)으로 구성된 시스템에서, <strong>최소한의 다양한 상품</strong>을 포함하는 주문 조합을 찾는 문제를 해결하고자 했습니다. 적합도(fitness)는 주문 그룹에 포함된 고유 상품 ID의 개수로 정의했으며, <strong>이 값이 낮을수록 더 좋은 해결책</strong>입니다. 즉, 더 적은 종류의 SKU로 주문을 처리하는 것이 목표입니다.</p>
<h2 id="heading-7l2u65ocioq1ro2yha">코드 구현</h2>
<h3 id="heading-1">1. 주요 클래스 정의</h3>
<p>먼저 필요한 데이터 모델을 정의했습니다:</p>
<pre><code class="lang-kotlin"><span class="hljs-class"><span class="hljs-keyword">class</span> <span class="hljs-title">Order</span></span>(
    <span class="hljs-keyword">val</span> orderId: String = uuid(),
    <span class="hljs-keyword">val</span> skus: MutableList&lt;OrderSku&gt;,
)

<span class="hljs-class"><span class="hljs-keyword">class</span> <span class="hljs-title">OrderSku</span></span>(
    <span class="hljs-keyword">val</span> skuId: String,
    <span class="hljs-keyword">val</span> quantity: <span class="hljs-built_in">Long</span>,
)

<span class="hljs-class"><span class="hljs-keyword">class</span> <span class="hljs-title">Individual</span></span>(
    <span class="hljs-keyword">val</span> orders: MutableList&lt;Order&gt;,
) {
    <span class="hljs-keyword">val</span> fitness: <span class="hljs-built_in">Long</span> = calculateFitness()

    <span class="hljs-function"><span class="hljs-keyword">fun</span> <span class="hljs-title">calculateFitness</span><span class="hljs-params">()</span></span>: <span class="hljs-built_in">Long</span> = orders
        .flatMap { it.skus }
        .map { it.skuId }
        .toSet().size.toLong()
}
</code></pre>
<p>여기서 <code>Individual</code>은 유전 알고리즘의 개체를 나타내며, <code>Order</code> 리스트를 포함합니다. 각 <code>Order</code>는 <code>OrderSku</code> 리스트로 구성됩니다. 개체의 적합도는 모든 주문에 포함된 고유 상품 ID의 개수이며, <strong>이 값이 낮을수록 더 좋은 해결책</strong>입니다.</p>
<h3 id="heading-2">2. 유전 알고리즘 구현</h3>
<p>순서도는 아래와 같습니다.</p>
<ol>
<li><p>시작</p>
</li>
<li><p>초기 개체군 생성</p>
</li>
<li><p>신규 세대 개체군 생성</p>
</li>
<li><p>앨리트 개체 필터링</p>
</li>
<li><p>교배 확률에 확인</p>
<ol>
<li><p>교배 확률이 높은 경우, 교배</p>
</li>
<li><p>교배 확률이 낮은 경우, 돌연변이</p>
</li>
</ol>
</li>
<li><p>최대 세대가 확인 및 2번 반복</p>
</li>
<li><p>종료</p>
</li>
</ol>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1745882958191/a73b6463-f13d-4b51-9934-753b8c5e048e.png" alt class="image--center mx-auto" /></p>
<p>알고리즘의 핵심은 <code>GenerationAlgorithm</code> 클래스에 구현했습니다:</p>
<pre><code class="lang-kotlin"><span class="hljs-class"><span class="hljs-keyword">class</span> <span class="hljs-title">GenerationAlgorithm</span></span>(
    <span class="hljs-keyword">private</span> <span class="hljs-keyword">val</span> config: Config = Config(),
) {
    <span class="hljs-keyword">data</span> <span class="hljs-class"><span class="hljs-keyword">class</span> <span class="hljs-title">Config</span></span>(
        <span class="hljs-keyword">val</span> maxGenerationCount: <span class="hljs-built_in">Int</span> = <span class="hljs-number">100</span>,
        <span class="hljs-keyword">val</span> eliteRatio: <span class="hljs-built_in">Double</span> = <span class="hljs-number">0.3</span>,
        <span class="hljs-keyword">val</span> crossRatio: <span class="hljs-built_in">Double</span> = <span class="hljs-number">0.5</span>,
        <span class="hljs-keyword">val</span> initSkuKindCount: <span class="hljs-built_in">Int</span> = <span class="hljs-number">1000</span>,
        <span class="hljs-keyword">val</span> initOrderCount: <span class="hljs-built_in">Int</span> = <span class="hljs-number">1000</span>,
    )

    <span class="hljs-function"><span class="hljs-keyword">fun</span> <span class="hljs-title">cross</span><span class="hljs-params">(i1: <span class="hljs-type">Individual</span>, i2: <span class="hljs-type">Individual</span>)</span></span>: MutableList&lt;Individual&gt; {
        <span class="hljs-keyword">val</span> mixedOrders = (i1.orders + i2.orders).toMutableList()
        mixedOrders.shuffle()
        <span class="hljs-keyword">return</span> mutableListOf(
            Individual(mixedOrders.take(mixedOrders.size / <span class="hljs-number">2</span>).toMutableList()),
            Individual(mixedOrders.takeLast(mixedOrders.size / <span class="hljs-number">2</span>).toMutableList()),
        )
    }

    <span class="hljs-function"><span class="hljs-keyword">fun</span> <span class="hljs-title">mutation</span><span class="hljs-params">(i1: <span class="hljs-type">Individual</span>, i2: <span class="hljs-type">Individual</span>)</span></span>: MutableList&lt;Individual&gt; {
        <span class="hljs-keyword">val</span> u = i1.orders.random()
        <span class="hljs-keyword">val</span> v = i2.orders.random()

        i1.orders.remove(u)
        i2.orders.remove(v)

        i1.orders.add(v)
        i2.orders.add(u)

        <span class="hljs-keyword">return</span> mutableListOf(i1, i2)
    }

    <span class="hljs-function"><span class="hljs-keyword">fun</span> <span class="hljs-title">calculate</span><span class="hljs-params">()</span></span> {
        <span class="hljs-comment">// 초기 데이터 설정 및 알고리즘 실행</span>
        <span class="hljs-comment">// ...</span>
    }
}
</code></pre>
<p>이 구현에는 다음과 같은 주요 매개변수가 있습니다:</p>
<ul>
<li><p><code>maxGenerationCount</code>: 최대 세대 수 (100)</p>
</li>
<li><p><code>eliteRatio</code>: 엘리트 비율 (30%)</p>
</li>
<li><p><code>crossRatio</code>: 교차 연산 확률 (50%)</p>
</li>
<li><p><code>initSkuKindCount</code>: 초기 SKU 종류 수 (1000)</p>
</li>
<li><p><code>initOrderCount</code>: 초기 주문 수 (1000)</p>
</li>
</ul>
<h3 id="heading-3">3. 유전 연산자: 교차와 돌연변이</h3>
<p>이번 구현에서는 두 가지 주요 유전 연산자를 사용했습니다:</p>
<h4 id="heading-crossover">교차(Crossover) 연산</h4>
<pre><code class="lang-kotlin"><span class="hljs-function"><span class="hljs-keyword">fun</span> <span class="hljs-title">cross</span><span class="hljs-params">(i1: <span class="hljs-type">Individual</span>, i2: <span class="hljs-type">Individual</span>)</span></span>: MutableList&lt;Individual&gt; {
    <span class="hljs-keyword">val</span> mixedOrders = (i1.orders + i2.orders).toMutableList()
    mixedOrders.shuffle()
    <span class="hljs-keyword">return</span> mutableListOf(
        Individual(mixedOrders.take(mixedOrders.size / <span class="hljs-number">2</span>).toMutableList()),
        Individual(mixedOrders.takeLast(mixedOrders.size / <span class="hljs-number">2</span>).toMutableList()),
    )
}
</code></pre>
<p>두 부모 개체의 주문들을 합치고 무작위로 섞은 후, 절반씩 나누어 두 개의 자식 개체를 생성합니다.</p>
<h4 id="heading-mutation">돌연변이(Mutation) 연산</h4>
<pre><code class="lang-kotlin"><span class="hljs-function"><span class="hljs-keyword">fun</span> <span class="hljs-title">mutation</span><span class="hljs-params">(i1: <span class="hljs-type">Individual</span>, i2: <span class="hljs-type">Individual</span>)</span></span>: MutableList&lt;Individual&gt; {
    <span class="hljs-keyword">val</span> u = i1.orders.random()
    <span class="hljs-keyword">val</span> v = i2.orders.random()

    i1.orders.remove(u)
    i2.orders.remove(v)

    i1.orders.add(v)
    i2.orders.add(u)

    <span class="hljs-keyword">return</span> mutableListOf(i1, i2)
}
</code></pre>
<p>두 개체 사이에서 무작위로 선택된 주문을 교환합니다. 각 개체에서 하나의 주문을 제거한 후, 서로의 주문을 추가합니다. 이 방식은 지역 최적해에서 벗어나 더 넓은 해 공간을 탐색할 수 있게 해줍니다.</p>
<h3 id="heading-4">4. 알고리즘 동작 과정</h3>
<p><code>calculate()</code> 메서드에서 알고리즘의 주요 로직이 실행됩니다:</p>
<ol>
<li><p><strong>초기 데이터 생성</strong>:</p>
<ul>
<li><p>지정된 수의 SKU ID를 생성합니다.</p>
</li>
<li><p>각 주문은 1~5개의 랜덤한 SKU를 포함하도록 합니다.</p>
</li>
<li><p>주문들을 10개씩 묶어 초기 개체군을 생성합니다.</p>
</li>
</ul>
</li>
<li><p><strong>진화 과정</strong>:</p>
<ul>
<li><p>최대 100세대까지 반복합니다.</p>
</li>
<li><p>각 세대마다:</p>
<ul>
<li><p>적합도에 따라 개체군을 정렬합니다. <strong>적합도가 낮은(고유 SKU 개수가 적은) 개체가 더 우수합니다.</strong></p>
</li>
<li><p>상위 30%(엘리트)는 직접 다음 세대로 전달합니다.</p>
</li>
<li><p>나머지 70%는 교차 또는 돌연변이 연산을 통해 새로운 개체를 생성합니다.</p>
</li>
<li><p>교차와 돌연변이 중 어떤 연산을 적용할지는 <code>crossRatio</code> 매개변수에 따라 결정됩니다(기본값 50%).</p>
</li>
<li><p>각 세대의 최고 적합도를 출력합니다.</p>
</li>
</ul>
</li>
</ul>
</li>
</ol>
<h2 id="heading-6rkw6ro8iou2hoyenq">결과 분석</h2>
<p>알고리즘을 실행하면 세대가 지남에 따라 적합도(고유 SKU ID의 개수)가 점차 감소하는 것을 확인할 수 있습니다. 이는 더 적은 종류의 SKU로 주문을 처리할 수 있게 되었다는 것을 의미합니다. 다음은 각 세대별 최고 적합도의 변화를 보여주는 출력 예시입니다:</p>
<pre><code class="lang-kotlin">Maximum individual: <span class="hljs-number">42</span>
[Generation <span class="hljs-number">0</span>]  Maximum individual: <span class="hljs-number">39</span>
[Generation <span class="hljs-number">1</span>]  Maximum individual: <span class="hljs-number">36</span>
[Generation <span class="hljs-number">2</span>]  Maximum individual: <span class="hljs-number">32</span>
...
[Generation <span class="hljs-number">99</span>]  Maximum individual: <span class="hljs-number">15</span>
</code></pre>
<h2 id="heading-7j20ioy1noygge2zloydmcdsi6tsojwg7j2y664">이 최적화의 실제 의미</h2>
<p>이러한 최적화가 실제로 의미하는 바는 무엇일까요? 실제 비즈니스 환경에서 적은 종류의 SKU로 주문을 처리하면 다음과 같은 이점이 있습니다:</p>
<ol>
<li><p><strong>재고 관리 단순화</strong>: 더 적은 종류의 SKU를 관리하므로 재고 관리가 쉬워집니다.</p>
</li>
<li><p><strong>보관 비용 절감</strong>: 다양한 SKU를 보관하는 데 필요한 공간과 비용을 줄일 수 있습니다.</p>
</li>
<li><p><strong>주문 처리 효율성 증가</strong>: 적은 종류의 SKU를 처리하므로 피킹, 패킹 과정이 단순해집니다.</p>
</li>
<li><p><strong>공급망 최적화</strong>: 더 적은 공급업체와 거래할 수 있어 공급망 관리가 쉬워집니다.</p>
</li>
</ol>
<h2 id="heading-64m7jew67oa7j20ioyxsoycsoydmcdspjhsmptshle">돌연변이 연산의 중요성</h2>
<p>이번 구현에서는 교차 연산과 함께 <strong>돌연변이 연산</strong>을 추가했습니다. 이는 매우 중요한 개선 사항입니다. 돌연변이 연산은 다음과 같은 이점을 제공합니다:</p>
<ol>
<li><p><strong>지역 최적해 탈출</strong>: 돌연변이는 현재 해 공간에서 벗어나 새로운 영역을 탐색할 수 있게 해줍니다.</p>
</li>
<li><p><strong>다양성 유지</strong>: 개체군의 다양성을 유지하여 조기 수렴을 방지합니다.</p>
</li>
<li><p><strong>더 넓은 탐색</strong>: 교차 연산만으로는 발견하기 어려운 해결책을 발견할 수 있습니다.</p>
</li>
</ol>
<p>우리의 구현에서는 두 개체 간에 주문을 교환하는 방식의 돌연변이를 사용했습니다. 이는 단순하지만 효과적인 방법으로, 각 세대마다 50%의 확률로 적용됩니다.</p>
<h2 id="heading-6rcc7isgioqwgoukpeyesq">개선 가능성</h2>
<p>현재 구현에도 여전히 다음과 같은 개선 가능성이 있습니다:</p>
<ol>
<li><p><strong>다양한 돌연변이 전략</strong>:</p>
<ul>
<li><p>현재는 주문 교환만 구현되어 있지만, SKU 수준의 돌연변이도 고려해볼 수 있습니다.</p>
</li>
<li><p>예를 들어, 주문 내의 특정 SKU를 다른 SKU로 대체하는 방식 등이 있습니다.</p>
</li>
</ul>
</li>
<li><p><strong>적합도 함수 개선</strong>:</p>
<ul>
<li><p>현재는 단순히 고유 SKU 개수만 고려합니다.</p>
</li>
<li><p>각 SKU별 재고 비용, 처리 시간 등 추가 요소들도 고려하도록 확장할 수 있습니다.</p>
</li>
</ul>
</li>
<li><p><strong>선택 방법 다양화</strong>:</p>
<ul>
<li>루울렛 휠 선택, 토너먼트 선택 등 다른 선택 방법을 시도해볼 수 있습니다.</li>
</ul>
</li>
<li><p><strong>매개변수 튜닝</strong>:</p>
<ul>
<li>엘리트 비율, 교차 확률 등의 매개변수를 다양하게 시도하여 최적의 조합을 찾을 수 있습니다.</li>
</ul>
</li>
</ol>
<h2 id="heading-6rkw66gg">결론</h2>
<p>이 프로젝트를 통해 유전 알고리즘을 활용하여 주문 최적화 문제를 해결하는 방법에 대해 알아보았습니다. 코틀린의 간결한 문법과 함수형 프로그래밍 기능을 활용하여 복잡한 알고리즘을 비교적 쉽게 구현할 수 있었습니다.</p>
<p>특히 교차 연산과 돌연변이 연산을 적절히 조합하여 더 효과적인 탐색을 가능하게 했습니다. 이 두 연산자는 유전 알고리즘의 핵심 요소로, 함께 사용할 때 더 강력한 결과를 얻을 수 있습니다.</p>
<p>Github : <a target="_blank" href="https://github.com/Tianea2160/generation-problem">유전 알고리즘 구현 코드</a></p>
<h2 id="heading-7lc46rogioyekoujja">참고 자료</h2>
<ul>
<li><p><a target="_blank" href="https://helloworld.kurly.com/blog/logistics-optimization-1/">마켓 컬리 : 컬리는 물류 최적화 문제를 어떻게 풀고 있을까? - 1부</a></p>
</li>
<li><p><a target="_blank" href="https://untitledtblog.tistory.com/110">[수리적 최적화] 유전 알고리즘 (Genetic Algorithm)과 전역 최적화</a></p>
</li>
</ul>
]]></content:encoded></item><item><title><![CDATA[[LeetCode] 11. Container With Most Water]]></title><description><![CDATA[Link : https://leetcode.com/problems/container-with-most-water/description/
문제 정의

You are given an integer array height of length n. There are n vertical lines drawn such that the two endpoints of the i<sup>th</sup> line are (i, 0) and (i, height[i]...]]></description><link>https://blog.hyunjun.org/leetcode-11-container-with-most-water</link><guid isPermaLink="true">https://blog.hyunjun.org/leetcode-11-container-with-most-water</guid><category><![CDATA[algorithms]]></category><category><![CDATA[2 pointer]]></category><category><![CDATA[leetcode]]></category><dc:creator><![CDATA[조현준]]></dc:creator><pubDate>Wed, 23 Apr 2025 05:48:00 GMT</pubDate><content:encoded><![CDATA[<p>Link : <a target="_blank" href="https://leetcode.com/problems/container-with-most-water/description/">https://leetcode.com/problems/container-with-most-water/description/</a></p>
<h2 id="heading-66y47kccioygleydma">문제 정의</h2>
<blockquote>
<p>You are given an integer array <code>height</code> of length <code>n</code>. There are <code>n</code> vertical lines drawn such that the two endpoints of the <code>i&lt;sup&gt;th&lt;/sup&gt;</code> line are <code>(i, 0)</code> and <code>(i, height[i])</code>.</p>
<p>Find two lines that together with the x-axis form a container, such that the container contains the most water.</p>
<p>Return <em>the maximum amount of water a container can store</em>.</p>
<p><strong>Notice</strong> that you may not slant the container.</p>
<p><strong>Example 1:</strong></p>
<p><img src="https://s3-lc-upload.s3.amazonaws.com/uploads/2018/07/17/question_11.jpg" alt /></p>
<pre><code class="lang-kotlin">Input: height = [<span class="hljs-number">1</span>,<span class="hljs-number">8</span>,<span class="hljs-number">6</span>,<span class="hljs-number">2</span>,<span class="hljs-number">5</span>,<span class="hljs-number">4</span>,<span class="hljs-number">8</span>,<span class="hljs-number">3</span>,<span class="hljs-number">7</span>]
Output: <span class="hljs-number">49</span>
Explanation: The above vertical lines are represented <span class="hljs-keyword">by</span> array [<span class="hljs-number">1</span>,<span class="hljs-number">8</span>,<span class="hljs-number">6</span>,<span class="hljs-number">2</span>,<span class="hljs-number">5</span>,<span class="hljs-number">4</span>,<span class="hljs-number">8</span>,<span class="hljs-number">3</span>,<span class="hljs-number">7</span>]. In <span class="hljs-keyword">this</span> case, the max area of water (blue section) the container can contain <span class="hljs-keyword">is</span> <span class="hljs-number">49</span>.
</code></pre>
<p><strong>Example 2:</strong></p>
<pre><code class="lang-kotlin">Input: height = [<span class="hljs-number">1</span>,<span class="hljs-number">1</span>]
Output: <span class="hljs-number">1</span>
</code></pre>
<p><strong>Constraints:</strong></p>
<ul>
<li><p><code>n == height.length</code></p>
</li>
<li><p><code>2 &lt;= n &lt;= 10&lt;sup&gt;5&lt;/sup&gt;</code></p>
</li>
<li><p><code>0 &lt;= height[i] &lt;= 10&lt;sup&gt;4&lt;/sup&gt;</code></p>
</li>
</ul>
</blockquote>
<h2 id="heading-66y47kcciou2hoyenq">문제 분석</h2>
<p>서로 다른 기둥이 있을 때 가장 많이 물을 담을 수 있는 범위를 구하는 문제입니다.</p>
<p>결국 서로 다른 기둥을 찾을 때에 최소 탐색으로 찾을 수 있도록 선택을 하는 것이 필요합니다.</p>
<p>이 문제는 직관적으로 ‘서로 다른 2개의 높이‘라는 부분에서 2 pointer 문제라는 것을 알 수 있기 때문에 그렇게 문제를 풀었습니다.</p>
<p>의사 코드는 다음과 같습니다</p>
<ol>
<li><p>left, right, answer 선언</p>
</li>
<li><p>left &lt; right 이면 반복</p>
<ol>
<li><p>height[left] &lt; height[right]이면, left+1</p>
</li>
<li><p>height[left] &gt;= height[right]이면, right+1</p>
</li>
</ol>
</li>
</ol>
<p>의사 코드의 핵심은 높이가 낮은 쪽을 스킵하면서 진행된다는 것입니다.</p>
<p>왜냐하면 아무리 너비가 길어도 높이가 낮으면 최대 값이 되지 못하기 때문입니다.</p>
<h2 id="heading-7zw06rkwiouwqeyvia">해결 방안</h2>
<pre><code class="lang-kotlin"><span class="hljs-keyword">import</span> kotlin.math.min
<span class="hljs-keyword">import</span> kotlin.math.max

<span class="hljs-class"><span class="hljs-keyword">class</span> <span class="hljs-title">Solution</span> </span>{
    <span class="hljs-function"><span class="hljs-keyword">fun</span> <span class="hljs-title">maxArea</span><span class="hljs-params">(height: <span class="hljs-type">IntArray</span>)</span></span>: <span class="hljs-built_in">Int</span> {
        <span class="hljs-keyword">var</span> left = <span class="hljs-number">0</span>
        <span class="hljs-keyword">var</span> right = height.lastIndex
        <span class="hljs-keyword">var</span> maxArea = <span class="hljs-number">0</span>

        <span class="hljs-keyword">while</span> (left &lt; right) {
            <span class="hljs-keyword">val</span> w = right - left
            <span class="hljs-keyword">val</span> h = min(height[right], height[left])
            <span class="hljs-keyword">val</span> area = w * h

            maxArea = max(maxArea, area)

            <span class="hljs-keyword">if</span> (height[left] &lt; height[right]) {
                left++
            } <span class="hljs-keyword">else</span> {
                right--
            }
        }

        <span class="hljs-keyword">return</span> maxArea
    }
}
</code></pre>
<h2 id="heading-64qq64ka7kcq">느낀점</h2>
<p>전형적인 2 pointer 문제이지만 아무 간단한 높이가 낮은 쪽부터 스킵한다는 생각을 못해서 시간을 많이 사용한 문제였던 것 같습니다.</p>
]]></content:encoded></item><item><title><![CDATA[아무것도 모르는 상황에서 ArgoCD로 쿠버네티스 여정 시작하기]]></title><description><![CDATA[모험의 시작
지금부터 아무것도 모르는 상황에서 ArgoCD를 세팅해보도록 하겠습니다. 이번 글은 AI를 사용해서 아무것도 모르는 상황에서 과연 어디까지 인프라를 세팅할 수 있을지 테스트해보는 시간입니다.
우선 제가 미리 만들어둔 API 서버가 존재하고 이걸 다음과 같이 구성할 것입니다. 저의 목표는 잘 만드는 것이 아니라 돌아가도록 구성하는 것이 목표이기 때문에 수단과 방법을 가리지 않을 것입니다.

API Pod 2개

DB Pod 1개


...]]></description><link>https://blog.hyunjun.org/argocd-with-ai</link><guid isPermaLink="true">https://blog.hyunjun.org/argocd-with-ai</guid><category><![CDATA[Devops]]></category><category><![CDATA[Kubernetes]]></category><category><![CDATA[ArgoCD]]></category><category><![CDATA[claude.ai]]></category><dc:creator><![CDATA[조현준]]></dc:creator><pubDate>Tue, 22 Apr 2025 05:59:50 GMT</pubDate><content:encoded><![CDATA[<h2 id="heading-66qo7zey7j2yioylnoyekq">모험의 시작</h2>
<p>지금부터 아무것도 모르는 상황에서 ArgoCD를 세팅해보도록 하겠습니다. 이번 글은 AI를 사용해서 아무것도 모르는 상황에서 과연 어디까지 인프라를 세팅할 수 있을지 테스트해보는 시간입니다.</p>
<p>우선 제가 미리 만들어둔 API 서버가 존재하고 이걸 다음과 같이 구성할 것입니다. 저의 목표는 잘 만드는 것이 아니라 돌아가도록 구성하는 것이 목표이기 때문에 수단과 방법을 가리지 않을 것입니다.</p>
<ol>
<li><p>API Pod 2개</p>
</li>
<li><p>DB Pod 1개</p>
</li>
</ol>
<p>로 구성할 예정이고 나머지는 어떻게 해야하는지 아무것도 모르는 것이 현재 상황입니다.</p>
<p>제가 알고 있는 기본 지식은 다음과 같습니다:</p>
<ul>
<li><p>Docker를 어떻게 사용하는지 간단히 알고 있습니다</p>
</li>
<li><p>혼자서 Application 서버를 만들고 포트와 같은 기본적인 세팅은 혼자서 할 수 있습니다</p>
</li>
<li><p>쿠버네티스는 이름만 알고 실제로 어떻게 동작하는지 어떻게 세팅하는지 하나도 모릅니다</p>
</li>
<li><p>ArgoCD는 쿠버네티스랑 같이 사용하면 좋은건 알지만 정확히 모릅니다</p>
</li>
</ul>
<p>이런 상황에서 클로드 MCP를 이용해서 ArgoCD를 세팅해보도록 하겠습니다</p>
<h2 id="heading-64e7lukio2mjoydvcdshljtjiug67cpioydtouvuoyngoulvcdruyzrk5ztlbtrs7tsnpa">도커 파일 세팅 및 이미지를 빌드해보자</h2>
<p>우선 간단하게 도커 이미지가 있어서 모든 것을 시작할 수 있으니 DockerFile을 클로드에게 만들어 달라고 했습니다. 너무 간단하게 프로젝트를 스캔하고 만들어줘서 1초만에 끝났습니다.</p>
<pre><code class="lang-kotlin">kotlinFROM openjdk:<span class="hljs-number">17</span>-jdk-slim

WORKDIR /app

COPY build/libs/box-recommend-<span class="hljs-number">0.0</span><span class="hljs-number">.1</span>-SNAPSHOT.jar app.jar

EXPOSE <span class="hljs-number">8080</span>

ENTRYPOINT [<span class="hljs-string">"java"</span>, <span class="hljs-string">"-jar"</span>, <span class="hljs-string">"/app/app.jar"</span>]
</code></pre>
<p>이 Dockerfile은 나의 Spring Boot 애플리케이션(box-recommend)을 도커 이미지로 패키징하는 과정을 담고 있습니다. OpenJDK 17을 기반으로 하고, 빌드된 JAR 파일을 컨테이너 내부로 복사한 다음, 8080 포트를 노출시키고 Java 명령어로 애플리케이션을 실행합니다.</p>
<p>이제 이미지를 빌드해볼 차례입니다. 터미널을 열고 Dockerfile이 있는 디렉토리에서 다음 명령을 실행했습니다:</p>
<pre><code class="lang-kotlin">bash# Gradle로 애플리케이션 빌드
./gradlew build

# Docker 이미지 빌드
docker build -t box-recommend:v1 .
</code></pre>
<p>빌드가 성공적으로 완료되었고, 로컬에서 테스트해보기로 했습니다:</p>
<pre><code class="lang-kotlin">bashdocker run -p <span class="hljs-number">8080</span>:<span class="hljs-number">8080</span> box-recommend:v1
</code></pre>
<p>애플리케이션이 정상적으로 시작되는 것을 확인했습니다. 이제 실제 쿠버네티스 환경으로 넘어갈 준비가 되었습니다.</p>
<h2 id="heading-7lg67ke64sk7yuw7iqk66w8ioyeuo2mhe2vmoyxroyencdtjizrk5zrpbwg652e7jum67o07j6q">쿠버네티스를 세팅하여서 파드를 띄워보자</h2>
<p>자, 이제 진짜 도전이 시작됩니다. 쿠버네티스에 대해 아무것도 모르지만, 일단 로컬 환경에서 테스트하기 위해 Minikube를 설치하기로 했습니다.</p>
<pre><code class="lang-kotlin">bash# Minikube 설치 (맥OS 기준)
brew install minikube

# Minikube 시작
minikube start
</code></pre>
<p>Minikube가 설치되고 실행되었습니다. 쿠버네티스 CLI인 kubectl도 함께 설치되었습니다.</p>
<p>이제 우리 애플리케이션을 위한 기본적인 쿠버네티스 매니페스트 파일들을 만들어야 합니다. 우선 API 서버 Deployment와 Service부터 정의해 보겠습니다:</p>
<pre><code class="lang-kotlin">yaml# api-deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: box-recommend-api
spec:
  replicas: <span class="hljs-number">2</span>  # API Pod <span class="hljs-number">2</span>개를 요청했으니 replicas를 <span class="hljs-number">2</span>로 설정
  selector:
    matchLabels:
      app: box-recommend
  template:
    metadata:
      labels:
        app: box-recommend
    spec:
      containers:
      - name: box-recommend
        image: box-recommend:v1
        ports:
        - containerPort: <span class="hljs-number">8080</span>
        env:
        - name: SPRING_DATASOURCE_URL
          value: jdbc:postgresql:<span class="hljs-comment">//box-recommend-db:5432/box_recommend</span>
        - name: SPRING_DATASOURCE_USERNAME
          value: test1234
        - name: SPRING_DATASOURCE_PASSWORD
          value: test1234
---
# api-service.yaml
apiVersion: v1
kind: Service
metadata:
  name: box-recommend-api
spec:
  selector:
    app: box-recommend
  ports:
  - port: <span class="hljs-number">80</span>
    targetPort: <span class="hljs-number">8080</span>
  type: ClusterIP
</code></pre>
<p>다음으로 PostgreSQL 데이터베이스를 위한 매니페스트 파일을 만들었습니다:</p>
<pre><code class="lang-kotlin">yaml# db-deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: box-recommend-db
spec:
  replicas: <span class="hljs-number">1</span>  # DB Pod <span class="hljs-number">1</span>개
  selector:
    matchLabels:
      app: box-recommend-db
  template:
    metadata:
      labels:
        app: box-recommend-db
    spec:
      containers:
      - name: postgres
        image: postgres:<span class="hljs-number">13</span>
        ports:
        - containerPort: <span class="hljs-number">5432</span>
        env:
        - name: POSTGRES_DB
          value: box_recommend
        - name: POSTGRES_USER
          value: test1234
        - name: POSTGRES_PASSWORD
          value: test1234
        volumeMounts:
        - name: postgres-<span class="hljs-keyword">data</span>
          mountPath: /<span class="hljs-keyword">var</span>/lib/postgresql/<span class="hljs-keyword">data</span>
      volumes:
      - name: postgres-<span class="hljs-keyword">data</span>
        emptyDir: {}  # 실제 환경에서는 PersistentVolume을 사용해야 합니다
---
# db-service.yaml
apiVersion: v1
kind: Service
metadata:
  name: box-recommend-db
spec:
  selector:
    app: box-recommend-db
  ports:
  - port: <span class="hljs-number">5432</span>
    targetPort: <span class="hljs-number">5432</span>
  type: ClusterIP
</code></pre>
<p>이제 이 파일들을 사용하여 쿠버네티스에 리소스를 생성해보겠습니다:</p>
<pre><code class="lang-kotlin">bash# Minikube의 Docker 데몬에 이미지 빌드하기
eval $(minikube docker-env)
docker build -t box-recommend:v1 .

# 쿠버네티스 리소스 생성
kubectl apply -f api-deployment.yaml
kubectl apply -f api-service.yaml
kubectl apply -f db-deployment.yaml
kubectl apply -f db-service.yaml
</code></pre>
<p>생성된 리소스들을 확인해봅니다:</p>
<pre><code class="lang-kotlin">bashkubectl <span class="hljs-keyword">get</span> deployments
kubectl <span class="hljs-keyword">get</span> pods
kubectl <span class="hljs-keyword">get</span> services
</code></pre>
<p>API 파드 2개와 DB 파드 1개가 정상적으로 실행 중인 것을 확인할 수 있었습니다.</p>
<h2 id="heading-66ci7kea7iqk7yq466asioyeuo2mhsdrsi8g7j206647keaioyggoyepe2vmoyxrcdsv6drsotrhktti7dsiqtsl5dshjwg7zi47lac7zwgioyimcdsnojrj4troz0g7zw067o07j6q">레지스트리 세팅 및 이미지 저장하여 쿠버네티스에서 호출할 수 있도록 해보자</h2>
<p>실제 프로덕션 환경에서는 도커 이미지를 도커 레지스트리에 푸시하고 쿠버네티스가 그곳에서 이미지를 가져와야 합니다. Minikube에서는 로컬 이미지를 사용할 수 있지만, GitOps 방식으로 ArgoCD를 사용하려면 레지스트리가 필요합니다.</p>
<p>간단하게 Docker Hub를 사용하기로 결정했습니다:</p>
<pre><code class="lang-kotlin">bash# Docker Hub에 로그인
docker login

# 이미지에 태그 지정 (내 Docker Hub 사용자명으로 대체)
docker tag box-recommend:v1 myusername/box-recommend:v1

# Docker Hub에 이미지 푸시
docker push myusername/box-recommend:v1
</code></pre>
<p>이제 매니페스트 파일을 업데이트하여 Docker Hub의 이미지를 사용하도록 변경합니다:</p>
<pre><code class="lang-kotlin">yaml# api-deployment.yaml (수정 부분)
      containers:
      - name: box-recommend
        image: myusername/box-recommend:v1  # Docker Hub 이미지로 변경
</code></pre>
<p>수정된 파일을 적용합니다:</p>
<pre><code class="lang-kotlin">bashkubectl apply -f api-deployment.yaml
</code></pre>
<h2 id="heading-argocd-ui">ArgoCD 세팅하여서 UI로 지금까지 구성한 내용을 확인해보자</h2>
<p>이제 ArgoCD를 설치하고 설정해보겠습니다. ArgoCD는 Git 저장소에서 쿠버네티스 매니페스트 파일을 가져와 클러스터에 자동으로 적용하는 GitOps 도구입니다.</p>
<pre><code class="lang-kotlin">bash# ArgoCD 네임스페이스 생성
kubectl create namespace argocd

# ArgoCD 설치
kubectl apply -n argocd -f https:<span class="hljs-comment">//raw.githubusercontent.com/argoproj/argo-cd/stable/manifests/install.yaml</span>

# ArgoCD CLI 설치 (맥OS 기준)
brew install argocd
</code></pre>
<p>ArgoCD 서비스를 외부에서 접속할 수 있도록 포트 포워딩을 설정합니다:</p>
<pre><code class="lang-kotlin">bashkubectl port-forward svc/argocd-server -n argocd <span class="hljs-number">8080</span>:<span class="hljs-number">443</span>
</code></pre>
<p>이제 웹 브라우저에서 <code>https://localhost:8080</code>으로 접속할 수 있습니다. 초기 로그인 정보는 다음과 같습니다:</p>
<ul>
<li><p>사용자명: admin</p>
</li>
<li><p>비밀번호: (자동 생성됨, 다음 명령어로 확인)</p>
</li>
</ul>
<pre><code class="lang-kotlin">bashkubectl -n argocd <span class="hljs-keyword">get</span> secret argocd-initial-admin-secret -o jsonpath=<span class="hljs-string">"{.data.password}"</span> | base64 -d
</code></pre>
<p>로그인 후, 우리의 애플리케이션을 ArgoCD에 등록하기 위해 먼저 Git 저장소를 만들고 매니페스트 파일을 저장해야 합니다. GitHub에 새 저장소를 만들고 매니페스트 파일을 푸시했습니다:</p>
<pre><code class="lang-kotlin">bash# 로컬에 Git 저장소 초기화
mkdir box-recommend-k8s
cd box-recommend-k8s
git <span class="hljs-keyword">init</span>

# 매니페스트 파일 복사
cp ../api-deployment.yaml .
cp ../api-service.yaml .
cp ../db-deployment.yaml .
cp ../db-service.yaml .

# 변경사항 커밋 및 푸시
git add .
git commit -m <span class="hljs-string">"Initial commit for Kubernetes manifests"</span>
git remote add origin https:<span class="hljs-comment">//github.com/myusername/box-recommend-k8s.git</span>
git push -u origin main
</code></pre>
<p>이제 ArgoCD UI에서 새 애플리케이션을 추가합니다:</p>
<ol>
<li><p>"NEW APP" 버튼 클릭</p>
</li>
<li><p>애플리케이션 정보 입력:</p>
<ul>
<li><p>Application Name: box-recommend</p>
</li>
<li><p>Project: default</p>
</li>
<li><p>Sync Policy: Automatic</p>
</li>
<li><p>Repository URL: <a target="_blank" href="https://github.com/myusername/box-recommend-k8s.git">https://github.com/myusername/box-recommend-k8s.git</a></p>
</li>
<li><p>Path: .</p>
</li>
<li><p>Cluster: <a target="_blank" href="https://kubernetes.default.svc">https://kubernetes.default.svc</a> (기본 클러스터)</p>
</li>
<li><p>Namespace: default</p>
</li>
</ul>
</li>
<li><p>"CREATE" 버튼 클릭</p>
</li>
</ol>
<p>ArgoCD가 자동으로 Git 저장소에서 매니페스트 파일을 가져와 쿠버네티스 클러스터에 적용합니다. UI에서 애플리케이션의 상태를 시각적으로 확인할 수 있습니다. 모든 리소스가 정상적으로 동기화되고 실행 중인 상태로 표시됩니다.</p>
<h2 id="heading-api">만들어둔 API 호출하여 정상 동작하는지 확인해보자</h2>
<p>이제 API가 정상적으로 동작하는지 확인해보겠습니다. 쿠버네티스 환경에서는 서비스를 통해 API에 접근할 수 있습니다. 간단하게 포트 포워딩을 사용하여 로컬에서 API에 접근해 보겠습니다:</p>
<pre><code class="lang-kotlin">bashkubectl port-forward svc/box-recommend-api <span class="hljs-number">8081</span>:<span class="hljs-number">80</span>
</code></pre>
<p>이제 웹 브라우저나 curl을 사용하여 API를 호출할 수 있습니다:</p>
<pre><code class="lang-kotlin">bashcurl http:<span class="hljs-comment">//localhost:8081/api/recommend</span>
</code></pre>
<p>그런데 API 응답이 없고 오류가 발생했습니다. 로그를 확인해보니 데이터베이스 연결 문제가 있었습니다:</p>
<pre><code class="lang-kotlin">bashkubectl logs deployment/box-recommend-api
</code></pre>
<p>로그를 살펴보니 PostgreSQL 데이터베이스가 초기화되지 않았거나, 연결 문제가 있었습니다. 먼저 데이터베이스 접속을 확인해보겠습니다:</p>
<pre><code class="lang-kotlin">bash# PostgreSQL 파드 이름 확인
kubectl <span class="hljs-keyword">get</span> pods | grep db

# 데이터베이스 접속
kubectl exec -it box-recommend-db-&lt;pod-id&gt; -- psql -U test1234 -d box_recommend
</code></pre>
<p>데이터베이스에 접속할 수 있었고, 테이블이 없는 상태였습니다. Spring Boot 애플리케이션은 일반적으로 Hibernate/JPA를 통해 초기화 시 필요한 테이블을 생성하지만, 초기 연결 문제로 이 과정이 실패한 것 같습니다.</p>
<p>문제를 해결하기 위해 API 서버의 환경 변수 설정을 수정했습니다:</p>
<pre><code class="lang-kotlin">yaml# api-deployment.yaml (수정 부분)
        env:
        - name: SPRING_DATASOURCE_URL
          value: jdbc:postgresql:<span class="hljs-comment">//box-recommend-db:5432/box_recommend</span>
        - name: SPRING_DATASOURCE_USERNAME
          value: test1234
        - name: SPRING_DATASOURCE_PASSWORD
          value: test1234
        - name: SPRING_JPA_HIBERNATE_DDL_AUTO
          value: update  # 추가: 데이터베이스 스키마 자동 업데이트
        - name: SPRING_JPA_SHOW_SQL
          value: <span class="hljs-string">"true"</span>  # 추가: SQL 로깅 활성화
</code></pre>
<p>변경사항을 Git 저장소에 커밋하고 푸시합니다:</p>
<pre><code class="lang-kotlin">bashgit add api-deployment.yaml
git commit -m <span class="hljs-string">"Fix database connection settings"</span>
git push
</code></pre>
<p>ArgoCD가 자동으로 변경사항을 감지하고 클러스터에 적용합니다. 잠시 후, API 파드가 재시작되고 정상적으로 동작하기 시작했습니다. 다시 API를 호출해봅니다:</p>
<pre><code class="lang-kotlin">bashcurl http:<span class="hljs-comment">//localhost:8081/api/recommend</span>
</code></pre>
<p>이번에는 응답이 정상적으로 받아졌습니다! 모든 것이 제대로 동작하고 있습니다.</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1745301526105/6d683e25-03d4-4e2f-b721-2139e54a88a2.png" alt class="image--center mx-auto" /></p>
<h2 id="heading-66qo7zeyioyeseqztsdrsi8g64qq64ka7kcq">모험 성공 및 느낀점</h2>
<p>처음에는 쿠버네티스와 ArgoCD에 대해 아무것도 모른 채 시작했지만, 단계적으로 접근하고 문제를 하나씩 해결해 나가면서 작동하는 시스템을 구축할 수 있었습니다. 이 과정에서 몇 가지 중요한 교훈을 얻었습니다:</p>
<ol>
<li><p><strong>컨테이너 기술의 강력함</strong>: Docker를 통해 애플리케이션을 패키징하면 어디서든 일관되게 실행할 수 있습니다.</p>
</li>
<li><p><strong>선언적 인프라의 가치</strong>: 쿠버네티스 매니페스트 파일과 같은 선언적 접근 방식은 인프라를 코드로 관리할 수 있게 해줍니다.</p>
</li>
<li><p><strong>GitOps의 효율성</strong>: ArgoCD를 통해 Git 저장소에 변경사항을 커밋하는 것만으로 자동으로 클러스터에 반영되는 경험은 놀라웠습니다.</p>
</li>
<li><p><strong>문제 해결 능력의 중요성</strong>: 초기 데이터베이스 연결 문제와 같은 장애가 발생했을 때, 로그를 확인하고 문제를 진단하여 해결하는 과정은 매우 중요했습니다.</p>
</li>
</ol>
<p>이 모험을 통해 쿠버네티스와 ArgoCD의 기본 개념과 동작 방식을 이해할 수 있었고, 앞으로 더 복잡한 인프라를 구축하는 데 필요한 기초를 다질 수 있었습니다. 아무것도 모르던 상태에서 시작했지만, "돌아가는" 시스템을 구축하는 목표는 달성했습니다.</p>
<p>물론 이 설정은 프로덕션 환경에서 사용하기에는 부족한 부분이 많습니다. 영구 스토리지, 보안 설정, 리소스 제한, 모니터링, 고가용성 등 고려해야 할 요소가 많습니다. 하지만 이번 모험은 첫 발을 내딛는 과정이었고, 앞으로 더 깊이 탐구하고 개선해 나갈 수 있는 기반이 되었습니다.</p>
<p>쿠버네티스와 ArgoCD의 세계는 생각보다 넓고 깊지만, 한 걸음씩 나아가다 보면 어느새 복잡한 인프라도 자신 있게 다룰 수 있게 될 것입니다. 이번 모험은 그 여정의 시작점이었습니다.</p>
]]></content:encoded></item><item><title><![CDATA[3차원 박스 적재 최적화를 위한 OptaPlanner 기반 구현기]]></title><description><![CDATA[1. 도입
물류센터, 창고관리(WMS), 배송 박싱 자동화 등의 문제에서 공통적으로 요구되는 기능 중 하나는 여러 물건(Item)을 제한된 공간(Bin)에 효과적으로 적재(Packing) 하는 것입니다.
이를 해결하기 위해서는 여러가지 알고리즘 밑 방법을 사용할 수 있는데 오늘은 Bin-Packing 알고리즘에 대해서 공부하고 이를 실제로 구현해보도록 하겠습니다.
이 글에서는 이러한 문제를 해결하기 위해 Java 기반의 제약 프로그래밍 프레임워...]]></description><link>https://blog.hyunjun.org/3d-bin-packing-optaplanner</link><guid isPermaLink="true">https://blog.hyunjun.org/3d-bin-packing-optaplanner</guid><category><![CDATA[optaplanner]]></category><category><![CDATA[bin packing]]></category><dc:creator><![CDATA[조현준]]></dc:creator><pubDate>Sun, 20 Apr 2025 02:13:13 GMT</pubDate><content:encoded><![CDATA[<h2 id="heading-1"><strong>1. 도입</strong></h2>
<p>물류센터, 창고관리(WMS), 배송 박싱 자동화 등의 문제에서 공통적으로 요구되는 기능 중 하나는 <strong>여러 물건(Item)을 제한된 공간(Bin)에 효과적으로 적재(Packing)</strong> 하는 것입니다.</p>
<p>이를 해결하기 위해서는 여러가지 알고리즘 밑 방법을 사용할 수 있는데 오늘은 Bin-Packing 알고리즘에 대해서 공부하고 이를 실제로 구현해보도록 하겠습니다.</p>
<p>이 글에서는 이러한 문제를 해결하기 위해 Java 기반의 제약 프로그래밍 프레임워크인 <strong>OptaPlanner</strong>와 <strong>JavaFX 기반 3D 시각화</strong>를 활용하여 <strong>3D Bin Packing 문제</strong>를 해결한 과정을 정리합니다.</p>
<h2 id="heading-2"><strong>2. 목표</strong></h2>
<ul>
<li><p>다양한 크기와 모양(Shape)의 물건들을 제한된 박스 내부에 겹치지 않도록 배치</p>
</li>
<li><p>회전 가능한 물체의 방향(Rotation) 고려</p>
</li>
<li><p>실제 현실과 유사하게 물리 제약, 무게 제약, 부피 제약, 버퍼 영역 등을 반영</p>
</li>
<li><p><strong>사용 박스 수를 최소화</strong>하는 방향으로 최적화</p>
</li>
<li><p>결과를 <strong>3D 화면으로 시각화</strong>하여 검증 가능하도록 구성</p>
</li>
</ul>
<h2 id="heading-3"><strong>3. 시스템 구성</strong></h2>
<h3 id="heading-31-item-bin"><strong>3.1 Item과 Bin 모델링</strong></h3>
<pre><code class="lang-kotlin"><span class="hljs-keyword">data</span> <span class="hljs-class"><span class="hljs-keyword">class</span> <span class="hljs-title">Item</span></span>(<span class="hljs-keyword">val</span> id: <span class="hljs-built_in">Int</span>, <span class="hljs-keyword">val</span> width: <span class="hljs-built_in">Long</span>, <span class="hljs-keyword">val</span> height: <span class="hljs-built_in">Long</span>, <span class="hljs-keyword">val</span> length: <span class="hljs-built_in">Long</span>, <span class="hljs-keyword">val</span> shape: Shape, ...)
<span class="hljs-keyword">data</span> <span class="hljs-class"><span class="hljs-keyword">class</span> <span class="hljs-title">Bin</span></span>(<span class="hljs-keyword">val</span> id: <span class="hljs-built_in">Int</span>, <span class="hljs-keyword">val</span> width: <span class="hljs-built_in">Long</span>, <span class="hljs-keyword">val</span> height: <span class="hljs-built_in">Long</span>, <span class="hljs-keyword">val</span> length: <span class="hljs-built_in">Long</span>, <span class="hljs-keyword">val</span> buffer: <span class="hljs-built_in">Double</span>, ...)
</code></pre>
<ul>
<li><p>Item은 물건 하나를 나타내며, 회전 가능성과 형태 정보도 포함합니다.</p>
</li>
<li><p>Bin은 적재 가능한 공간으로, 버퍼와 최대 무게 제한 등을 포함합니다.</p>
</li>
</ul>
<h3 id="heading-32-planningentity-itemassignment"><strong>3.2 PlanningEntity: ItemAssignment</strong></h3>
<pre><code class="lang-kotlin"><span class="hljs-meta">@PlanningEntity</span>
<span class="hljs-keyword">data</span> <span class="hljs-class"><span class="hljs-keyword">class</span> <span class="hljs-title">ItemAssignment</span></span>(..., <span class="hljs-keyword">var</span> bin: Bin?, <span class="hljs-keyword">var</span> x: <span class="hljs-built_in">Long</span>?, <span class="hljs-keyword">var</span> y: <span class="hljs-built_in">Long</span>?, <span class="hljs-keyword">var</span> z: <span class="hljs-built_in">Long</span>?, <span class="hljs-keyword">var</span> rotation: Rotation?)
</code></pre>
<ul>
<li>각 ItemAssignment는 특정 Item이 어떤 Bin의 어느 위치(x,y,z)에 어떤 방향(rotation)으로 배치될지를 나타냅니다.</li>
</ul>
<h2 id="heading-4"><strong>4. 제약 조건 구성</strong></h2>
<h3 id="heading-41"><strong>4.1 주요 제약 조건</strong></h3>
<ul>
<li><p><strong>itemMustFitInBin</strong>: 물건이 박스를 넘지 않도록 보장</p>
</li>
<li><p><strong>noOverlap</strong>: 두 아이템이 같은 공간을 차지하지 않도록 제한</p>
</li>
<li><p><strong>binCapacityExceeded</strong>: 버퍼를 고려한 부피 초과 방지</p>
</li>
<li><p><strong>binWeightLimitExceeded</strong>: 최대 무게 제한</p>
</li>
<li><p><strong>minimizeBinUsage</strong>: 사용한 박스 수를 줄이기 위한 소프트 제약</p>
</li>
</ul>
<h3 id="heading-42-constraintpurpose"><strong>4.2 ConstraintPurpose를 통한 점수 구조</strong></h3>
<pre><code class="lang-kotlin"><span class="hljs-keyword">enum</span> <span class="hljs-class"><span class="hljs-keyword">class</span> <span class="hljs-title">ConstraintPurpose</span></span>(<span class="hljs-keyword">val</span> level: <span class="hljs-built_in">Int</span>, <span class="hljs-keyword">val</span> isHard: <span class="hljs-built_in">Boolean</span>, <span class="hljs-keyword">val</span> description: String)
</code></pre>
<ul>
<li><p>하드 제약: 충돌, 무게 초과 등 현실적 충족 필수 조건</p>
</li>
<li><p>소프트 제약: 사용 박스 수 최소화, 빈 공간 최소화, 무게 배분 등</p>
</li>
</ul>
<h2 id="heading-5"><strong>5. 솔버 구성</strong></h2>
<pre><code class="lang-kotlin">SolverFactory.create&lt;BinPackingSolution&gt;(
    SolverConfig()
        .withSolutionClass(...)
        .withEntityClasses(...)
        .withConstraintProviderClass(...)
        .withTerminationConfig(
            TerminationConfig().apply {
                unimprovedSecondsSpentLimit = <span class="hljs-number">3L</span>
            }
        )
</code></pre>
<ul>
<li><p>SolverFactory를 통해 제약 조건 기반의 최적해를 탐색</p>
</li>
<li><p>종료 조건은 3초 동안 점수 개선이 없을 경우 자동 종료</p>
</li>
</ul>
<h2 id="heading-6"><strong>6. 시각화</strong></h2>
<h3 id="heading-61-javafx-3d-viewer"><strong>6.1 JavaFX 기반 3D Viewer</strong></h3>
<h4 id="heading-kirso7zsmpqg7yq57kevoioq"><strong>주요 특징:</strong></h4>
<ul>
<li><p>각 Bin은 <strong>검정 테두리의 박스</strong>로 시각화</p>
</li>
<li><p>각 Item은 <strong>반투명하고 색상 지정된 3D Box</strong>로 시각화</p>
</li>
<li><p>각 아이템은 <strong>Golden Angle 기반 Hue 값</strong>을 통해 고유 색상을 유지</p>
</li>
<li><p>XYZ 축은 빨강, 초록, 파랑 선 및 라벨로 표현</p>
</li>
<li><p>카메라는 대각선 위에서 XY 평면을 내려다보는 구조로 배치</p>
</li>
<li><p>마우스 회전 및 확대 기능은 이후 단계에서 확장 가능</p>
</li>
</ul>
<h3 id="heading-62"><strong>6.2 예시 화면</strong></h3>
<p>실행 시 결과 예시는 다음과 같습니다:</p>
<ul>
<li>콘솔 화면</li>
</ul>
<pre><code class="lang-kotlin">=== 결과 ===
Item <span class="hljs-number">1</span> -&gt; Bin <span class="hljs-number">1</span> | Rotation: XYZ | X: <span class="hljs-number">0</span>, Y: <span class="hljs-number">0</span>, Z: <span class="hljs-number">0</span>
Item <span class="hljs-number">2</span> -&gt; Bin <span class="hljs-number">1</span> | Rotation: YXZ | X: <span class="hljs-number">1</span>, Y: <span class="hljs-number">0</span>, Z: <span class="hljs-number">0</span>
...

Score: <span class="hljs-number">0</span>hard/<span class="hljs-number">0</span>soft

Bin <span class="hljs-number">1</span> [XY 평면 @ Z=<span class="hljs-number">0</span>]
| <span class="hljs-number">1</span> | <span class="hljs-number">2</span> |   |
| <span class="hljs-number">3</span> |   |   |
|   |   |   |

Bin <span class="hljs-number">1</span> [XY 평면 @ Z=<span class="hljs-number">1</span>]
| <span class="hljs-number">4</span> | <span class="hljs-number">5</span> |   |
|   |   |   |
|   |   |   |
</code></pre>
<ul>
<li>JavaFX 화면</li>
</ul>
<p><img src="https://github.com/user-attachments/assets/cdc613f3-9628-4e34-a73b-9d6765a4b070" alt class="image--center mx-auto" /></p>
<p>콘솔에는 XY 평면 기준으로 z=0부터 아이템이 어떻게 적재되었는지를 확인할 수 있습니다.</p>
<h2 id="heading-7"><strong>7. 학습 및 구현에서의 주요 고려 사항</strong></h2>
<ul>
<li><p><strong>단순히 부피만 고려하는 것이 아니라, 모양에 따른 buffer 영역까지 고려</strong>해야 현실적인 배치가 가능</p>
</li>
<li><p><strong>아이템 회전과 도형별 부피 공식</strong>, 특히 원통, 원뿔, 파우치형 등은 단순 박스형보다 복잡</p>
</li>
<li><p><strong>3D 시각화는 디버깅 및 결과 검증에 매우 효과적</strong></p>
</li>
</ul>
<h2 id="heading-8"><strong>8. 마무리</strong></h2>
<p>이번 프로젝트를 통해 물류 시스템에서 자주 접하게 되는 <strong>Packing 최적화 문제</strong>에 대해 제약 기반의 방식으로 접근할 수 있음을 경험할 수 있었습니다.</p>
<p>OptaPlanner는 수많은 상태를 가진 조합 문제를 빠르게 탐색할 수 있게 해주며, JavaFX 기반의 3D 출력은 단지 “작동한다” 이상의 검증 도구로 활용할 수 있었습니다.</p>
<p>다음 확장 방향으로는 다음을 고려하고 있습니다:</p>
<ul>
<li><p>사용자 마우스 회전, 확대 조작 추가</p>
</li>
<li><p>다양한 박스 규격 자동 선택 로직</p>
</li>
<li><p>병렬 Bin 추천 기능 및 우선순위 부여 로직</p>
</li>
<li><p>웹기반 3D View 또는 Blender 렌더링 자동화</p>
</li>
</ul>
<h2 id="heading-9">9. 참고 자료</h2>
<ul>
<li><p><a target="_blank" href="https://github.com/Tianea2160/bin-packing">Bin Packing 소스 코드</a></p>
</li>
<li><p><a target="_blank" href="https://www.optaplanner.org/">Optaplanner 공식 문서</a></p>
</li>
</ul>
]]></content:encoded></item></channel></rss>