<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
    <channel>
        <title>Jesslog</title>
        <link>https://velog.io/</link>
        <description>꾸준한 공부만이 답이다</description>
        <lastBuildDate>Sat, 14 Feb 2026 05:47:29 GMT</lastBuildDate>
        <docs>https://validator.w3.org/feed/docs/rss2.html</docs>
        <generator>https://github.com/jpmonette/feed</generator>
        <image>
            <title>Jesslog</title>
            <url>https://velog.velcdn.com/images/jess_kim/profile/34f1ba41-0dd4-4b31-8c33-5aab4bc7ad2c/image.png</url>
            <link>https://velog.io/</link>
        </image>
        <copyright>Copyright (C) 2019. Jesslog. All rights reserved.</copyright>
        <atom:link href="https://v2.velog.io/rss/jess_kim" rel="self" type="application/rss+xml"/>
        <item>
            <title><![CDATA[Kafka에 대해 자세히 알아보자]]></title>
            <link>https://velog.io/@jess_kim/Kafka%EB%9E%80</link>
            <guid>https://velog.io/@jess_kim/Kafka%EB%9E%80</guid>
            <pubDate>Sat, 14 Feb 2026 05:47:29 GMT</pubDate>
            <description><![CDATA[<blockquote>
<p><strong>Kafka는 대규모 데이터 스트림을 처리하기 위한 분산형 메시징 플랫폼을 말한다.</strong></p>
</blockquote>
<p>단순한 메시지 큐가 아닌 분산 로그 시스템이기 때문에 내부 구성 요소들이 &quot;고성능 / 장애 복구 / 확장성&quot;을 중심으로 설계되어 있다.</p>
</br>

<h2 id="🔹-kafka-핵심-개념-요약">🔹 Kafka 핵심 개념 요약</h2>
<table>
<thead>
<tr>
<th align="center"><img src="https://velog.velcdn.com/images/jess_kim/post/32c50f0e-22fb-4e32-aa96-4ff520a58173/image.png" alt=""></th>
</tr>
</thead>
</table>
<h4 id="🔸-producer-생산자">🔸 Producer (생산자)</h4>
<ul>
<li>메시지를 만들어서 Kafka에 전송하는 주체</li>
<li>예: 주문 서비스가 &quot;주문 생성 이벤트&quot;를 발행</li>
</ul>
</br>

<h4 id="🔸-consumer-소비자">🔸 Consumer (소비자)</h4>
<ul>
<li>Kafka로부터 메시지를 읽어 처리하는 주체</li>
<li>예: 배송 서비스가 &quot;주문 생성 이벤트&quot;를 구독해서 배송 요청 처리</li>
</ul>
</br>

<h4 id="🔸-broker-브로커">🔸 Broker (브로커)</h4>
<ul>
<li>메시지를 저장하고 전달하는 역할의 Kafka 서버 한 대</li>
<li>여러 대가 모여 Kafka Cluster를 구성</li>
<li>브로커 장애 시에도 데이터 유지 가능</li>
</ul>
</br>

<h4 id="🔸-topic-토픽">🔸 Topic (토픽)</h4>
<ul>
<li>메시지가 구분되어 저장되는 논리적 분류 단위</li>
<li>예: <code>order-created</code>, <code>payment-completed</code></li>
</ul>
</br>

<h4 id="🔸-partition-파티션">🔸 Partition (파티션)</h4>
<ul>
<li>토픽을 쪼갠 물리적 단위</li>
<li>토픽을 여러 조각으로 나누어 병렬처리와 확장성 확보</li>
<li>각 파티션은 파티션 내부에서만 순서를 보장한다</li>
<li>Partition 수만큼 Consumer 병렬 처리 가능</li>
<li>파티션 증가 = 처리량 증가</li>
</ul>
</br>

<h4 id="🔸-offset-오프셋">🔸 Offset (오프셋)</h4>
<ul>
<li>파티션 내 메시지의 순서 번호</li>
<li>Consumer가 어디까지 읽었는지 추적하는 기준</li>
</ul>
</br>

<hr>
</br>

<h2 id="🔹-kafka-동작-구조">🔹 Kafka 동작 구조</h2>
<pre><code class="language-ruby">Producer  →  [Kafka Broker]  →  Consumer
               | Topic |
           ┌──────────────┐
           │ Partition 0  │ → 메시지 A, B, C ...
           │ Partition 1  │ → 메시지 D, E, F ...
           └──────────────┘
</code></pre>
<ol>
<li>Producer가 특정 Topic으로 메시지를 보냄</li>
<li>Kafka Broker는 그 메시지를 해당 Topic의 Partition에 저장</li>
<li>Consumer는 자신이 구독한 Topic의 Partition에서 메시지를 Offset 순서대로 읽음</li>
</ol>
</br>

<hr>
</br>

<h2 id="🔹-kafka의-주요-특징">🔹 Kafka의 주요 특징</h2>
<h4 id="🔸-고성능--고처리량">🔸 고성능 / 고처리량</h4>
<ul>
<li>초당 수십~수백만 건의 메시지 처리 가능</li>
</ul>
</br>

<h4 id="🔸-확장성">🔸 확장성</h4>
<ul>
<li>브로커와 파티션을 늘려 부하를 분산 가능</li>
</ul>
</br>

<h4 id="🔸-내구성--장애-복구">🔸 내구성 / 장애 복구</h4>
<ul>
<li>메시지를 디스크에 저장하고 복제하여 장애 시에도 데이터 유실 방지</li>
</ul>
</br>

<h4 id="🔸-실시간-처리">🔸 실시간 처리</h4>
<ul>
<li>스트림 데이터를 실시간으로 처리</li>
<li>예: IoT 센서, 로그, 결제 이벤트 등</li>
</ul>
</br>

<h4 id="🔸-pub--sub-구조">🔸 Pub / Sub 구조</h4>
<ul>
<li>한 Producer가 보낸 메시지를 여러 Consumer가 동시에 구독 가능</li>
</ul>
</br>

<hr>
</br>

<h2 id="🔹-kafka의-활용-예시">🔹 Kafka의 활용 예시</h2>
<h4 id="🔸-로그-수집-이벤트">🔸 로그 수집 이벤트</h4>
<ul>
<li>각 서버의 로그를 Kafka에 모은 뒤 ELK(ElasticSearch, Logstash, Kibana)로 전송</li>
</ul>
</br>

<h4 id="🔸-주문--결제-이벤트-처리">🔸 주문 / 결제 이벤트 처리</h4>
<ul>
<li>주문, 결제, 배송 서비스를 비동기 이벤트 기반으로 연결</li>
</ul>
</br>

<h4 id="🔸-실시간-데이터-분석">🔸 실시간 데이터 분석</h4>
<ul>
<li>클릭스트림, 트래픽 데이터, 금융 거래를 실시간 분석</li>
</ul>
</br>

<h4 id="🔸-iot-데이터-스트리밍">🔸 IoT 데이터 스트리밍</h4>
<ul>
<li>수많은 센서 데이터 실시간 수집 및 처리</li>
</ul>
</br>

<h4 id="🔸-데이터-파이프라인-중간-버퍼">🔸 데이터 파이프라인 중간 버퍼</h4>
<ul>
<li>Spark, Flink, Hadoop 등으로 데이터를 전달하는 중간 허브 역할</li>
</ul>
</br>

<hr>
</br>

<h2 id="🔹-kafka의-파티션-전략">🔹 Kafka의 파티션 전략</h2>
<blockquote>
<p><strong>Kafka에서 파티션 전략은 성능 / 순서 / 확장성 / 데이터 일관성을 동시에 결정하는 핵심 설계 요소이다.
그렇기 때문에 단순히 &quot;많이 나누면 좋다&quot;가 아니라, 비즈니스 특성과 소비 패턴을 고려해 설계해야 한다.</strong></p>
</blockquote>
<h3 id="🔸-key가-없는-경우">🔸 Key가 없는 경우</h3>
<pre><code class="language-java">kafkaTemplate.send(&quot;order-topic&quot;, value);
</code></pre>
<ul>
<li>Kafka가 자동으로 Round Robin</li>
<li>메시지 균등 분산</li>
<li>순서 보장 없음 (전체 단위로)</li>
<li>예: 로그 수집, 클릭 이벤트, 순서 의미가 약한 데이터 등</li>
</ul>
</br>

<h3 id="🔸-key-기반-파티셔닝">🔸 Key 기반 파티셔닝</h3>
<pre><code class="language-java">kafkaTemplate.send(&quot;order-topic&quot;, userId, value);
</code></pre>
<p><strong>&quot;이 <code>단위</code> 안에서는 순서를 반드시 보장해야 한다&quot; → 그 <code>단위</code>를 key로 사용</strong></p>
<table>
<thead>
<tr>
<th align="center"><img src="https://velog.velcdn.com/images/jess_kim/post/cf0c4147-01ef-4cff-893d-f970eb5401b0/image.png" alt=""></th>
</tr>
</thead>
</table>
</br>

<h4 id="🔸-key-기반-파티셔닝-예시">🔸 key 기반 파티셔닝 예시</h4>
<ul>
<li>사용자별 순서 중요 → userId</li>
<li>주문별 순서 중요 → orderId</li>
<li>상품 재고 중요 → productId</li>
<li>전체 순서 중요 → Partition 1개</li>
</ul>
</br>

<h3 id="🔸-hot-partition-문제">🔸 Hot Partition 문제</h3>
<ul>
<li><p>예: userId 기준으로 분산했는데 특정 user가 트래픽 80% 차지
  → 결과:</p>
<pre><code class="language-sql">  Partition 0 → 80% 부하
   나머지 → 한가함
</code></pre>
<p>  이를 Hot Partition이라고 한다.</p>
</li>
<li><p>Hot Partition 문제에 대한 해결 전략으로 3개 정도 서술하자면,</p>
<ol>
<li>Key 세분화</li>
<li>Composite Key 전략</li>
<li>Custom Partitioner 사용</li>
</ol>
</li>
</ul>
</br>

<h4 id="🔸-1-key-세분화">🔸 1. Key 세분화</h4>
<pre><code class="language-java">userId + randomSuffix
</code></pre>
<p>단점: 순서 보장 깨질 수 있음</p>
<h4 id="🔸-2-composite-key-전략">🔸 2. Composite Key 전략</h4>
<pre><code class="language-java">userId + orderDate
</code></pre>
<p>시간 단위로 분산</p>
<h4 id="🔸-3-custom-partitioner-사용">🔸 3. Custom Partitioner 사용</h4>
<pre><code class="language-java">public class CustomPartitioner implements Partitioner {

    @Override
    public int partition(...) {
        // 직접 분산 로직 작성
    }
}
</code></pre>
<p>특정 로직 기반 분산 가능</p>
</br>

<h3 id="🔸-partition-개수-설계-기준">🔸 Partition 개수 설계 기준</h3>
<blockquote>
<p><strong>파티션 수는 늘릴 수는 있지만 줄일 때는 위험(순서 보장 깨질 수 있음, 데이터 분산 재정렬)이 따르기 때문에 처음 설계가 매우 중요하다.</strong></p>
</blockquote>
<ul>
<li><p>파티션 전략 설게 시 고려할 점:</p>
<ul>
<li>어디까지 순서를 보장해야 하는가?</li>
<li>최대 트래픽은 얼마나 되는가?</li>
<li>특정 key에 트래픽이 몰릴 가능성은 없는가?</li>
</ul>
</li>
<li><p>목표 처리량을 기반으로 계산해서 설계할 때는:</p>
<ul>
<li><strong><code>목표 TPS ÷ 파티션당 처리 가능 TPS = 최소 파티션 수</code></strong></li>
</ul>
</li>
<li><p>Consumer 수 고려해서 설계할 때는</p>
<ul>
<li>Consumer Group 내 Consumer 수 &lt;= Partition 수</li>
<li>향후 확장 고려해서 여유 두기</li>
</ul>
</li>
</ul>
</br>

<h4 id="🔸-partition-너무-많으면-생기는-문제">🔸 Partition 너무 많으면 생기는 문제</h4>
<table>
<thead>
<tr>
<th align="center">문제</th>
<th align="center">설명</th>
</tr>
</thead>
<tbody><tr>
<td align="center">파일 핸들 증가</td>
<td align="center">OS 리소스 증가</td>
</tr>
<tr>
<td align="center">메모리 사용 증가</td>
<td align="center">브로커 부하</td>
</tr>
<tr>
<td align="center">Rebalance 시간 증가</td>
<td align="center">장애 시 지연</td>
</tr>
<tr>
<td align="center">관리 복잡도 증가</td>
<td align="center">운영 비용 증가</td>
</tr>
</tbody></table>
<p>일반적으로 수십~수백 개는 흔함. 수천 개 이상은 신중해야 함</p>
</br>

<hr>
<h2 id="🔹-kafka-outbox-패턴-적용">🔹 Kafka Outbox 패턴 적용</h2>
<p>Kafka + Outbox 패턴 설계는 데이터 정합성과 확장성을 동시에 만족시키기 위한 핵심 설계이다.
특히 주문/결제/재고처럼 정합성이 중요한 도메인에서 거의 표준에 가깝다고 생각한다.</p>
<p>Outbox 패턴의 핵심은:</p>
<blockquote>
<p><strong>이벤트를 DB 트랜잭션 안에서 함께 저장하고 나중에 별도 프로세스가 Kafka로 발행하는 것에 있다.</strong></p>
</blockquote>
<ul>
<li>Outbox가 해결하는 것:<ul>
<li>DB와 Kafka 간 원자성</li>
<li>Kafka 전송 실패 시 재시도 가능</li>
<li>메시지 유실 방지</li>
</ul>
</li>
<li>하지만, Kafka 레벨에서 중복은 발생 가능</li>
<li>따라서 Consumer는:<ul>
<li>멱등성 처리 필요</li>
<li>이벤트 ID 기반 중복 제거</li>
</ul>
</li>
</ul>
<h4 id="🔸-알아두면-좋은-실무에서-무난하고-안전한-설계-공식">🔸 알아두면 좋은 실무에서 무난하고 안전한 설계 공식</h4>
<ol>
<li>Outbox 테이블에 event_id 포함</li>
<li>Kafka key = aggregate_id</li>
<li>Consumer는 event_id로 멱등 처리</li>
<li>Partition 수는 예상 TPS 기반 설계</li>
</ol>
<pre><code class="language-ruby">[Order Service]
   ├─ Order 저장
   └─ Outbox 저장 (aggregate_id 포함)

[Outbox Publisher]
   └─ Kafka 전송 (key = aggregate_id)

[Kafka]
   └─ 같은 aggregate는 같은 Partition

[Consumer]
   └─ event_id 기반 멱등 처리
</code></pre>
</br>

<h3 id="🔸-kafka-장애-시-outbox-재처리-전략">🔸 Kafka 장애 시 Outbox 재처리 전략</h3>
<p>Kafka 장애 시 Outbox 재처리 전략의 핵심은:</p>
<blockquote>
<p><strong>1. 데이터 유실 없이
2. 중복은 허용하되 멱등으로 해결한다
3. 무한 재시도는 막는다
4. 락 전략을 명확히 한다</strong></p>
</blockquote>
<h4 id="🔸-전체-장애-대응-흐름">🔸 전체 장애 대응 흐름</h4>
<pre><code class="language-ruby">DB TX 성공
   ↓
Outbox 저장
   ↓
Kafka 장애 발생
   ↓
Outbox 재시도
   ↓
Kafka 복구
   ↓
정상 전송
   ↓
Consumer 멱등 처리
</code></pre>
</br>

<h3 id="🔸-장애-시나리오별-분석">🔸 장애 시나리오별 분석</h3>
<h4 id="🔸-시나리오-1-kafka-브로커-일시적-다운">🔸 시나리오 1. Kafka 브로커 일시적 다운</h4>
<p><strong>[상황]</strong></p>
<ul>
<li>DB 저장 성공</li>
<li>Outbox 저장 성공</li>
<li>Kafka 전송 시도 → 실패</li>
</ul>
<p><strong>[해결 전략]</strong></p>
<ul>
<li>Outbox row는 <code>status = READY</code> 상태 유지 → Publisher가 재시도</li>
</ul>
</br>

<h4 id="🔸-시나리오-2-kafka-전송-성공했지만-응답-받기-전-장애">🔸 시나리오 2. Kafka 전송 성공했지만 응답 받기 전 장애</h4>
<p><strong>[상황]</strong></p>
<ul>
<li>Kafka에 실제로 메시지는 저장됨</li>
<li>네트워크 문제로 응답 못 받음</li>
<li>애플리케이션은 실패로 인식</li>
<li>재전송 발생
→ 중복 이벤트 발생 가능</li>
</ul>
<p><strong>[해결 전략]</strong></p>
<ul>
<li>Consumer는 반드시 멱등 처리</li>
</ul>
</br>

<h4 id="🔸-시나리오-3-outbox-publisher-장애">🔸 시나리오 3. Outbox Publisher 장애</h4>
<p><strong>[상황]</strong></p>
<ul>
<li>애플리케이션 정상</li>
<li>Outbox 쌓임</li>
<li>Publisher 프로세스 다운</li>
</ul>
<p><strong>[해결 전략]</strong></p>
<ul>
<li>재기동 후 미처리 row 재전송 필요</li>
</ul>
</br>

<h3 id="🔸-outbox-재처리-흐름">🔸 Outbox 재처리 흐름</h3>
<pre><code class="language-js">1. READY 조회
2. PROCESSING 변경 (락 확보)
3. Kafka 전송 시도
4. 성공 → SENT
5. 실패 → READY (retry_count++)
</code></pre>
</br>

<h3 id="🔸-outbox의-동시성-제어-전략">🔸 outbox의 동시성 제어 전략</h3>
<p>멀티 인스턴스 환경에서는 같은 row를 여러 Publisher가 가져가면 안 된다.</p>
<h3 id="해결방법-1-db-row-lock-pessimistic-lock">해결방법 1. <strong>DB Row Lock (Pessimistic Lock)</strong></h3>
<pre><code class="language-sql">SELECT *
FROM outbox
WHERE status = &#39;READY&#39;
ORDER BY created_at
FOR UPDATE SKIP LOCKED
LIMIT 100;
</code></pre>
<p>동시에 여러 인스턴스가 실행해도 중복 처리 방지</p>
<ul>
<li>조회와 동시에 row-level lock 획득</li>
<li>다른 트랜잭션은 해당 row 접근 불가</li>
<li><code>SKIP LOCKED</code> → 이미 잡힌 row는 건너뜀</li>
</ul>
<h4 id="장점">장점</h4>
<ul>
<li>구현이 직관적</li>
<li>멀티 인스턴스 환경에서 안전</li>
<li>충돌 시 자동 회피</li>
</ul>
<h4 id="단점">단점</h4>
<ul>
<li>DB 락 경합 가능</li>
<li>처리 시간이 길면 lock 유지 시간 증가</li>
<li>트랜잭션 관리 신중해야 함</li>
</ul>
</br>

<h3 id="해결방법-2-상태-전이-기반-lock-optimistic-방식">해결방법 2. <strong>상태 전이 기반 Lock (Optimistic 방식)</strong></h3>
<pre><code class="language-sql">UPDATE outbox
SET status = &#39;PROCESSING&#39;
WHERE id = ?
AND status = &#39;READY&#39;;
</code></pre>
<p>update count == 1 이면 성공적으로 점유</p>
<ul>
<li>READY row 조회</li>
<li>UPDATE로 상태 변경 시도</li>
<li>성공한 인스턴스만 처리</li>
</ul>
<h4 id="장점-1">장점</h4>
<ul>
<li>DB lock 유지 시간 짧음</li>
<li>대량 처리에 유리</li>
<li>확장성 좋음</li>
</ul>
<h4 id="단점-1">단점</h4>
<ul>
<li>충돌 시 재시도 로직 필요</li>
<li>상태 꼬임 가능 (예: 장애 중단)</li>
</ul>
<hr>
</br>

<h2 id="🔹-rabbitmq와-비교">🔹 RabbitMQ와 비교</h2>
<table>
<thead>
<tr>
<th>항목</th>
<th align="center"><strong>Kafka</strong></th>
<th align="center"><strong>RabbitMQ</strong></th>
</tr>
</thead>
<tbody><tr>
<td>구조</td>
<td align="center">로그 기반 분산 스트리밍</td>
<td align="center">큐 기반 메시지 브로커</td>
</tr>
<tr>
<td>메시지 순서</td>
<td align="center">파티션 단위 순서 보장</td>
<td align="center">큐 단위로 메시지 순서 보장</td>
</tr>
<tr>
<td>처리 목적</td>
<td align="center">대규모 데이터 스트림 (실시간 분석)</td>
<td align="center">트랜잭션성 메시징 (요청-응답, 워크큐 등)</td>
</tr>
<tr>
<td>저장 방식</td>
<td align="center">디스크 로그에 저장 (내구성 높음)</td>
<td align="center">메모리 중심 (빠름, 하지만 유실 가능성 있음)</td>
</tr>
<tr>
<td>소비 방식</td>
<td align="center">Consumer Group 단위로 병렬 처리</td>
<td align="center">각 큐에 한 소비자 그룹만 처리</td>
</tr>
<tr>
<td>사용 예</td>
<td align="center">로그, 이벤트, IoT, 데이터 파이프라인</td>
<td align="center">주문 처리, 이메일 발송, 알림 등</td>
</tr>
</tbody></table>
</br>

]]></description>
        </item>
        <item>
            <title><![CDATA[12/29]]></title>
            <link>https://velog.io/@jess_kim/1229</link>
            <guid>https://velog.io/@jess_kim/1229</guid>
            <pubDate>Mon, 29 Dec 2025 01:30:29 GMT</pubDate>
            <description><![CDATA[<h2 id="🔹-kafka-자세히-알아보기">🔹 Kafka 자세히 알아보기</h2>
<p><img src="https://www.tutorialspoint.com/apache_kafka/images/cluster_architecture.jpg" alt="Image"></p>
<p>Kafka는 단순한 메시지 큐가 아니라 <strong>분산 로그 시스템</strong>이다.
그래서 내부 구성요소들이 “고성능 · 장애복구 · 확장성”을 중심으로 설계되어 있다.</p>
<hr>
</br>

<h2 id="🔹-broker-브로커">🔹 Broker (브로커)</h2>
<h3 id="🔸-정의">🔸 정의</h3>
<ul>
<li>Kafka 서버 <strong>한 대</strong></li>
<li>메시지를 <strong>저장하고 전달하는 역할</strong></li>
<li>여러 Broker가 모여 <strong>Kafka Cluster</strong>를 구성</li>
</ul>
</br>

<h3 id="🔸-broker의-핵심-책임">🔸 Broker의 핵심 책임</h3>
<table>
<thead>
<tr>
<th>기능</th>
<th>설명</th>
</tr>
</thead>
<tbody><tr>
<td>메시지 저장</td>
<td>Topic → Partition 단위로 디스크에 로그 저장</td>
</tr>
<tr>
<td>Producer 요청 처리</td>
<td>메시지 수신</td>
</tr>
<tr>
<td>Consumer 요청 처리</td>
<td>메시지 전달</td>
</tr>
<tr>
<td>Replica 관리</td>
<td>다른 Broker와 데이터 복제</td>
</tr>
</tbody></table>
<p>📌 <strong>중요 포인트</strong></p>
<ul>
<li>Kafka는 <strong>메모리 큐가 아니라 디스크 로그</strong></li>
<li>Broker 장애 시에도 데이터 유지 가능</li>
</ul>
<hr>
</br>

<h2 id="🔹-topic--partition">🔹 Topic &amp; Partition</h2>
<p><img src="https://media.geeksforgeeks.org/wp-content/uploads/20220713194453/kafka1.png" alt="Image"></p>
<h3 id="🔸-topic">🔸 Topic</h3>
<ul>
<li>메시지의 논리적 분류 단위</li>
<li>예: <code>order-created</code>, <code>payment-completed</code></li>
</ul>
</br>

<h3 id="🔸-partition">🔸 Partition</h3>
<ul>
<li>Topic을 쪼갠 <strong>물리적 단위</strong></li>
<li>병렬 처리와 확장성을 담당</li>
</ul>
</br>

<h3 id="🔸-핵심-특징">🔸 핵심 특징</h3>
<table>
<thead>
<tr>
<th>항목</th>
<th>설명</th>
</tr>
</thead>
<tbody><tr>
<td>순서 보장</td>
<td><strong>Partition 내부에서만 순서 보장</strong></td>
</tr>
<tr>
<td>병렬 처리</td>
<td>Partition 수만큼 Consumer 병렬 처리 가능</td>
</tr>
<tr>
<td>확장성</td>
<td>Partition 증가 = 처리량 증가</td>
</tr>
</tbody></table>
<p>📌 <strong>실무 핵심</strong></p>
<ul>
<li>Kafka에서 <strong>성능 = Partition 수</strong></li>
<li>단, Partition 늘리면 <strong>순서 보장 범위는 더 좁아짐</strong></li>
</ul>
<hr>
</br>

<h2 id="🔹-replica--isr-복제와-장애-복구">🔹 Replica &amp; ISR (복제와 장애 복구)</h2>
<p><img src="https://images.ctfassets.net/gt6dp23g0g38/6P0oOJdQ8gJkU0ib014amg/3074980c72714d158fea435866283388/Kafka_Internals_046.png" alt="Image"></p>
<h3 id="🔸-replica">🔸 Replica</h3>
<ul>
<li>Partition의 복사본</li>
<li>여러 Broker에 분산 저장</li>
</ul>
</br>

<h3 id="🔸-leader--follower">🔸 Leader / Follower</h3>
<table>
<thead>
<tr>
<th>역할</th>
<th>설명</th>
</tr>
</thead>
<tbody><tr>
<td>Leader</td>
<td>Producer/Consumer와 직접 통신</td>
</tr>
<tr>
<td>Follower</td>
<td>Leader 데이터를 복제</td>
</tr>
</tbody></table>
</br>

<h3 id="🔸-isr-in-sync-replicas">🔸 ISR (In-Sync Replicas)</h3>
<ul>
<li><strong>Leader와 동기화 상태인 Replica 집합</strong></li>
<li>ISR에 속한 Replica만 Leader 승격 가능</li>
</ul>
<p>📌 <strong>왜 중요한가?</strong></p>
<ul>
<li>Leader Broker 장애 → ISR 중 하나가 <strong>즉시 Leader 승격</strong></li>
<li>데이터 유실 없이 서비스 지속 가능</li>
</ul>
<hr>
</br>

<h2 id="🔹-controller-클러스터의-두뇌">🔹 Controller (클러스터의 두뇌)</h2>
<h3 id="🔸-정의-1">🔸 정의</h3>
<ul>
<li>Kafka Cluster에서 <strong>단 하나만 존재</strong></li>
<li>Broker 중 하나가 Controller 역할 수행</li>
</ul>
</br>

<h3 id="🔸-controller의-역할">🔸 Controller의 역할</h3>
<table>
<thead>
<tr>
<th>기능</th>
<th>설명</th>
</tr>
</thead>
<tbody><tr>
<td>Broker 상태 관리</td>
<td>Broker 다운/복구 감지</td>
</tr>
<tr>
<td>Leader 선출</td>
<td>Partition Leader 재선출</td>
</tr>
<tr>
<td>ISR 관리</td>
<td>Replica 동기화 상태 관리</td>
</tr>
<tr>
<td>메타데이터 관리</td>
<td>Topic, Partition 정보 유지</td>
</tr>
</tbody></table>
<p>📌 <strong>Controller 장애 시</strong></p>
<ul>
<li>다른 Broker가 <strong>자동으로 Controller 승격</strong></li>
<li>서비스 중단 없음</li>
</ul>
<hr>
</br>

<h2 id="🔹-zookeeper-vs-kraft-raft-기반">🔹 Zookeeper vs KRaft (RAFT 기반)</h2>
<p><img src="https://www.xenonstack.com/hubfs/kafka-zooKeeper-architecture.png" alt="Image"></p>
<p><img src="https://docs.confluent.io/platform/current/_images/KRaft-isolated-mode.png" alt="Image"></p>
<h3 id="🔸-과거-zookeeper-기반">🔸 과거: Zookeeper 기반</h3>
<p><strong>Zookeeper가 담당했던 것</strong></p>
<ul>
<li>Broker 등록</li>
<li>Controller 선출</li>
<li>Topic / Partition 메타데이터 저장</li>
</ul>
<p>❌ 문제점</p>
<ul>
<li>운영 복잡도 ↑</li>
<li>Kafka + Zookeeper 이중 관리</li>
<li>확장성 한계</li>
</ul>
</br>

<h3 id="🔸-현재미래-kraft-kafka-raft">🔸 현재/미래: KRaft (Kafka Raft)</h3>
<p>Kafka 2.8+부터 도입된 <strong>Zookeeper 제거 아키텍처</strong></p>
<table>
<thead>
<tr>
<th>항목</th>
<th>Zookeeper</th>
<th>KRaft</th>
</tr>
</thead>
<tbody><tr>
<td>메타데이터 저장</td>
<td>Zookeeper</td>
<td>Kafka 내부</td>
</tr>
<tr>
<td>합의 알고리즘</td>
<td>ZAB</td>
<td><strong>RAFT</strong></td>
</tr>
<tr>
<td>운영 복잡도</td>
<td>높음</td>
<td>낮음</td>
</tr>
<tr>
<td>장애 포인트</td>
<td>2개</td>
<td>1개</td>
</tr>
</tbody></table>
<p>📌 <strong>실무 기준</strong></p>
<ul>
<li>신규 Kafka 클러스터 → <strong>KRaft 권장</strong></li>
<li>기존 운영 중 → 점진적 마이그레이션 고려</li>
</ul>
<hr>
</br>

<h2 id="🔹-producer-→-broker-내부-흐름">🔹 Producer → Broker 내부 흐름</h2>
<p><img src="https://images.ctfassets.net/8vofjvai1hpv/3UHKmM3EKx4uC7pQKu7DAE/b6733704bf885d6228eb476433b6378e/producer.png" alt="Image"></p>
<p><img src="https://miro.medium.com/v2/resize%3Afit%3A1400/0%2AOTTiofYHcwux6BCZ" alt="Image"></p>
<ol>
<li>Producer가 메시지 전송</li>
<li>Partition Leader가 메시지 저장</li>
<li>Follower Replica들이 복제</li>
<li><code>acks</code> 설정에 따라 Producer 응답</li>
</ol>
<h3 id="🔸-acks-옵션">🔸 acks 옵션</h3>
<table>
<thead>
<tr>
<th>값</th>
<th>의미</th>
</tr>
</thead>
<tbody><tr>
<td><code>acks=0</code></td>
<td>저장 확인 안 함 (빠르나 위험)</td>
</tr>
<tr>
<td><code>acks=1</code></td>
<td>Leader 저장만 확인</td>
</tr>
<tr>
<td><code>acks=all</code></td>
<td>ISR 전체 저장 확인 (가장 안전)</td>
</tr>
</tbody></table>
<hr>
</br>

<h2 id="🔹-consumer-group-내부-동작">🔹 Consumer Group 내부 동작</h2>
<p><img src="https://cdn.educba.com/academy/wp-content/uploads/2022/06/Kafka-Rebalance-768x427.jpg" alt="Image"></p>
<h3 id="🔸-consumer-group">🔸 Consumer Group</h3>
<ul>
<li>같은 Group ID를 가진 Consumer 묶음</li>
<li><strong>Partition은 Group 내에서 1 Consumer만 처리</strong></li>
</ul>
</br>

<h3 id="🔸-rebalance">🔸 Rebalance</h3>
<ul>
<li>Consumer 추가/제거 시</li>
<li>Partition 재분배 발생</li>
</ul>
<p>📌 <strong>주의</strong></p>
<ul>
<li>Rebalance 동안 메시지 소비 일시 중단</li>
<li>Consumer 설계 시 <strong>처리 시간 최소화</strong> 중요</li>
</ul>
<hr>
</br>

<h2 id="🔹-전체-구조-요약">🔹 전체 구조 요약</h2>
<pre><code>[Producer]
     ↓
[Partition Leader (Broker)]
     ↓ (Replica)
[Follower Broker]
     ↓
[Consumer Group]</code></pre></br>

<table>
<thead>
<tr>
<th>구성요소</th>
<th>핵심 역할</th>
</tr>
</thead>
<tbody><tr>
<td>Broker</td>
<td>메시지 저장 &amp; 전달</td>
</tr>
<tr>
<td>Partition</td>
<td>성능 &amp; 순서 단위</td>
</tr>
<tr>
<td>Replica / ISR</td>
<td>장애 복구</td>
</tr>
<tr>
<td>Controller</td>
<td>클러스터 제어</td>
</tr>
<tr>
<td>KRaft</td>
<td>메타데이터 합의</td>
</tr>
</tbody></table>
<hr>
</br>

<h2 id="🔹-실무에서-꼭-기억할-포인트">🔹 실무에서 꼭 기억할 포인트</h2>
<ol>
<li><strong>Kafka = 분산 로그 시스템</strong></li>
<li>성능 튜닝의 핵심은 <strong>Partition 설계</strong></li>
<li>장애 대응은 <strong>Replica + ISR</strong></li>
<li>최신 Kafka는 <strong>Zookeeper 제거(KRaft)</strong> 방향</li>
<li>이벤트 기반 아키텍처의 핵심 인프라</li>
</ol>
]]></description>
        </item>
        <item>
            <title><![CDATA[12/26]]></title>
            <link>https://velog.io/@jess_kim/1226</link>
            <guid>https://velog.io/@jess_kim/1226</guid>
            <pubDate>Fri, 26 Dec 2025 00:38:54 GMT</pubDate>
            <description><![CDATA[<h1 id="낙관적-락-vs-비관적-락">낙관적 락 vs 비관적 락</h1>
<p>(DB 관점)</p>
</br>

<h2 id="🔹-낙관적-락-optimistic-lock">🔹 낙관적 락 (Optimistic Lock)</h2>
<h3 id="🔸-개념">🔸 개념</h3>
<ul>
<li><strong>충돌이 드물다</strong>고 가정</li>
<li>데이터를 읽을 때는 락을 걸지 않고, <strong>업데이트 시점에 변경 여부를 검증</strong></li>
<li>주로 <strong>버전(version) 컬럼</strong> 또는 <strong>타임스탬프</strong>로 검증</li>
</ul>
</br>

<h3 id="🔸-동작-방식">🔸 동작 방식</h3>
<ol>
<li>트랜잭션 A/B가 같은 row를 읽음</li>
<li>A가 먼저 수정 → version 증가</li>
<li>B가 수정 시도 → version 불일치 → <strong>업데이트 실패</strong></li>
</ol>
<p><img src="https://systemdesignschool.io/blog/optimistic-locking/optimistic-locking-conflict.png" alt="Image"></p>
</br>

<h3 id="🔸-예시-jpa">🔸 예시 (JPA)</h3>
<pre><code class="language-java">@Version
private Long version;</code></pre>
</br>

<h3 id="🔸-장점">🔸 장점</h3>
<ul>
<li>락 대기 없음 → <strong>성능 우수</strong></li>
<li>데드락 위험 거의 없음</li>
<li>읽기 중심 시스템에 적합</li>
</ul>
</br>

<h3 id="🔸-단점">🔸 단점</h3>
<ul>
<li>충돌 시 <strong>재시도 로직 필요</strong></li>
<li>충돌 빈도가 높으면 오히려 비용 증가</li>
</ul>
</br>

<h3 id="🔸-적합한-상황">🔸 적합한 상황</h3>
<ul>
<li>트래픽은 많지만 <strong>동시 수정은 드문 경우</strong></li>
<li>조회 위주의 서비스</li>
<li>REST API, 이벤트 기반 처리, MSA 환경</li>
</ul>
<hr>
</br>

<h2 id="🔹-비관적-락-pessimistic-lock">🔹 비관적 락 (Pessimistic Lock)</h2>
<h3 id="🔸-개념-1">🔸 개념</h3>
<ul>
<li><strong>충돌이 자주 발생한다</strong>고 가정</li>
<li>데이터를 읽는 순간부터 <strong>락을 선점</strong></li>
<li>다른 트랜잭션은 대기 또는 실패</li>
</ul>
</br>

<h3 id="🔸-동작-방식-1">🔸 동작 방식</h3>
<ol>
<li>트랜잭션 A가 row 조회 + 락 획득</li>
<li>B는 해당 row 접근 시 <strong>대기(block)</strong> 또는 실패</li>
<li>A 커밋/롤백 후 락 해제</li>
</ol>
</br>

<h3 id="🔸-예시-jpa-1">🔸 예시 (JPA)</h3>
<pre><code class="language-java">@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query(&quot;select o from Order o where o.id = :id&quot;)
Order findForUpdate(Long id);</code></pre>
</br>

<h3 id="🔸-장점-1">🔸 장점</h3>
<ul>
<li><strong>데이터 정합성 강력 보장</strong></li>
<li>충돌 처리 로직 단순</li>
</ul>
</br>

<h3 id="🔸-단점-1">🔸 단점</h3>
<ul>
<li>락 대기 → <strong>성능 저하</strong></li>
<li>데드락 가능성</li>
<li>트랜잭션 길어지면 시스템 병목</li>
</ul>
</br>

<h3 id="🔸-적합한-상황-1">🔸 적합한 상황</h3>
<ul>
<li><strong>동시 수정이 빈번</strong></li>
<li>금액, 재고, 좌석, 포인트 등 <strong>절대 정확해야 하는 데이터</strong></li>
<li>짧은 트랜잭션이 보장될 때</li>
</ul>
<hr>
</br>

<h2 id="🔹-핵심-비교-표">🔹 핵심 비교 표</h2>
<table>
<thead>
<tr>
<th>항목</th>
<th>낙관적 락</th>
<th>비관적 락</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>
<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>
<hr>
</br>

<h2 id="🔹-실무-선택-기준-중요">🔹 실무 선택 기준 (중요)</h2>
<h3 id="🔸-낙관적-락을-선택해야-할-때">🔸 낙관적 락을 선택해야 할 때</h3>
<ul>
<li>평균적으로 <strong>동시 수정 확률 &lt; 1%</strong></li>
<li>API 요청이 많고 응답 지연이 민감</li>
<li>재시도가 허용되는 도메인</li>
</ul>
</br>

<h3 id="🔸-비관적-락을-선택해야-할-때">🔸 비관적 락을 선택해야 할 때</h3>
<ul>
<li><strong>절대 중복/오류가 발생하면 안 됨</strong></li>
<li>충돌이 빈번하고 재시도가 비용 큼</li>
<li>트랜잭션이 매우 짧음</li>
</ul>
<hr>
</br>

<h2 id="🔹-실무에서-자주-쓰는-패턴">🔹 실무에서 자주 쓰는 패턴</h2>
<ul>
<li><p><strong>기본은 낙관적 락</strong></p>
</li>
<li><p>정말 중요한 구간만 <strong>비관적 락으로 국소 적용</strong></p>
</li>
<li><p>MSA 환경에서는 DB 락보다</p>
<ul>
<li>Redis 분산 락</li>
<li>메시지 큐 직렬화</li>
<li>Saga / Outbox 패턴
을 함께 고려</li>
</ul>
</li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[12/24]]></title>
            <link>https://velog.io/@jess_kim/1224</link>
            <guid>https://velog.io/@jess_kim/1224</guid>
            <pubDate>Wed, 24 Dec 2025 03:49:56 GMT</pubDate>
            <description><![CDATA[<h2 id="🔹-적용-기술-구조-요약-정리">🔹 적용 기술 구조 요약 정리</h2>
<p><img src="https://velog.velcdn.com/images/jess_kim/post/c8c78dd6-17f7-4399-8e7b-2a7b45aedb16/image.png" alt=""></p>
<p><img src="https://velog.velcdn.com/images/jess_kim/post/fcafb32f-ecae-4e17-8505-30e81c74c532/image.png" alt=""></p>
<hr>
<p><img src="https://velog.velcdn.com/images/jess_kim/post/920a9238-d80e-4b75-a375-d865c0df8713/image.png" alt=""></p>
<hr>
<p><img src="https://velog.velcdn.com/images/jess_kim/post/210261ea-6c82-4573-857e-c0d00668c2f8/image.png" alt=""></p>
<p><img src="https://velog.velcdn.com/images/jess_kim/post/60541ac0-4471-4b84-a859-10acd004907c/image.png" alt=""></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[12/23]]></title>
            <link>https://velog.io/@jess_kim/1223</link>
            <guid>https://velog.io/@jess_kim/1223</guid>
            <pubDate>Tue, 23 Dec 2025 02:39:47 GMT</pubDate>
            <description><![CDATA[<h2 id="🔹-outbox-패턴-적용-repository">🔹 outbox 패턴 적용 Repository</h2>
<h3 id="🔸-jpaorderoutboxeventrepository">🔸 JpaOrderOutboxEventRepository</h3>
<pre><code class="language-java">package chill_logistics.order_server.infrastructure.repository;

import chill_logistics.order_server.domain.entity.OrderOutboxEvent;
import chill_logistics.order_server.domain.entity.OrderOutboxStatus;
import jakarta.persistence.LockModeType;
import java.util.List;
import java.util.UUID;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Lock;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;

public interface JpaOrderOutboxEventRepository extends JpaRepository&lt;OrderOutboxEvent, UUID&gt; {

    /* 처리 대상으로 가져와서 락 거는 목적 용도 (PENDING + Lock + 오래된 순) */
    @Lock(LockModeType.PESSIMISTIC_WRITE)
    @Query(&quot;SELECT e FROM OrderOutboxEvent e &quot; +
           &quot;WHERE e.orderOutboxStatus = :status &quot; +
           &quot;ORDER BY e.createdAt ASC&quot;)
    List&lt;OrderOutboxEvent&gt; findPendingEventsForUpdate(
        @Param(&quot;status&quot;) OrderOutboxStatus status,
        Pageable pageable);

    /* 검색/관리용 (status/orderId 조건 검색) */
    @Query(&quot;SELECT e FROM OrderOutboxEvent e &quot; +
           &quot;WHERE (:status IS NULL OR e.orderOutboxStatus = :status) &quot; +
           &quot;AND (:orderId IS NULL OR e.orderId = :orderId) &quot; +
           &quot;ORDER BY e.createdAt ASC&quot;)
    List&lt;OrderOutboxEvent&gt; findOutboxEvents(
        @Param(&quot;status&quot;) OrderOutboxStatus status,
        @Param(&quot;orderId&quot;) UUID orderId,
        Pageable pageable);

    /* FAILED 모니터링/조회용 */
    @Query(&quot;SELECT e FROM OrderOutboxEvent e &quot; +
           &quot;WHERE e.orderOutboxStatus = &#39;FAILED&#39; &quot; +
           &quot;ORDER BY e.lastRetryAt DESC, e.createdAt DESC&quot;)
    List&lt;OrderOutboxEvent&gt; findFailedEvents(Pageable pageable);
}
</code></pre>
</br>

<h3 id="🔸-orderoutboxeventrepositoryadapter">🔸 OrderOutboxEventRepositoryAdapter</h3>
<pre><code class="language-java">package chill_logistics.order_server.infrastructure.repository;

import chill_logistics.order_server.domain.entity.OrderOutboxEvent;
import chill_logistics.order_server.domain.entity.OrderOutboxStatus;
import chill_logistics.order_server.domain.repository.OrderOutboxEventRepository;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.PageRequest;

@RequiredArgsConstructor
public class OrderOutboxEventRepositoryAdapter implements OrderOutboxEventRepository {

    private final JpaOrderOutboxEventRepository jpaRepository;

    @Override
    public OrderOutboxEvent save(OrderOutboxEvent event) {
        return jpaRepository.save(event);
    }

    @Override
    public Optional&lt;OrderOutboxEvent&gt; findById(UUID id) {
        return jpaRepository.findById(id);
    }

    @Override
    public List&lt;OrderOutboxEvent&gt; findPendingEvents(OrderOutboxStatus status, int batchSize) {
        return jpaRepository.findPendingEventsForUpdate(status, PageRequest.of(0, batchSize));
    }

    @Override
    public List&lt;OrderOutboxEvent&gt; findOutboxEvents(OrderOutboxStatus status, UUID orderId, int page, int size) {
        return jpaRepository.findOutboxEvents(status, orderId, PageRequest.of(page, size));
    }

    @Override
    public List&lt;OrderOutboxEvent&gt; findFailedEvents(int page, int size) {
        return jpaRepository.findFailedEvents(PageRequest.of(page, size));
    }
}</code></pre>
</br>

<h3 id="🔸-orderoutboxeventrepository">🔸 OrderOutboxEventRepository</h3>
<pre><code class="language-java">package chill_logistics.order_server.domain.repository;

import chill_logistics.order_server.domain.entity.OrderOutboxEvent;
import chill_logistics.order_server.domain.entity.OrderOutboxStatus;
import java.util.List;
import java.util.Optional;
import java.util.UUID;

public interface OrderOutboxEventRepository {

    OrderOutboxEvent save(OrderOutboxEvent orderOutboxEvent);

    Optional&lt;OrderOutboxEvent&gt; findById(UUID orderOutboxEventId);

    List&lt;OrderOutboxEvent&gt; findPendingEvents(OrderOutboxStatus status, int batchSize);

    List&lt;OrderOutboxEvent&gt; findOutboxEvents(OrderOutboxStatus status, UUID orderId, int page, int size);

    List&lt;OrderOutboxEvent&gt; findFailedEvents(int page, int size);
}</code></pre>
]]></description>
        </item>
        <item>
            <title><![CDATA[12/22]]></title>
            <link>https://velog.io/@jess_kim/1222</link>
            <guid>https://velog.io/@jess_kim/1222</guid>
            <pubDate>Mon, 22 Dec 2025 02:42:59 GMT</pubDate>
            <description><![CDATA[<h2 id="🔹-outbox-패턴-적용-리팩터링">🔹 Outbox 패턴 적용 리팩터링</h2>
<h3 id="🔸-orderoutboxevent">🔸 OrderOutboxEvent</h3>
<pre><code class="language-java">package chill_logistics.order_server.domain.entity;

import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.EntityListeners;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import java.time.Duration;
import java.time.LocalDateTime;
import java.util.UUID;
import lib.entity.BaseEntity;
import lombok.AccessLevel;
import lombok.Getter;
import lombok.NoArgsConstructor;
import org.hibernate.annotations.GenericGenerator;
import org.springframework.data.jpa.domain.support.AuditingEntityListener;

@Getter
@Entity
@Table(name = &quot;p_order_outbox_event&quot;)
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@EntityListeners(AuditingEntityListener.class)
public class OrderOutboxEvent extends BaseEntity {

    @Id
    @GeneratedValue(generator = &quot;uuidv7&quot;)
    @GenericGenerator(
        name = &quot;uuidv7&quot;,
        strategy = &quot;lib.id.UUIDv7Generator&quot;
    )
    @Column(name = &quot;id&quot;, columnDefinition = &quot;BINARY(16)&quot;)
    private UUID id;

    @Column(name = &quot;order_id&quot;, nullable = false, columnDefinition = &quot;BINARY(16)&quot;)
    private UUID orderId;

    @Column(name = &quot;event_type&quot;, nullable = false, length = 100)
    private String eventType;

    @Column(name = &quot;payload&quot;, nullable = false, columnDefinition = &quot;TEXT&quot;)
    private String payload;

    @Enumerated(EnumType.STRING)
    @Column(name = &quot;order_outbox_status&quot;, nullable = false, length = 30)
    private OrderOutboxStatus orderOutboxStatus;

    @Column(name = &quot;retry_count&quot;, nullable = false)
    private int retryCount;

    @Column(name = &quot;published_at&quot;)
    private LocalDateTime publishedAt;

    @Column(name = &quot;last_retry_at&quot;)
    private LocalDateTime lastRetryAt;

    private static final int MAX_RETRY_COUNT = 3;
    private static final long MAX_BACKOFF_MILLIS = 100L;

    private OrderOutboxEvent(UUID orderId, String eventType, String payload) {
        this.orderId = orderId;
        this.eventType = eventType;
        this.payload = payload;
        this.orderOutboxStatus = OrderOutboxStatus.PENDING;
        this.retryCount = 0;
    }

    public static OrderOutboxEvent create(UUID orderId, String eventType, String payload) {
        return new OrderOutboxEvent(orderId, eventType, payload);
    }

    /* 이미 처리 되었는지 */
    public boolean alreadyHandled() {
        return this.orderOutboxStatus == OrderOutboxStatus.PUBLISHED
            || this.orderOutboxStatus == OrderOutboxStatus.FAILED;
    }

    /* 재시도 해도 되는지 */
    public boolean canRetry() {
        return this.orderOutboxStatus == OrderOutboxStatus.PENDING
            &amp;&amp; this.retryCount &lt; MAX_RETRY_COUNT;
    }

    /* 지금 재시도 할 준비 되었는지 */
    public boolean readyToRetry() {

        if (!canRetry()) {
            return false;
        }

        // 첫 시도는 즉시 성공
        if (this.lastRetryAt == null) {
            return true;
        }

        long timeSinceLastRetryMillis =
            Duration.between(this.lastRetryAt, LocalDateTime.now()).toMillis();

        return timeSinceLastRetryMillis &gt;= MAX_BACKOFF_MILLIS;
    }

    /* 성공 확정 */
    public void markPublished() {
        this.orderOutboxStatus = OrderOutboxStatus.PUBLISHED;
        this.publishedAt = LocalDateTime.now();
    }

    /* 실패 처리 */
    public void markFailed() {
        this.retryCount++;
        this.lastRetryAt = LocalDateTime.now();

        // 최대 초과 시 FAILED
        if (this.retryCount &gt;= MAX_RETRY_COUNT) {
            this.orderOutboxStatus = OrderOutboxStatus.FAILED;
        }
    }
}
</code></pre>
</br>

<h3 id="🔸-kafkaproducerconfig">🔸 KafkaProducerConfig</h3>
<pre><code class="language-java">package chill_logistics.order_server.infrastructure.config;

import java.util.HashMap;
import java.util.Map;
import org.apache.kafka.clients.producer.ProducerConfig;
import org.apache.kafka.common.serialization.StringSerializer;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.kafka.core.DefaultKafkaProducerFactory;
import org.springframework.kafka.core.KafkaTemplate;
import org.springframework.kafka.core.ProducerFactory;

@Configuration
public class KafkaProducerConfig {

    @Bean
    public ProducerFactory&lt;String, String&gt; outboxProducerFactory() {

        Map&lt;String, Object&gt; config = new HashMap&lt;&gt;();

        config.put(ProducerConfig.BOOTSTRAP_SERVERS_CONFIG, &quot;localhost:9092&quot;);
        config.put(ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG, StringSerializer.class);
        config.put(ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG, StringSerializer.class);

        // Outbox 패턴에서 권장되는 설정
        config.put(ProducerConfig.ACKS_CONFIG, &quot;all&quot;);
        config.put(ProducerConfig.ENABLE_IDEMPOTENCE_CONFIG, true);
        config.put(ProducerConfig.MAX_IN_FLIGHT_REQUESTS_PER_CONNECTION, 5);

        return new DefaultKafkaProducerFactory&lt;&gt;(config);
    }

    @Bean
    public KafkaTemplate&lt;String, String&gt; outboxKafkaTemplate() {
        return new KafkaTemplate&lt;&gt;(outboxProducerFactory());
    }
}
</code></pre>
</br>

<h3 id="🔸-orderoutboxkafkaproducer">🔸 OrderOutboxKafkaProducer</h3>
<pre><code class="language-java">package chill_logistics.order_server.infrastructure.kafka.outbox;

import chill_logistics.order_server.domain.event.OutboxEventProducer;
import chill_logistics.order_server.infrastructure.kafka.KafkaPassportProducerSupport;
import chill_logistics.order_server.lib.error.ErrorCode;
import java.util.concurrent.CompletableFuture;
import lib.passport.PassportIssuer;
import lib.web.error.BusinessException;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.kafka.clients.producer.ProducerRecord;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.kafka.core.KafkaTemplate;
import org.springframework.kafka.support.SendResult;
import org.springframework.stereotype.Service;

@Slf4j
@Service
@RequiredArgsConstructor
public class OrderOutboxKafkaProducer implements OutboxEventProducer {

    private final KafkaTemplate&lt;String, String&gt; kafkaTemplate;
    private final PassportIssuer passportIssuer;

    @Value(&quot;${app.kafka.topic.order-after-create}&quot;)
    private String orderAfterCreateTopic;

    @Value(&quot;${app.kafka.topic.order-canceled}&quot;)
    private String orderCanceledTopic;

    @Override
    public CompletableFuture&lt;SendResult&lt;String, String&gt;&gt; publish(String eventType, String key, String payload) {

        String topic = resolveTopic(eventType);

        ProducerRecord&lt;String, String&gt; record = new ProducerRecord&lt;&gt;(topic, key, payload);

        // ✅ passport/user/trace 헤더 삽입
        KafkaPassportProducerSupport.writeHeaders(record.headers(), passportIssuer);

        return kafkaTemplate.send(record);
    }

    private String resolveTopic(String eventType) {

        return switch (eventType) {
            case &quot;OrderAfterCreateV1&quot; -&gt; orderAfterCreateTopic;
            case &quot;OrderCanceledV1&quot; -&gt; orderCanceledTopic;
            default -&gt; throw new BusinessException(ErrorCode.UNKNOWN_EVENT_TYPE);
        };
    }
}</code></pre>
</br>

<h3 id="🔸-orderoutboxprocessor">🔸 OrderOutboxProcessor</h3>
<pre><code class="language-java">package chill_logistics.order_server.infrastructure.kafka.outbox;

import chill_logistics.order_server.domain.entity.OrderOutboxEvent;
import chill_logistics.order_server.domain.entity.OrderOutboxStatus;
import java.util.List;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.domain.PageRequest;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;

@Slf4j
@Component
@RequiredArgsConstructor
public class OrderOutboxProcessor {

    private static final int BATCH_SIZE = 200;                 // 한 번의 process 에 처리할 이벤트 건 수
    private static final long FIXED_DELAY_MS = 500;            // Processor 스케줄러 실행 주기

    private final OrderOutboxEventRepository outboxEventRepository;
    private final OutboxEventTransactionManager transactionManager;

    @Scheduled(fixedDelay = FIXED_DELAY_MS)
    public void publishPendingEvents() {

        // 상태가 PENDING인 이벤트만 조회 &amp; createdAt 기준 오래된 이벤트부터 처리
        List&lt;OrderOutboxEvent&gt; eventList = outboxEventRepository.findPendingEvents(
            OrderOutboxStatus.PENDING,
            PageRequest.of(0, BATCH_SIZE)
        );

        if (eventList.isEmpty()) {
            return;
        }

        int processed = 0;
        int skipped = 0;

        for (OrderOutboxEvent event : eventList) {

            // 이미 처리되었거나, 재시도 조건 충족 안 되면 스킵
            if (event.alreadyHandled() || !event.readyToRetry()) {
                skipped++;
                continue;
            }

            // 이벤트 1건 → REQUIRES_NEW 트랜잭션에서 처리
            transactionManager.publishEvent(event.getId());
            processed++;
        }

        log.info(&quot;[OUTBOX 처리 완료] 조회건수={} 처리건수={} 스킵건수={}&quot;,
            eventList.size(), processed, skipped);
    }
}</code></pre>
</br>

<h3 id="🔸-outboxeventtransactionmanager">🔸 OutboxEventTransactionManager</h3>
<pre><code class="language-java">package chill_logistics.order_server.infrastructure.kafka.outbox;

import chill_logistics.order_server.domain.entity.OrderOutboxEvent;
import chill_logistics.order_server.domain.event.OutboxEventProducer;
import chill_logistics.order_server.lib.error.ErrorCode;
import java.util.UUID;
import java.util.concurrent.TimeUnit;
import lib.web.error.BusinessException;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;

@Slf4j
@Service
@RequiredArgsConstructor
public class OutboxEventTransactionManager {

    private static final long SEND_CONFIRM_TIMEOUT_MS = 3000;  // Kafka 전송 요청 보낸 뒤, 성공 응답 기다리는 최대 시간

    private final OrderOutboxEventRepository outboxEventRepository;
    private final OutboxEventProducer outboxEventProducer;

    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void publishEvent(UUID outboxId) {

        OrderOutboxEvent event = outboxEventRepository.findById(outboxId)
            .orElseThrow(() -&gt; new BusinessException(ErrorCode.ORDER_OUTBOX_EVENT_NOT_FOUND));

        // 이미 처리되었거나, 재시도 조건 충족 안 되면 종료
        if (event.alreadyHandled() || !event.readyToRetry()) {
            return;
        }

        String key = event.getOrderId().toString();

        try {
            // Kafka 전송 + 성공 확정 대기
            outboxEventProducer
                .publish(event.getEventType(), key, event.getPayload())
                .get(SEND_CONFIRM_TIMEOUT_MS, TimeUnit.MILLISECONDS);

            // 전송 성공 처리
            event.markPublished();

            log.info(&quot;[OUTBOX 이벤트 발행 성공] outboxId={} eventType={} orderId={}&quot;,
                event.getId(), event.getEventType(), event.getOrderId());

        } catch (Exception ex) {
            // 전송 실패 처리 (retryCount 증가)
            event.markFailed();

            log.warn(&quot;[OUTBOX 이벤트 발행 실패] outboxId={} 재시도 횟수={}&quot;,
                event.getId(), event.getRetryCount(), ex);
        }
    }
}
</code></pre>
</br>

<h3 id="🔸-ordercommandservice">🔸 OrderCommandService</h3>
<pre><code class="language-java">package chill_logistics.order_server.application.service;

import chill_logistics.order_server.application.dto.command.CreateOrderCommandV1;
import chill_logistics.order_server.application.dto.command.CreateOrderResultV1;
import chill_logistics.order_server.application.dto.command.FirmInfoV1;
import chill_logistics.order_server.application.dto.command.FirmResultV1;
import chill_logistics.order_server.application.dto.command.OrderAfterCreateV1;
import chill_logistics.order_server.application.dto.command.OrderCanceledV1;
import chill_logistics.order_server.application.dto.command.OrderProductInfoV1;
import chill_logistics.order_server.application.dto.command.ProductResultV1;
import chill_logistics.order_server.application.dto.command.ReceiverInfoV1;
import chill_logistics.order_server.application.dto.command.SupplierInfoV1;
import chill_logistics.order_server.application.dto.command.UpdateOrderStatusCommandV1;
import chill_logistics.order_server.domain.entity.Order;
import chill_logistics.order_server.domain.entity.OrderOutboxEvent;
import chill_logistics.order_server.domain.entity.OrderProduct;
import chill_logistics.order_server.domain.entity.OrderQuery;
import chill_logistics.order_server.domain.entity.OrderStatus;
import chill_logistics.order_server.domain.port.FirmPort;
import chill_logistics.order_server.domain.port.HubPort;
import chill_logistics.order_server.domain.port.ProductPort;
import chill_logistics.order_server.domain.repository.OrderQueryRepository;
import chill_logistics.order_server.domain.repository.OrderRepository;
import chill_logistics.order_server.lib.error.ErrorCode;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.time.LocalDateTime;
import java.util.List;
import java.util.UUID;
import lib.entity.Role;
import lib.util.SecurityUtils;
import lib.web.error.BusinessException;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Slf4j
@Service
@RequiredArgsConstructor
public class OrderCommandService {

    private static final String EVENT_ORDER_AFTER_CREATE = &quot;OrderAfterCreateV1&quot;;
    private static final String EVENT_ORDER_CANCELED = &quot;OrderCanceledV1&quot;;

    private final OrderRepository orderRepository;
    private final OrderQueryRepository orderQueryRepository;
    private final ProductPort productPort;
    private final HubPort hubPort;
    private final FirmPort firmPort;
    private final OrderOutboxEventRepository outboxEventRepository;
    private final ObjectMapper objectMapper;

    private Order readOrderOrThrow(UUID orderId) {
        return orderRepository.findById(orderId)
                .orElseThrow(() -&gt; new BusinessException(ErrorCode.ORDER_NOT_FOUND));
    }

    // Outbox 이벤트 적재
    private void saveOutboxEvent(UUID orderId, String eventType, Object message) {

        final String payload;
        try {
            payload = objectMapper.writeValueAsString(message);

        } catch (JsonProcessingException e) {
            // Outbox payload 직렬화 실패는 주문 트랜잭션 자체를 실패시키는 게 일반적으로 안전
            log.error(&quot;[OUTBOX payload 직렬화 실패] orderId={} eventType={}&quot;, orderId, eventType, e);

            throw new BusinessException(ErrorCode.OUTBOX_PAYLOAD_SERIALIZATION_FAILED);
        }

        OrderOutboxEvent outboxEvent = OrderOutboxEvent.create(orderId, eventType, payload);

        outboxEventRepository.save(outboxEvent);

        log.info(&quot;[OUTBOX 이벤트 적재 완료] orderId={} eventType={} outboxId={}&quot;,
            orderId, eventType, outboxEvent.getId());
    }

    @Transactional
    public CreateOrderResultV1 createOrder(CreateOrderCommandV1 command) {

        // 업체 조회
        FirmResultV1 supplierResult = firmPort.readFirmById(command.supplierFirmId(), &quot;SUPPLIER&quot;);
        FirmResultV1 receiverResult = firmPort.readFirmById(command.receiverFirmId(), &quot;RECEIVER&quot;);

        // 주문 상품 체크 및 재고 감소
        List&lt;OrderProductInfoV1&gt; orderProductInfoList =
                command.productList()
                        .stream()
                        .map(p -&gt; {
                            // 상품 조회
                            ProductResultV1 product = productPort.readProductById(p.productId());

                            // 공급 업체 소속 상품인지 체크
                            if (!product.firmId().equals(command.supplierFirmId())) {
                                throw new BusinessException(ErrorCode.PRODUCT_NOT_FROM_FIRM);
                            }

                            // 상품 재고 체크
                            if (product.stockQuantity() &lt; p.quantity()) {
                                throw new BusinessException(ErrorCode.OUT_OF_STOCK);
                            }

                            // 상품 재고 감소
                            productPort.decreaseStock(p.productId(), p.quantity());

                            return new OrderProductInfoV1(
                                    p.productId(),
                                    product.name(),
                                    product.price(),
                                    p.quantity()
                            );
                        })
                        .toList();

        // 주문 생성
        Order order = Order.create(
                command.supplierFirmId(),
                command.receiverFirmId(),
                command.requestNote(),
                orderProductInfoList
        );

        Order createOrder = orderRepository.save(order);

        // 주문 생성 시 주문 읽기 생성
        // TODO: 추후 주문 읽기 전략 수정예정 (임시: 대표 상품)
        OrderQuery orderQuery = OrderQuery.create(
                createOrder,
                SupplierInfoV1.from(supplierResult),
                ReceiverInfoV1.from(receiverResult)
        );

        orderQueryRepository.save(orderQuery);

        // Kafka 메시지 생성 (즉시 발행하지 않음)
        OrderAfterCreateV1 message = new OrderAfterCreateV1(
                createOrder.getId(),
                supplierResult.hubId(),
                receiverResult.hubId(),
                createOrder.getReceiverFirmId(),
                receiverResult.firmFullAddress(),
                receiverResult.firmOwnerName(),
                createOrder.getRequestNote(),
                // TODO: 추후 주문 읽기 전략 수정예정 (임시: 대표 상품)
                createOrder.getOrderProductList().get(0).getProductName(),
                createOrder.getOrderProductList().get(0).getQuantity(),
                createOrder.getCreatedAt()
        );

        // Outbox 적재
        saveOutboxEvent(createOrder.getId(), EVENT_ORDER_AFTER_CREATE, message);

        return CreateOrderResultV1.from(
                createOrder,
                FirmInfoV1.from(supplierResult),
                FirmInfoV1.from(receiverResult)
        );
    }

    @Transactional
    public void updateOrderStatus(UUID id, UpdateOrderStatusCommandV1 command) {

        // 주문 조회
        Order order = readOrderOrThrow(id);

        // 담당 허브 소속 주문인지 체크
        if (SecurityUtils.hasRole(Role.HUB_MANAGER)) {
            List&lt;UUID&gt; managingHubId = hubPort.readHubId(SecurityUtils.getCurrentUserId());
            UUID receiverHubId = firmPort.readHubId(order.getReceiverFirmId());

            if (!managingHubId.contains(receiverHubId)) {
                throw new BusinessException(ErrorCode.ORDER_NOT_IN_MANAGING_HUB);
            }
        }

        // 상태 변경
        order.updateStatus(command.status());

        // TODO: 주문 읽기 업데이트 (OrderStatus)
    }

    @Transactional
    public void deleteOrder(UUID id) {

        // 주문 조회
        Order order = readOrderOrThrow(id);

        // 담당 허브 소속 주문인지 체크
        if (SecurityUtils.hasRole(Role.HUB_MANAGER)) {
            List&lt;UUID&gt; managingHubId = hubPort.readHubId(SecurityUtils.getCurrentUserId());
            UUID receiverHubId = firmPort.readHubId(order.getReceiverFirmId());

            if (!managingHubId.contains(receiverHubId)) {
                throw new BusinessException(ErrorCode.ORDER_NOT_IN_MANAGING_HUB);
            }
        }

        // 본인 주문인지 체크
        if (SecurityUtils.hasRole(Role.FIRM_MANAGER)) {
            UUID currentUserId = SecurityUtils.getCurrentUserId();

            if (!currentUserId.equals(order.getCreatedBy())) {
                throw new BusinessException(ErrorCode.ORDER_NOT_CREATED_BY_USER);
            }
        }

        order.updateStatus(OrderStatus.CANCELED);
        order.delete(SecurityUtils.getCurrentUserId());

        // 재고 복원
        for (OrderProduct p : order.getOrderProductList()) {
            productPort.recoverStock(p.getProductId(), p.getQuantity());
        }

        // Kafka 메시지 생성 (즉시 발행하지 않음)
        OrderCanceledV1 message = new OrderCanceledV1(
            order.getId(),
            order.getOrderStatus(),  // CANCELED
            LocalDateTime.now()
        );

        saveOutboxEvent(order.getId(), EVENT_ORDER_CANCELED, message);

        // TODO: 주문 읽기 업데이트 (OrderStatus, delete)
    }
}</code></pre>
]]></description>
        </item>
        <item>
            <title><![CDATA[12/19]]></title>
            <link>https://velog.io/@jess_kim/1219</link>
            <guid>https://velog.io/@jess_kim/1219</guid>
            <pubDate>Fri, 19 Dec 2025 02:54:49 GMT</pubDate>
            <description><![CDATA[<h2 id="🔹-outbox-엔티티-알아보기">🔹 outbox 엔티티 알아보기</h2>
<pre><code class="language-java">import com.klp.common.BaseEntity;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import java.time.LocalDateTime;
import java.util.UUID;
import lombok.AccessLevel;
import lombok.Getter;
import lombok.NoArgsConstructor;

@Entity
@Table(name = &quot;p_order_outbox_events&quot;)
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class OrderOutboxEvent extends BaseEntity {

    @Id
    @GeneratedValue(strategy = GenerationType.UUID)
    private UUID id;

    @Column(nullable = false)
    private UUID orderId;

    @Column(nullable = false)
    private String eventType;

    @Column(columnDefinition = &quot;TEXT&quot;, nullable = false)
    private String payload;

    @Enumerated(EnumType.STRING)
    @Column(nullable = false)
    private OrderOutboxStatus status;

    @Column(nullable = false)
    private Integer retryCount = 0;

    private LocalDateTime publishedAt;

    private LocalDateTime lastRetryAt;

    public static OrderOutboxEvent create(UUID orderId,
        String eventType, String payload) {
        OrderOutboxEvent event = new OrderOutboxEvent();
        event.orderId = orderId;
        event.eventType = eventType;
        event.payload = payload;
        event.status = OrderOutboxStatus.PENDING;
        return event;
    }

    private static final int MAX_RETRY_COUNT = 20;           // 최대 20회
    private static final long INITIAL_BACKOFF_MILLIS = 1000L;  // 초기 1초
    private static final long MAX_BACKOFF_MILLIS = 300000L;    // 최대 5분

    public void markAsPublishing() {
        this.status = OrderOutboxStatus.PUBLISHING;
        this.lastRetryAt = LocalDateTime.now();
        this.retryCount++;
    }

    public void markAsPublished() {
        this.status = OrderOutboxStatus.PUBLISHED;
        this.publishedAt = LocalDateTime.now();
    }

    public void markAsFailed() {
        this.retryCount++;
        this.lastRetryAt = LocalDateTime.now();

        // 20회 초과 시 FAILED 처리
        if (this.retryCount &gt;= MAX_RETRY_COUNT) {
            this.status = OrderOutboxStatus.FAILED;
        }
    }

    public boolean canRetry() {
        return this.status != OrderOutboxStatus.PUBLISHED
            &amp;&amp; this.status != OrderOutboxStatus.FAILED;
    }

    /**
     * AWS/Google Cloud 권장 전략 1초 → 2초 → 4초 → 8초 → ... → 최대 5분
     */
    // 1초 2초 ... 최대 5분 -&gt; AWS, GOOGLE 방식
    public long getBackoffMillis() {
        // Exponential: 1초 * 2^(retryCount-1)
        long exponentialBackoff = INITIAL_BACKOFF_MILLIS * (1L &lt;&lt; (this.retryCount - 1));

        // 최대값 제한
        long cappedBackoff = Math.min(exponentialBackoff, MAX_BACKOFF_MILLIS);

        // Jitter 추가 (0~20% 랜덤 변동)
        double jitterFactor = 0.8 + (Math.random() * 0.4); // 0.8 ~ 1.2
        return (long) (cappedBackoff * jitterFactor);
    }

    public boolean shouldRetryNow() {
        if (!canRetry()) {
            return false;
        }

        // 첫 시도이거나 backoff 시간이 지났으면 재시도
        if (this.lastRetryAt == null) {
            return true;
        }

        long elapsed = java.time.Duration.between(this.lastRetryAt, LocalDateTime.now())
            .toMillis();
        return elapsed &gt;= getBackoffMillis();
    }

    public void resetToPending() {
        if (this.status == OrderOutboxStatus.PUBLISHING) {
            this.status = OrderOutboxStatus.PENDING;
        }
    }

}</code></pre>
<hr>
</br>

<h2 id="🔹-이-클래스의-역할-왜-존재하는가">🔹 이 클래스의 역할 (왜 존재하는가)</h2>
<p>이 코드는 <strong>Order 서비스에서 Outbox Pattern을 구현한 엔티티</strong>로,
<strong>“주문 트랜잭션과 이벤트 발행을 분리하면서도 정합성을 보장”</strong>하기 위한 핵심 구성요소다.
전체 구조를 기준 → 필드 → 상태 전이 → 재시도/백오프 전략 순서로 알아봤다.</p>
<p><code>OrderOutboxEvent</code>는 다음 목적을 가진다.</p>
<ul>
<li><strong>Order DB 트랜잭션 안에서 이벤트를 안전하게 저장</strong></li>
<li>Kafka / 메시지 브로커 발행 실패 시에도 <strong>이벤트 유실 방지</strong></li>
<li>비동기 재시도 + 지수 백오프를 통해 <strong>안정적인 이벤트 발행 보장</strong></li>
<li>Saga / 이벤트 기반 아키텍처에서 <strong>Exactly-once에 가까운 동작 기반</strong></li>
</ul>
<p>즉,</p>
<blockquote>
<p>“주문은 성공했는데 이벤트는 못 보냈다”
→ <strong>절대 발생하지 않게 만드는 구조</strong></p>
</blockquote>
<hr>
</br>

<h2 id="🔹-테이블--엔티티-구조">🔹 테이블 / 엔티티 구조</h2>
<pre><code class="language-java">@Entity
@Table(name = &quot;p_order_outbox_events&quot;)
public class OrderOutboxEvent extends BaseEntity</code></pre>
<ul>
<li><p><code>p_order_outbox_events</code></p>
</li>
<li><p>일반 도메인 엔티티가 아니라 <strong>이벤트 로그 / 메시지 큐 대체 테이블</strong></p>
</li>
<li><p><code>BaseEntity</code>:</p>
<ul>
<li>보통 <code>createdAt</code>, <code>updatedAt</code> 같은 audit 필드 포함</li>
</ul>
</li>
</ul>
<hr>
</br>

<h2 id="🔹-핵심-필드-알아보기">🔹 핵심 필드 알아보기</h2>
<h3 id="🔸-식별자-및-이벤트-기본-정보">🔸 식별자 및 이벤트 기본 정보</h3>
<pre><code class="language-java">@Id
@GeneratedValue(strategy = GenerationType.UUID)
private UUID id;</code></pre>
<ul>
<li>Outbox 이벤트 자체의 ID</li>
<li>orderId와 <strong>1:N 관계 가능</strong> (한 주문에서 여러 이벤트)</li>
</ul>
<pre><code class="language-java">@Column(nullable = false)
private UUID orderId;</code></pre>
<ul>
<li>어떤 주문에서 발생한 이벤트인지 식별</li>
</ul>
<pre><code class="language-java">@Column(nullable = false)
private String eventType;</code></pre>
<ul>
<li>예: <code>OrderAfterCreateV1</code>, <code>OrderCanceledV1</code></li>
<li>Consumer가 분기 처리할 수 있는 기준값</li>
</ul>
<pre><code class="language-java">@Column(columnDefinition = &quot;TEXT&quot;, nullable = false)
private String payload;</code></pre>
<ul>
<li>Kafka로 보낼 <strong>JSON 직렬화 데이터</strong></li>
<li>스키마 변경에도 유연 (TEXT)</li>
</ul>
</br>

<h3 id="🔸-상태-관리-필드-outbox의-핵심">🔸 상태 관리 필드 (Outbox의 핵심)</h3>
<pre><code class="language-java">@Enumerated(EnumType.STRING)
private OrderOutboxStatus status;</code></pre>
<p>일반적인 상태 흐름:</p>
<pre><code>PENDING → PUBLISHING → PUBLISHED
                   ↘ FAILED</code></pre><pre><code class="language-java">private Integer retryCount = 0;</code></pre>
<ul>
<li>발행 시도 횟수</li>
<li>백오프 계산의 기준값</li>
</ul>
<pre><code class="language-java">private LocalDateTime publishedAt;
private LocalDateTime lastRetryAt;</code></pre>
<ul>
<li>성공 시각 / 마지막 시도 시각 기록</li>
<li>모니터링, 운영 분석에 매우 중요</li>
</ul>
<hr>
</br>

<h2 id="🔹-객체-생성-방식-팩토리-메서드">🔹 객체 생성 방식 (팩토리 메서드)</h2>
<pre><code class="language-java">public static OrderOutboxEvent create(UUID orderId, String eventType, String payload)</code></pre>
<p>의도:</p>
<ul>
<li><strong>무조건 PENDING 상태로 시작</strong></li>
<li>생성 규칙을 한 곳에서 통제</li>
<li>생성자 노출 차단 (<code>protected</code>)</li>
</ul>
<p>👉 트랜잭션 내부에서 다음처럼 사용됨</p>
<pre><code class="language-java">OrderOutboxEvent event =
    OrderOutboxEvent.create(orderId, &quot;OrderAfterCreateV1&quot;, payload);
outboxRepository.save(event);</code></pre>
<hr>
</br>

<h2 id="🔹-재시도--백오프-정책-매우-중요한-부분">🔹 재시도 / 백오프 정책 (매우 중요한 부분)</h2>
<h3 id="🔸-재시도-제한-상수">🔸 재시도 제한 상수</h3>
<pre><code class="language-java">private static final int MAX_RETRY_COUNT = 20;
private static final long INITIAL_BACKOFF_MILLIS = 1000L;
private static final long MAX_BACKOFF_MILLIS = 300000L;</code></pre>
<ul>
<li><strong>최대 20회 시도</strong></li>
<li>1초 → 2초 → 4초 → … → 최대 5분</li>
<li>AWS / Google Cloud 권장 전략과 동일</li>
</ul>
</br>

<h3 id="🔸상태-전이-메서드">🔸상태 전이 메서드</h3>
<h4 id="발행-시도-시작">발행 시도 시작</h4>
<pre><code class="language-java">public void markAsPublishing() {
    this.status = PUBLISHING;
    this.lastRetryAt = now;
    this.retryCount++;
}</code></pre>
<ul>
<li>실제 Kafka 전송 직전에 호출</li>
<li><strong>중복 실행 방지용 락 역할</strong></li>
</ul>
</br>

<h4 id="발행-성공">발행 성공</h4>
<pre><code class="language-java">public void markAsPublished()</code></pre>
<ul>
<li>최종 성공 상태</li>
<li>이후 <strong>절대 재시도하지 않음</strong></li>
</ul>
</br>

<h4 id="발행-실패">발행 실패</h4>
<pre><code class="language-java">public void markAsFailed()</code></pre>
<ul>
<li>retryCount 증가</li>
<li>20회 초과 시 <code>FAILED</code> 확정</li>
<li>운영자가 수동 개입해야 하는 상태</li>
</ul>
<hr>
</br>

<h2 id="🔹-재시도-가능-여부-판단">🔹 재시도 가능 여부 판단</h2>
<pre><code class="language-java">public boolean canRetry()</code></pre>
<ul>
<li>PUBLISHED / FAILED → ❌</li>
<li>PENDING / PUBLISHING → ⭕</li>
</ul>
<pre><code class="language-java">public boolean shouldRetryNow()</code></pre>
<ul>
<li><p>최초 시도 → 즉시 가능</p>
</li>
<li><p>이후:</p>
<ul>
<li><code>현재 시간 - lastRetryAt &gt;= backoffMillis</code></li>
</ul>
</li>
</ul>
<p>👉 <strong>스케줄러가 이 메서드만 믿고 동작 가능</strong></p>
<hr>
</br>

<h2 id="🔹-지수-백오프--jitter-구현">🔹 지수 백오프 + Jitter 구현</h2>
<pre><code class="language-java">long exponentialBackoff =
    1초 * 2^(retryCount - 1);</code></pre>
<pre><code class="language-java">double jitterFactor = 0.8 ~ 1.2;</code></pre>
<p>왜 Jitter를 넣었나?</p>
<ul>
<li>여러 이벤트가 <strong>동시에 재시도되는 Thundering Herd 방지</strong></li>
<li>클라우드 환경에서 권장되는 방식</li>
</ul>
<p>이 구현은 <strong>실무 기준에서도 매우 잘 짜인 편</strong>이다. (권장되는 방식)</p>
<hr>
</br>

<h2 id="🔹-resettopending의-의미">🔹 <code>resetToPending()</code>의 의미</h2>
<pre><code class="language-java">public void resetToPending()</code></pre>
<p>사용 시점:</p>
<ul>
<li><p>PUBLISHING 상태에서</p>
<ul>
<li>앱 크래시</li>
<li>트랜잭션 롤백</li>
<li>락 타임아웃</li>
</ul>
</li>
</ul>
<p>같은 상황이 발생했을 때</p>
<p>👉 <strong>다시 잡아서 재처리 가능하도록 복구</strong></p>
<hr>
</br>

<h2 id="🔹-이-엔티티가-보장하는-것">🔹 이 엔티티가 보장하는 것</h2>
<p>이 설계로 인해 보장되는 특성:</p>
<table>
<thead>
<tr>
<th>항목</th>
<th>보장 여부</th>
</tr>
</thead>
<tbody><tr>
<td>이벤트 유실 방지</td>
<td>✅</td>
</tr>
<tr>
<td>재시도 자동화</td>
<td>✅</td>
</tr>
<tr>
<td>트랜잭션 정합성</td>
<td>✅</td>
</tr>
<tr>
<td>장애 내성</td>
<td>✅</td>
</tr>
<tr>
<td>클라우드 친화적</td>
<td>✅</td>
</tr>
</tbody></table>
<hr>
</br>

<h2 id="🔹-한-줄-요약">🔹 한 줄 요약</h2>
<blockquote>
<p>이 <code>OrderOutboxEvent</code>는
<strong>주문 서비스에서 Kafka 이벤트 발행을 “운영 수준으로 안전하게” 만드는 Outbox 엔티티</strong>이며,
지수 백오프 + 재시도 + 상태 머신이 결합된 <strong>실전형 구현</strong>이다.</p>
</blockquote>
]]></description>
        </item>
        <item>
            <title><![CDATA[12/18]]></title>
            <link>https://velog.io/@jess_kim/1218</link>
            <guid>https://velog.io/@jess_kim/1218</guid>
            <pubDate>Thu, 18 Dec 2025 04:53:16 GMT</pubDate>
            <description><![CDATA[<h2 id="🔹-outboxinbox-알아보기">🔹 outbox/inbox 알아보기</h2>
<p><strong>Outbox / Inbox 패턴</strong>을 “깊이(depth)” 단계별로 쌓아 올리는 방식으로 정리해봤다.
(이벤트 기반/마이크로서비스에서 “DB 트랜잭션 ↔ 메시지 발행”의 정합성을 맞추는 용도)</p>
<hr>
</br>

<h2 id="🔹-depth1-이론왜-필요한가--무엇을-해결하나">🔹 depth.1 이론(왜 필요한가 / 무엇을 해결하나)</h2>
<h3 id="🔸-outbox-패턴">🔸 Outbox 패턴</h3>
<ul>
<li><p>서비스가 <strong>DB에 상태 변경</strong>(예: 주문 생성)을 한 뒤, 곧바로 Kafka 같은 브로커로 이벤트를 발행하면
<strong>DB 커밋은 성공했는데 메시지 발행은 실패</strong>(또는 반대) 같은 “반쪽 성공”이 생길 수 있다.</p>
</li>
<li><p>Outbox는 이를 막기 위해,</p>
<ol>
<li><strong>업무 데이터 변경</strong>과</li>
<li><strong>이벤트 기록(Outbox 테이블 insert)</strong>
를 <strong>같은 DB 트랜잭션</strong>으로 묶고,
이후 별도 프로세스(퍼블리셔)가 Outbox를 읽어 브로커로 발행한다.</li>
</ol>
</li>
</ul>
<blockquote>
<p><em><strong>즉 “DB가 진실(Source of Truth)이고, 이벤트는 DB에 먼저 적어둔 뒤 안전하게 발행”하는 구조.</strong></em></p>
</blockquote>
</br>

<h3 id="🔸-inbox-패턴">🔸 Inbox 패턴</h3>
<ul>
<li><p>브로커(Kafka)는 “최소 1회(at-least-once)” 전달이 흔해서, 컨슈머는 <strong>중복 메시지</strong>를 받을 수 있다.</p>
</li>
<li><p>Inbox는 컨슈머가 메시지를 처리할 때,</p>
<ul>
<li>메시지의 고유 식별자(eventId/messageId)를 기준으로</li>
<li><strong>이미 처리했는지(Inbox 테이블/스토어로) 확인하고</strong></li>
<li>중복이면 <strong>무시(멱등)</strong> 하도록 보장한다.</li>
</ul>
</li>
</ul>
<blockquote>
<p><em><strong>즉 “수신 측에서 중복 처리를 방지해서 정확히 한 번처럼 보이게” 만드는 구조.</strong></em></p>
</blockquote>
<hr>
</br>

<h2 id="🔹-depth2-최소-구성요소">🔹 depth.2 최소 구성요소</h2>
<h3 id="🔸-outbox-최소-구성">🔸 Outbox 최소 구성</h3>
<ol>
<li><p><strong>Outbox 테이블</strong></p>
<ul>
<li><code>id</code>(eventId, UUID 등), <code>aggregate_type</code>, <code>aggregate_id</code>, <code>event_type</code></li>
<li><code>payload</code>(JSON), <code>status</code>(PENDING/SENT/FAILED), <code>created_at</code></li>
</ul>
</li>
<li><p><strong>업무 트랜잭션 안에서 Outbox 레코드 저장</strong></p>
<ul>
<li>예: 주문 저장 + outbox insert를 같은 트랜잭션으로</li>
</ul>
</li>
<li><p><strong>Outbox 퍼블리셔(폴링 워커)</strong></p>
<ul>
<li>주기적으로 <code>status=PENDING</code> 레코드를 조회 → Kafka 발행 → 성공 시 <code>SENT</code>로 업데이트</li>
</ul>
</li>
<li><p><strong>재시도 정책(최소)</strong></p>
<ul>
<li>실패하면 <code>FAILED</code>로 남기고 다음 폴링에서 재시도(또는 backoff)</li>
</ul>
</li>
</ol>
<blockquote>
<p><em><strong>최소 구성에서는 “폴링 기반”이 가장 단순</strong></em></p>
</blockquote>
</br>

<h3 id="🔸-inbox-최소-구성">🔸 Inbox 최소 구성</h3>
<ol>
<li><p><strong>Inbox 테이블(Processed Messages)</strong></p>
<ul>
<li><code>event_id</code>(유니크), <code>consumer_name</code>, <code>processed_at</code></li>
</ul>
</li>
<li><p><strong>컨슘 시 처리 순서</strong></p>
<ul>
<li>트랜잭션 시작</li>
<li><code>event_id</code>가 Inbox에 이미 있으면 → 종료(중복 무시)</li>
<li>없으면 → 업무 로직 처리(상태 변경) + Inbox insert</li>
<li>커밋</li>
</ul>
</li>
</ol>
<blockquote>
<p><em><strong>핵심은 “업무 처리”와 “처리완료 기록”을 같은 트랜잭션으로 묶는 것.</strong></em></p>
</blockquote>
<hr>
</br>

<h2 id="🔹-depth3-도입-시-추가로-신경-써야-하는-것">🔹 depth.3 도입 시 추가로 신경 써야 하는 것</h2>
<h3 id="🔸-이벤트-키와-중복-설계">🔸 이벤트 “키”와 “중복” 설계</h3>
<ul>
<li><p>Inbox가 있으려면 <strong>eventId가 반드시 고유</strong>해야 하고, 컨슈머가 그걸 신뢰해야 한다.</p>
</li>
<li><p><strong>eventId 생성 주체</strong></p>
<ul>
<li>Outbox 레코드 id를 eventId로 쓰는 게 가장 깔끔(발행되는 메시지에 그대로 포함)</li>
</ul>
</li>
</ul>
</br>

<h3 id="🔸-outbox-퍼블리셔의-동시성락">🔸 Outbox 퍼블리셔의 동시성/락</h3>
<ul>
<li><p>퍼블리셔가 2개 이상 떠도 안전해야 함(이중 발행 방지)</p>
</li>
<li><p>방법(선택지)</p>
<ul>
<li><code>SELECT … FOR UPDATE SKIP LOCKED</code></li>
<li>상태를 <code>PENDING -&gt; SENDING</code>으로 CAS 업데이트 후 발행</li>
<li>배치 단위로 “claim” 후 처리</li>
</ul>
</li>
</ul>
</br>

<h3 id="🔸-발행-성공-판정과-브로커-ack">🔸 발행 성공 판정과 브로커 ack</h3>
<ul>
<li><p>“Kafka 발행 성공”을 <strong>어디까지로 볼지</strong></p>
<ul>
<li>프로듀서 ack(브로커에 기록 완료)까지 받으면 성공 처리</li>
</ul>
</li>
<li><p>실패/타임아웃 시: 실제론 기록됐는데 타임아웃일 수 있어 → <strong>중복 발행 가능성</strong>을 전제로 Inbox가 필요해짐(서로 보완 관계)</p>
</li>
</ul>
</br>

<h3 id="🔸-메시지-순서-보장aggregate-단위">🔸 메시지 순서 보장(aggregate 단위)</h3>
<ul>
<li>주문 같은 aggregate는 “생성→상태변경” 순서가 중요함</li>
<li>Kafka를 쓴다면 <strong>partition key를 aggregateId(orderId)</strong> 로 고정해서 순서가 깨지지 않게</li>
</ul>
</br>

<h3 id="🔸-스키마-진화와-호환성">🔸 스키마 진화와 호환성</h3>
<ul>
<li><p>payload(JSON)의 필드가 바뀌면 컨슈머가 깨지기 쉬움</p>
</li>
<li><p>최소 방어:</p>
<ul>
<li><code>event_type + version</code> 넣기(<code>OrderCreatedV1</code> 같은 네이밍)</li>
<li>컨슈머는 모르는 필드는 무시할 수 있게(유연한 역직렬화)</li>
</ul>
</li>
</ul>
</br>

<h3 id="🔸-장애모니터링운영">🔸 장애/모니터링/운영</h3>
<ul>
<li><p>Outbox에 <code>FAILED</code>가 쌓이면 “업무는 됐는데 이벤트가 안 나감” 상태</p>
</li>
<li><p>최소한 필요:</p>
<ul>
<li>FAILED 건수 알람</li>
<li>오래된 PENDING 감지</li>
<li>DLQ(Dead Letter) 또는 수동 재처리 도구</li>
</ul>
</li>
</ul>
<hr>
</br>

<h2 id="🔹-depth4-확장-시-추천-체크리스트">🔹 depth.4 확장 시 추천 체크리스트</h2>
<ul>
<li><p><strong>CDC 기반 Outbox (Debezium)</strong>
폴링 대신 DB binlog를 읽어 Kafka로 내보내 성능/지연 개선</p>
</li>
<li><p><strong>Exactly-once처럼 보이게 만들기 조합</strong></p>
<ul>
<li>생산: Outbox(또는 Kafka 트랜잭션)</li>
<li>소비: Inbox + 멱등 업데이트(유니크 제약/업서트)</li>
</ul>
</li>
<li><p><strong>Idempotency Key를 도메인 명령에도 적용</strong></p>
<ul>
<li>“주문 생성 요청” 자체도 중복 호출될 수 있으면 API 레벨에도 idempotency key 추가</li>
</ul>
</li>
<li><p><strong>리플레이 전략</strong></p>
<ul>
<li>Inbox 보관 기간(TTL) 결정: 영구 보관 vs 일정 기간 후 정리</li>
</ul>
</li>
<li><p><strong>대용량 최적화</strong></p>
<ul>
<li>outbox/inbox 파티셔닝, 아카이빙, payload 분리(큰 payload는 object storage + pointer)</li>
</ul>
</li>
</ul>
<hr>
</br>

<h2 id="🔹-현재-내-프로젝트-기준으로-봤을-때">🔹 현재 내 프로젝트 기준으로 봤을 때</h2>
<ul>
<li><p><strong>order-server</strong></p>
<ul>
<li>주문 생성 트랜잭션에 Outbox 기록: <code>OrderAfterCreateV1</code></li>
</ul>
</li>
<li><p><strong>hub-server</strong></p>
<ul>
<li><code>OrderAfterCreateV1</code> 컨슘 시 Inbox로 중복 제거</li>
<li>허브경로 계산 결과 저장 + Outbox로 <code>HubRouteAfterCreateV1</code></li>
</ul>
</li>
<li><p><strong>delivery-server</strong></p>
<ul>
<li><code>HubRouteAfterCreateV1</code> 컨슘 시 Inbox</li>
<li>HubDelivery/FirmDelivery 생성(트랜잭션)</li>
</ul>
</li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[12/17]]></title>
            <link>https://velog.io/@jess_kim/1217</link>
            <guid>https://velog.io/@jess_kim/1217</guid>
            <pubDate>Wed, 17 Dec 2025 02:12:01 GMT</pubDate>
            <description><![CDATA[<h2 id="🔹-보상-트랜잭션이란">🔹 보상 트랜잭션이란?</h2>
<p><strong>보상 트랜잭션(Compensating Transaction)</strong>은
<strong>분산 환경에서 이미 커밋된 작업을 “되돌리기 위해” 실행하는 추가 트랜잭션</strong>을 말한다.</p>
<blockquote>
<p>❌ “롤백”이 아니라
✅ “취소·무효·상쇄”를 <strong>새로운 트랜잭션으로 수행</strong>하는 개념이다.</p>
</blockquote>
<hr>
</br>

<h2 id="🔹-왜-보상-트랜잭션이-필요한가">🔹 왜 보상 트랜잭션이 필요한가?</h2>
<h3 id="🔸-단일-db-트랜잭션">🔸 단일 DB 트랜잭션</h3>
<pre><code class="language-text">BEGIN
  주문 생성
  결제 처리
ROLLBACK  ← 문제 생기면 한 번에 되돌림</code></pre>
<ul>
<li>ACID 보장</li>
<li>하나의 DB / 하나의 트랜잭션 매니저</li>
<li><strong>롤백 가능</strong></li>
</ul>
</br>

<h3 id="🔸-분산-트랜잭션-msa">🔸 분산 트랜잭션 (MSA)</h3>
<pre><code class="language-text">order-server      → 주문 생성 (COMMIT)
payment-server    → 결제 승인 (COMMIT)
delivery-server   → 배송 생성 ❌ 실패</code></pre>
<ul>
<li>이미 <strong>각 서비스는 COMMIT 완료</strong></li>
<li>전통적인 <code>ROLLBACK</code> 불가능</li>
<li>2PC는 현실적으로 성능·가용성 문제 큼</li>
</ul>
<p>👉 <strong>그래서 등장한 개념이 보상 트랜잭션</strong></p>
<hr>
</br>

<h2 id="🔹-보상-트랜잭션의-정의">🔹 보상 트랜잭션의 정의</h2>
<blockquote>
<p><strong>보상 트랜잭션이란</strong>
이미 성공적으로 커밋된 트랜잭션의 효과를
<strong>비즈니스적으로 무효화하기 위해 실행하는 “역방향 트랜잭션”</strong>이다.</p>
</blockquote>
<table>
<thead>
<tr>
<th>구분</th>
<th>롤백</th>
<th>보상 트랜잭션</th>
</tr>
</thead>
<tbody><tr>
<td>실행 시점</td>
<td>커밋 이전</td>
<td>커밋 이후</td>
</tr>
<tr>
<td>방식</td>
<td>DB 상태 복구</td>
<td>새로운 비즈니스 동작</td>
</tr>
<tr>
<td>기술적 성격</td>
<td>DB 기능</td>
<td>애플리케이션 로직</td>
</tr>
<tr>
<td>사용 환경</td>
<td>단일 시스템</td>
<td>분산 시스템(MSA)</td>
</tr>
</tbody></table>
<hr>
</br>

<h2 id="🔹-실제-예시로-보면-가장-명확함">🔹 실제 예시로 보면 가장 명확함</h2>
<p>예: 주문 → 배송 생성 실패</p>
<h3 id="🔸-정상-흐름">🔸 정상 흐름</h3>
<ol>
<li>주문 생성 ✅</li>
<li>허브 경로 계산 ✅</li>
<li>배송 생성 ❌ 실패</li>
</ol>
<h3 id="🔸-이때-보상은">🔸 이때 “보상”은?</h3>
<ul>
<li>주문을 <strong>ROLLBACK</strong> ❌ (이미 커밋됨)</li>
<li>대신:</li>
</ul>
<pre><code class="language-text">주문 상태 → CANCELED
배송 예약 데이터 → 취소 처리
결제 → 환불 (필요 시)</code></pre>
<p>이 전체가 <strong>보상 트랜잭션</strong>이다.</p>
<hr>
</br>

<h2 id="🔹-saga-패턴에서의-위치">🔹 Saga 패턴에서의 위치</h2>
<p>보상 트랜잭션은 <strong>Saga 패턴의 핵심 구성 요소</strong>다.</p>
<h3 id="🔸-choreography-saga">🔸 Choreography Saga</h3>
<ul>
<li>실패 이벤트 발생</li>
<li>각 서비스가 <strong>자기 보상 로직 수행</strong></li>
</ul>
<h3 id="🔸-orchestrator-saga">🔸 Orchestrator Saga</h3>
<ul>
<li>중앙 Orchestrator가</li>
<li>“A 실패 → B 보상 → C 보상” 순서 제어</li>
</ul>
<p>👉 <strong>어떤 Saga든 보상 트랜잭션 없이는 성립 불가</strong></p>
<hr>
</br>

<h2 id="🔹-보상-트랜잭션의-중요한-특성">🔹 보상 트랜잭션의 중요한 특성</h2>
<h3 id="🔸-항상-완전-복구를-목표로-하지-않는다">🔸 항상 “완전 복구”를 목표로 하지 않는다</h3>
<ul>
<li>재고 복구 ❌</li>
<li>주문 취소 상태로 마킹 ✅</li>
<li>금전적 환불만 보장하는 경우도 많음</li>
</ul>
</br>

<h3 id="🔸-멱등성idempotency이-필수">🔸 멱등성(Idempotency)이 필수</h3>
<pre><code class="language-text">같은 보상 이벤트가 2번 와도
결과는 1번 수행된 것과 동일해야 함</code></pre>
<ul>
<li>Kafka 중복 소비</li>
<li>네트워크 재시도</li>
<li>장애 복구 시 재처리</li>
</ul>
</br>

<h3 id="🔸-순서-보장이-중요">🔸 순서 보장이 중요</h3>
<ul>
<li>배송 취소 → 결제 환불 (순서 중요)</li>
<li>잘못하면 “배송 완료 후 환불” 같은 사고 발생</li>
</ul>
</br>

<h3 id="🔸-보상도-실패할-수-있다">🔸 보상도 실패할 수 있다</h3>
<ul>
<li>그래서 <strong>재시도 / DLQ / 알림</strong> 필요</li>
<li>“보상 트랜잭션도 하나의 비즈니스 트랜잭션”</li>
</ul>
<hr>
</br>

<h2 id="🔹-요약-정리">🔹 요약 정리</h2>
<blockquote>
<p><strong>보상 트랜잭션은 분산 환경에서 이미 커밋된 작업을 기술적으로 롤백할 수 없을 때, 비즈니스적으로 상태를 상쇄하기 위해 수행하는 역방향 트랜잭션이며, Saga 패턴의 핵심 구성 요소다.</strong></p>
</blockquote>
]]></description>
        </item>
        <item>
            <title><![CDATA[12/16]]></title>
            <link>https://velog.io/@jess_kim/1216</link>
            <guid>https://velog.io/@jess_kim/1216</guid>
            <pubDate>Tue, 16 Dec 2025 04:49:50 GMT</pubDate>
            <description><![CDATA[<h1 id="주문-취소-시-kafka-skeleton-code">주문 취소 시 Kafka Skeleton Code</h1>
<h2 id="🔹-order-server">🔹 order-server</h2>
<h3 id="🔸-orderstatuschangedv1-dto-추가">🔸 OrderStatusChangedV1 DTO 추가</h3>
<pre><code class="language-java">public record OrderStatusChangedV1(
    UUID orderId,
    OrderStatus orderStatus,
    java.time.LocalDateTime changedAt
) {}
</code></pre>
</br>

<h3 id="🔸-ordercommandservice">🔸 OrderCommandService</h3>
<p>현재 구현된 코드를 기준으로 봤을 때, 이미</p>
<pre><code class="language-java">@Transactional
    public void deleteOrder(UUID id) {
}</code></pre>
<ul>
<li><p>여기서 <strong><code>취소 + 재고복원 + soft delete</code></strong>까지 책임지고 있으니, 여기에 추가로 <strong><code>OrderStatusChangedV1</code></strong>를 같이 발행하도록 하면 흐름이 깔끔하다.</p>
</li>
<li><p><strong><code>OrderStatusChangedV1</code></strong> 이벤트를 발행하는 위치는
<code>order.updateStatus(CANCELED)</code> + <code>order.delete()</code> + <code>재고복원</code>까지 다 성공한 다음, 마지막에 발행하는 것이 좋다.
이유는 주문 취소가 확정일 때만 보상 처리하도록 하는 게 일관성에 좋기 때문이다.</p>
</li>
<li><p>여기서 <code>멱등성: deleteOrder()가 두 번 호출되면?</code> 상황도 생각해봤을 때,
<code>Order.updateStatus()</code>는 <code>COMPLETED, CANCELED -&gt; false</code>라서
이미 CANCELED면 <code>INVALID_ORDER_STATUS_TRANSITION</code>이 터질 것이다.</p>
</li>
<li><p>이후에 트랜잭션 커밋 전에 이벤트가 나가면 생길 문제를 방지하기 위해,
(Kafka 전송은 성공했는데 DB 커밋이 실패하면 delivery-server는 “취소됐다고 믿고 배송 취소”를 해버릴 수 있기 때문)
<code>@TransactionalEventListener(phase = AFTER_COMMIT)</code>로 커밋 이후에 Kafka 발행하도록 처리방식으로 가벼운 고도화 또 가능. ➡️ 지금은 일단 보류</p>
</li>
</ul>
<pre><code class="language-java">@Transactional
    public void deleteOrder(UUID id) {

        // 주문 조회
        Order order = readOrderOrThrow(id);

        // 담당 허브 소속 주문인지 체크
        if (SecurityUtils.hasRole(Role.HUB_MANAGER)) {
            List&lt;UUID&gt; managingHubId = hubPort.readHubId(SecurityUtils.getCurrentUserId());
            UUID receiverHubId = firmPort.readHubId(order.getReceiverFirmId());

            if (!managingHubId.contains(receiverHubId)) {
                throw new BusinessException(ErrorCode.ORDER_NOT_IN_MANAGING_HUB);
            }
        }

        // 본인 주문인지 체크
        if (SecurityUtils.hasRole(Role.FIRM_MANAGER)) {
            UUID currentUserId = SecurityUtils.getCurrentUserId();

            if (!currentUserId.equals(order.getCreatedBy())) {
                throw new BusinessException(ErrorCode.ORDER_NOT_CREATED_BY_USER);
            }
        }

        order.updateStatus(OrderStatus.CANCELED);
        order.delete(SecurityUtils.getCurrentUserId());

        // 재고 복원
        for (OrderProduct p : order.getOrderProductList()) {
            productPort.recoverStock(p.getProductId(), p.getQuantity());
        }

        // Kafka 메시지 발행
        OrderStatusChangedV1 message = new OrderStatusChangedV1(
            order.getId(),
            order.getOrderStatus(),  // CANCELED
            LocalDateTime.now()
        );

        eventPublisher.sendOrderStatusChanged(message);

        // TODO: 주문 읽기 업데이트 (OrderStatus, delete)
    }</code></pre>
</br>

<h3 id="🔸-eventpublisher">🔸 EventPublisher</h3>
<pre><code class="language-java">package chill_logistics.order_server.domain.event;

import chill_logistics.order_server.application.dto.command.OrderAfterCreateV1;

public interface EventPublisher {

    void sendOrderAfterCreate(OrderAfterCreateV1 message);

    /* 여기 추가 */
    void sendOrderStatusChanged(OrderStatusChangedV1 message);
}
</code></pre>
</br>

<h3 id="🔸-orderaftercreateproducer">🔸 OrderAfterCreateProducer</h3>
<p>OrderAfterCreateProducer 클래스명 OrderEventPublisher로 수정하면 더 깔끔할 것 같다.</p>
<pre><code class="language-java">package chill_logistics.order_server.infrastructure.kafka;

import chill_logistics.order_server.domain.event.EventPublisher;
import chill_logistics.order_server.application.dto.command.OrderAfterCreateV1;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.kafka.core.KafkaTemplate;
import org.springframework.stereotype.Service;

@Slf4j
@Service
@RequiredArgsConstructor
public class OrderAfterCreateProducer implements EventPublisher {

    private final KafkaTemplate&lt;String, OrderAfterCreateV1&gt; orderAfterCreateKafkaTemplate;
    private final KafkaTemplate&lt;String, OrderStatusChangedV1&gt; orderStatusChangedKafkaTemplate;

    @Value(&quot;${app.kafka.topic.order-after-create}&quot;)
    private String orderAfterCreateTopic;

    @Value(&quot;${app.kafka.topic.order-status-changed}&quot;)
    private String orderStatusChangedTopic;

    /**
     * 주문 생성 후 Kafka로 OrderAfterCreate 이벤트를 발행합니다.
     *
     * @param message 주문 생성 정보를 담고 있는 메시지 객체
     */
    @Override
    public void sendOrderAfterCreate(OrderAfterCreateV1 message) {

        String key = message.orderId().toString();

        log.info(&quot;[Kafka] OrderAfterCreate 메시지 발행, topic={}, key={}, message={}&quot;,
                orderAfterCreateTopic, key, message);

        orderAfterCreateKafkaTemplate
                .send(orderAfterCreateTopic, key, message)
                .whenComplete((result, ex) -&gt; {
                    if (ex != null) {
                        log.error(&quot;[Kafka] OrderAfterCreate 메시지 전송 실패, key={}&quot;, key, ex);
                    } else {
                        log.info(&quot;[Kafka] OrderAfterCreate 메시지 전송 성공, orderId={}, offset={}&quot;,
                                key, result.getRecordMetadata().offset());
                    }
                });
    }

    /* 여기 추가 */

    /**
     * 주문 상태 변경(취소/실패/완료 등) 시 Kafka로 OrderStatusChanged 이벤트 발행
     */
    @Override
    public void sendOrderStatusChanged(OrderStatusChangedV1 message) {

        String key = message.orderId().toString();

        log.info(&quot;[Kafka] OrderStatusChanged 메시지 발행, topic={}, key={}, message={}&quot;,
            orderStatusChangedTopic, key, message);

        orderStatusChangedKafkaTemplate
            .send(orderStatusChangedTopic, key, message)
            .whenComplete((result, ex) -&gt; {
                if (ex != null) {
                    log.error(&quot;[Kafka] OrderStatusChanged 메시지 전송 실패, key={}&quot;, key, ex);
                } else {
                    log.info(&quot;[Kafka] OrderStatusChanged 메시지 전송 성공, orderId={}, offset={}&quot;,
                        key, result.getRecordMetadata().offset());
                }
            });
    }
}</code></pre>
</br>

<h3 id="🔸-applicationyml">🔸 application.yml</h3>
<pre><code class="language-yml">app:
  kafka:
    topic:
      order-after-create: order-after-create
      /* 여기 추가 */
      order-status-changed: order-status-changed</code></pre>
</br>

<h3 id="🔸-kafkaproducerconfig">🔸 KafkaProducerConfig</h3>
<pre><code class="language-java">package chill_logistics.order_server.infrastructure.config;

import chill_logistics.order_server.application.dto.command.OrderAfterCreateV1;
import chill_logistics.order_server.application.dto.command.OrderStatusChangedV1;
import org.apache.kafka.clients.producer.ProducerConfig;
import org.apache.kafka.common.serialization.StringSerializer;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.kafka.core.DefaultKafkaProducerFactory;
import org.springframework.kafka.core.KafkaTemplate;
import org.springframework.kafka.core.ProducerFactory;
import org.springframework.kafka.support.serializer.JsonSerializer;

import java.util.HashMap;
import java.util.Map;

@Configuration
public class KafkaProducerConfig {

    /* 공통 Producer 설정 */
    private Map&lt;String, Object&gt; baseProducerProps() {
        Map&lt;String, Object&gt; props = new HashMap&lt;&gt;();

        // Kafka Broker
        props.put(ProducerConfig.BOOTSTRAP_SERVERS_CONFIG, &quot;localhost:9092&quot;);

        // 직렬화 설정
        props.put(ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG, StringSerializer.class);
        props.put(ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG, JsonSerializer.class);

        return props;
    }

    /* OrderAfterCreate */
    @Bean
    public ProducerFactory&lt;String, OrderAfterCreateV1&gt; orderAfterCreateProducerFactory() {
        return new DefaultKafkaProducerFactory&lt;&gt;(baseProducerProps());
    }

    @Bean
    public KafkaTemplate&lt;String, OrderAfterCreateV1&gt; orderAfterCreateKafkaTemplate() {
        return new KafkaTemplate&lt;&gt;(orderAfterCreateProducerFactory());
    }

    /* OrderStatusChanged */
    @Bean
    public ProducerFactory&lt;String, OrderStatusChangedV1&gt; orderStatusChangedProducerFactory() {
        return new DefaultKafkaProducerFactory&lt;&gt;(baseProducerProps());
    }

    @Bean
    public KafkaTemplate&lt;String, OrderStatusChangedV1&gt; orderStatusChangedKafkaTemplate() {
        return new KafkaTemplate&lt;&gt;(orderStatusChangedProducerFactory());
    }
}
</code></pre>
<hr>
</br>

<h2 id="🔹-delivery-server">🔹 delivery-server</h2>
<ul>
<li><p>HubRouteAfterCreate 처리(배송 생성) 쪽에서 “이미 취소된 주문이면 생성하지 않기” 같은 가드 로직 필요</p>
</li>
<li><p>멱등성: orderId 기준으로 이미 배송 만들어졌으면, 다시 생성하지 않도록 처리 필요
(같은 주문으로 배송이 중복 생성되지 않도록)</p>
</li>
</ul>
<h3 id="🔸-hubdelievery">🔸 HubDelievery</h3>
<pre><code class="language-java">package chill_logistics.delivery_server.domain.entity;

import ...

    ...(생략)

    // length=15 옵션 제거
    @Enumerated(EnumType.STRING)
    @Column(name = &quot;delivery_status&quot;, nullable = false)
    private DeliveryStatus deliveryStatus;

    ...(생략)

    ...(기존 메서드 생략)

    // 주문 취소 시 처리 메서드
    public void cancelDueToOrder() {

        // 멱등성: 이미 취소된 상태면 다시 호출되어도 OK || 이미 상품 받았으면 주문 취소 불가
        if (this.deliveryStatus == DeliveryStatus.DELIVERY_CANCELLED
            || this.deliveryStatus == DeliveryStatus.DELIVERY_COMPLETED) {
            return;
        }

        DeliveryStatus nextDeliveryStatus = DeliveryStatus.DELIVERY_CANCELLED;

        if (!this.deliveryStatus.canTransitTo(nextDeliveryStatus)) {
            throw new BusinessException(ErrorCode.DELIVERY_ALREADY_COMPLETED_OR_CANCELED);
        }

        this.deliveryStatus = nextDeliveryStatus;
    }
}
</code></pre>
</br>

<h3 id="🔸-firmdelivery">🔸 FirmDelivery</h3>
<pre><code class="language-java">package chill_logistics.delivery_server.domain.entity;

import ...

...(생략)

    // length=15 옵션 제거
    @Enumerated(EnumType.STRING)
    @Column(name = &quot;delivery_status&quot;, nullable = false)
    private DeliveryStatus deliveryStatus;

    ...(생략)

    ... (기존 메서드 생략)

    // 주문 취소 시 처리 메서드
    public void cancelDueToOrder() {

        // 멱등성: 이미 취소된 상태면 다시 호출되어도 OK || 이미 상품 받았으면 주문 취소 불가
        if (this.deliveryStatus == DeliveryStatus.DELIVERY_CANCELLED
            || this.deliveryStatus == DeliveryStatus.DELIVERY_COMPLETED) {
            return;
        }

        DeliveryStatus nextDeliveryStatus = DeliveryStatus.DELIVERY_CANCELLED;

        if (!this.deliveryStatus.canTransitTo(nextDeliveryStatus)) {
            throw new BusinessException(ErrorCode.DELIVERY_ALREADY_COMPLETED_OR_CANCELED);
        }

        this.deliveryStatus = nextDeliveryStatus;
    }
}
</code></pre>
</br>

<h3 id="🔸-orderstatus-추가">🔸 OrderStatus 추가</h3>
<pre><code class="language-java">package chill_logistics.delivery_server.application;

public enum OrderStatus {

    CREATED,
    PROCESSING,
    IN_TRANSIT,
    COMPLETED,
    CANCELED
}
</code></pre>
</br>

<h3 id="🔸-orderstatuschangedv1-추가">🔸 OrderStatusChangedV1 추가</h3>
<pre><code class="language-java">package chill_logistics.delivery_server.infrastructure.kafka.dto;

import chill_logistics.delivery_server.application.OrderStatus;
import java.time.LocalDateTime;
import java.util.UUID;

public record OrderStatusChangedV1(
    UUID orderId,
    OrderStatus orderStatus,
    String reason,
    LocalDateTime changedAt
) {}
</code></pre>
</br>

<h3 id="🔸-kafkaconsumerconfig">🔸 KafkaConsumerConfig</h3>
<ul>
<li>공통 설정 분리 후 HubRouteAfterCreate / OrderStatusChanged 각각 container 설정</li>
</ul>
<pre><code class="language-java">package chill_logistics.delivery_server.infrastructure.config;

import chill_logistics.delivery_server.infrastructure.kafka.dto.HubRouteAfterCreateV1;
import chill_logistics.delivery_server.infrastructure.kafka.dto.OrderStatusChangedV1;
import java.util.HashMap;
import java.util.Map;
import org.apache.kafka.clients.consumer.ConsumerConfig;
import org.apache.kafka.common.serialization.StringDeserializer;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.kafka.config.ConcurrentKafkaListenerContainerFactory;
import org.springframework.kafka.core.ConsumerFactory;
import org.springframework.kafka.core.DefaultKafkaConsumerFactory;
import org.springframework.kafka.support.serializer.JsonDeserializer;

@Configuration
public class KafkaConsumerConfig {

    /* 공통 Consumer 설정 */
    private Map&lt;String, Object&gt; baseConsumerProps(String groupId) {

        Map&lt;String, Object&gt; props = new HashMap&lt;&gt;();

        // Kafka Broker
        props.put(ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG, &quot;localhost:9092&quot;);

        // Consumer Group ID
        props.put(ConsumerConfig.GROUP_ID_CONFIG, groupId);

        // Key 역직렬화
        props.put(ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class);

        // Value 역직렬화
        props.put(ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG, JsonDeserializer.class);

        // 처음 실행 시 offset 정책 (earliest: consumer group이 이 토픽의 이 파티션을 지금 들어오는 새 메시지부터 읽는다)
        props.put(ConsumerConfig.AUTO_OFFSET_RESET_CONFIG, &quot;latest&quot;);

        return props;
    }

    /* HubRouteAfterCreate */
    @Bean
    public ConsumerFactory&lt;String, HubRouteAfterCreateV1&gt; hubConsumerFactory() {

        // JSON → HubRouteAfterCreateV1 역직렬화를 위한 Deserializer
        JsonDeserializer&lt;HubRouteAfterCreateV1&gt; deserializer =
            new JsonDeserializer&lt;&gt;(HubRouteAfterCreateV1.class, false);

        // Kafka 메시지 역직렬화 시 허용할 패키지를 명시적으로 지정
        deserializer.addTrustedPackages(
            &quot;chill_logistics.delivery_server.infrastructure.kafka.dto&quot;
        );

        // 위 설정값 + Key/Value Deserializer를 기반으로 실제 Consumer 인스턴스를 만들어 KafkaListener에 제공
        return new DefaultKafkaConsumerFactory&lt;&gt;(
            baseConsumerProps(&quot;delivery-server-hub-route-group&quot;),
            new StringDeserializer(),   // Key Deserializer (String)
            deserializer                // Value Deserializer (HubRouteAfterCreateV1)
        );
    }

    /* HubRouteAfterCreate */
    @Bean
    public ConcurrentKafkaListenerContainerFactory&lt;String, HubRouteAfterCreateV1&gt;
    // @KafkaListener가 사용할 Listener 컨테이너 생성 (ConsumerFactory를 주입하여 실제 Kafka Consumer 동작을 구성)
    hubKafkaListenerContainerFactory() {

        ConcurrentKafkaListenerContainerFactory&lt;String, HubRouteAfterCreateV1&gt; factory =
            new ConcurrentKafkaListenerContainerFactory&lt;&gt;();

        // ConsumerFactory 설정
        factory.setConsumerFactory(hubConsumerFactory());

        return factory;
    }

    /* OrderStatusChanged */
    @Bean
    public ConsumerFactory&lt;String, OrderStatusChangedV1&gt; orderStatusChangedConsumerFactory() {

        JsonDeserializer&lt;OrderStatusChangedV1&gt; deserializer =
            new JsonDeserializer&lt;&gt;(OrderStatusChangedV1.class, false);

        deserializer.addTrustedPackages(
            &quot;chill_logistics.delivery_server.infrastructure.kafka.dto&quot;,
            &quot;chill_logistics.delivery_server.application&quot;  // OrderStatus ENUM 역직렬화용
        );

        return new DefaultKafkaConsumerFactory&lt;&gt;(
            baseConsumerProps(&quot;delivery-server-order-status-group&quot;),
            new StringDeserializer(),
            deserializer
        );
    }

    /* OrderStatusChanged */
    @Bean
    public ConcurrentKafkaListenerContainerFactory&lt;String, OrderStatusChangedV1&gt;
    orderStatusChangedKafkaListenerContainerFactory() {

        ConcurrentKafkaListenerContainerFactory&lt;String, OrderStatusChangedV1&gt; factory =
            new ConcurrentKafkaListenerContainerFactory&lt;&gt;();

        factory.setConsumerFactory(orderStatusChangedConsumerFactory());

        return factory;
    }
}
</code></pre>
</br>

<h3 id="🔸-orderstatuschangedlistener-추가">🔸 OrderStatusChangedListener 추가</h3>
<pre><code class="language-java">package chill_logistics.delivery_server.infrastructure.kafka;

import chill_logistics.delivery_server.application.OrderStatus;
import chill_logistics.delivery_server.application.service.OrderCancellationService;
import chill_logistics.delivery_server.infrastructure.kafka.dto.OrderStatusChangedV1;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.kafka.annotation.KafkaListener;
import org.springframework.stereotype.Component;

@Slf4j
@Component
@RequiredArgsConstructor
public class OrderStatusChangedListener {

    private final OrderCancellationService orderCancellationService;

    @KafkaListener(
        topics = &quot;order-status-changed&quot;,
        groupId = &quot;delivery-server-order-status-group&quot;,
        containerFactory = &quot;orderStatusChangedKafkaListenerContainerFactory&quot;
    )
    public void listen(OrderStatusChangedV1 message) {

        log.info(&quot;Kafka 메시지 수신: {}&quot;, message);

        if (message.orderStatus() == OrderStatus.CANCELED) {
            orderCancellationService.cancelDeliveriesByOrder(
                message.orderId()
            );
        }
    }
}
</code></pre>
</br>

<h3 id="🔸-ordercancellationservice-추가">🔸 OrderCancellationService 추가</h3>
<pre><code class="language-java">package chill_logistics.delivery_server.application.service;

import chill_logistics.delivery_server.domain.entity.FirmDelivery;
import chill_logistics.delivery_server.domain.entity.HubDelivery;
import chill_logistics.delivery_server.domain.repository.FirmDeliveryRepository;
import chill_logistics.delivery_server.domain.repository.HubDeliveryRepository;
import java.util.List;
import java.util.UUID;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Slf4j
@Service
@RequiredArgsConstructor
public class OrderCancellationService {

    private final HubDeliveryRepository hubDeliveryRepository;
    private final FirmDeliveryRepository firmDeliveryRepository;

    /* [주문 취소/실패 시 배송 취소 메서드]
     * orderId 기준 HubDelivery / FirmDelivery 취소 처리
     */
    @Transactional
    public void cancelDeliveriesByOrder(UUID orderId) {

        List&lt;HubDelivery&gt; hubDeliveryList = hubDeliveryRepository.findByOrderId(orderId);
        List&lt;FirmDelivery&gt; firmDeliveryList = firmDeliveryRepository.findByOrderId(orderId);

        if (hubDeliveryList.isEmpty() &amp;&amp; firmDeliveryList.isEmpty()) {
            log.info(&quot;[주문 취소에 따른 배송 취소] 대상 배송이 없습니다. orderId={}&quot;, orderId);

            return;
        }

        for (HubDelivery hubDelivery : hubDeliveryList) {
            hubDelivery.cancelDueToOrder();
        }

        for (FirmDelivery firmDelivery : firmDeliveryList) {
            firmDelivery.cancelDueToOrder();
        }

        log.info(&quot;[주문 취소에 따른 배송 취소] 처리 완료. orderId={}&quot;, orderId);
    }
}
</code></pre>
]]></description>
        </item>
        <item>
            <title><![CDATA[12/15]]></title>
            <link>https://velog.io/@jess_kim/1215</link>
            <guid>https://velog.io/@jess_kim/1215</guid>
            <pubDate>Mon, 15 Dec 2025 02:10:56 GMT</pubDate>
            <description><![CDATA[<h2 id="🔹-delivery-server-기능-고도화-우선순위">🔹 delivery-server 기능 고도화 우선순위</h2>
<h3 id="🔸-1-주문-취소-시-트랜잭션-추가">🔸 1. 주문 취소 시 트랜잭션 추가</h3>
<p>delivery-server가 정상 Flow의 가장 마지막에 위치하기 때문에, 배송 서비스의 보상 트랜잭션 작업을 하게 되면 다른 서버에서도 추가 작업은 필수로 요하게 된다.</p>
<p>때문에, 타 서비스의 기능 고도화 작업이 이루어지는 동안, delivery-server에서는 주문 취소 시의 Flow에 대한 트랜잭션을 추가하는 작업부터 하기로 결정.</p>
<p>작업 방향:</p>
<blockquote>
<p><em><strong>order-server에서 주문 취소 이벤트 추가 발행 → delivery-server에서 해당 이벤트 구독</strong></em></p>
</blockquote>
</br>

<h3 id="🔸-2-주문-생성-후-배송-생성-실패-시-보상-트랜잭션">🔸 2. 주문 생성 후 배송 생성 실패 시 보상 트랜잭션</h3>
<p>order-server 성공 → hub-server 성공 → delivery-server 실패
이 시나리오의 보상 트랜잭션 작업 예정.</p>
<p>이 때 꼭 가져가야 할 것이 outbox/inbox이다.
outbox에서 kafka 이벤트를 발행하고, 실패 시에도 주기적인 retry를 시도할 수 있다.</p>
<p>다만, 이렇게 되면 모든 서비스에서 추가적인 작업이 필요해지기 때문에, 팀원들과 의논 후 개발 일정을 정하는 것이 좋겠다고 판단된다.</p>
<hr>
</br>

<h2 id="🔹-요약-정리">🔹 요약 정리</h2>
<p><img src="https://velog.velcdn.com/images/jess_kim/post/d3266ffc-4755-41f6-a4e8-ec76823c4604/image.png" alt=""></p>
<p><img src="https://velog.velcdn.com/images/jess_kim/post/556bec90-4d02-4bd3-aea1-0f3783d9b1ba/image.png" alt=""></p>
<blockquote>
<p><em><strong>➡️ 결론: order-server쪽 동시성 제어 하는 동안, case1의 보상 트랜잭션부터 마련하고 있자. 다음 과정으로 추후에 같이 outbox/inbox 도입하는 것이 베스트</strong></em></p>
</blockquote>
<p><del>case 3의 경우 추후에 작업 및 블로깅 예정</del></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[12/12]]></title>
            <link>https://velog.io/@jess_kim/1212</link>
            <guid>https://velog.io/@jess_kim/1212</guid>
            <pubDate>Fri, 12 Dec 2025 02:13:09 GMT</pubDate>
            <description><![CDATA[<h2 id="🔹예상-시나리오-기반-배송-보상-트랜잭션">🔹예상 시나리오 기반 배송 보상 트랜잭션</h2>
<blockquote>
<p><strong>📌 기존의 성공 예상 시나리오:</strong></p>
</blockquote>
<ul>
<li><strong>order-server:</strong> 주문 생성 (DB insert)
→ Kafka로 <strong><code>OrderAfterCreate</code></strong> 이벤트 발행</br></li>
<li><strong>hub-server:</strong>  <strong><code>OrderAfterCreate</code></strong> 구독
→ 허브 간 경로 알고리즘 + 예상 소요시간 계산
→ Kafka로 <strong><code>HubRouteAfterCreate</code></strong> 이벤트 발행</br></li>
<li><strong>delivery-server:</strong> <strong><code>HubRouteAfterCreate</code></strong> 구독
→ <strong><code>HubRouteAfterCreate</code></strong>에 들어있는 order/hub/firm 정보 + 예상 소요시간 기반 HubDelivery/FirmDelivery 생성</li>
</ul>
<blockquote>
<p><strong>📌 실패 시 보상 예상 시나리오:</strong>
주문은 생성되어 위의 플로우를 다 돌았는데, 나중에 결제 실패 혹은 유저가 주문 취소해서 orderStatus가 FAILED 또는 CANCELED로 바뀔 때</p>
</blockquote>
<ul>
<li><strong>order-server:</strong> orderStatus FAILED/CANCELED로 변경
→ <strong><code>OrderStatusChanged</code></strong> 이벤트 발행</br></li>
<li><strong>delivery-server:</strong> <strong><code>OrderStatusChanged</code></strong> 구독
→ 이미 생성된 HubDelivery/FirmDelivery 취소 처리</li>
</ul>
<p><strong>&lt; 각 이벤트 역할 &gt;</strong>
OrderAfterCreate → 배송 “생성용” 메시지
OrderStatusChangedV1 → 배송 “보상/취소용” 메시지</p>
<p>보상 트랜잭션용 추가 이벤트가 발행되어야 한다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[12/11]]></title>
            <link>https://velog.io/@jess_kim/1211</link>
            <guid>https://velog.io/@jess_kim/1211</guid>
            <pubDate>Thu, 11 Dec 2025 01:18:16 GMT</pubDate>
            <description><![CDATA[<h2 id="🔹-delivery-server-보상-트랜잭션">🔹 delivery-server 보상 트랜잭션</h2>
<p>“주문 + 배송 생성”을 하나의 비즈니스 트랜잭션으로 봤을 때 <strong>order-server 쪽 실패를 delivery-server에서 어떻게 되돌릴지</strong>를 설계하는 거니까, 사실상 작은 Saga 설계라고 생각하면서 고민해봤다.</p>
<p>아래 순서로 정리해보면:</p>
<ol>
<li><strong>어디서 실패가 나냐에 따라 케이스 나누기</strong></li>
<li><strong>간단하면서도 실용적인 보상 전략 (이벤트 기반 보상)</strong></li>
<li><strong>필수로 넣어야 할 것들: 상태 모델, 이벤트, outbox, idempotency</strong></li>
<li><strong>조금 더 진하게 가고 싶으면: Orchestrator 기반 설계 방향</strong></li>
</ol>
<hr>
</br>

<h2 id="🔹-실패-위치별로-케이스-먼저-정리">🔹 실패 위치별로 케이스 먼저 정리</h2>
<p>현재 그림(대략):</p>
<ol>
<li><code>order-server</code>에서 주문 생성</li>
<li><code>OrderAfterCreateV1</code> Kafka 발행</li>
<li><code>hub-server</code>에서 허브 경로 계산 후 <code>HubRouteAfterCreateV1</code> 발행</li>
<li><code>delivery-server</code>에서 HubDelivery / FirmDelivery 생성</li>
</ol>
<p>여기서 “order-server가 실패했다”를 조금 더 쪼개면:</p>
<h3 id="🔸-a-주문-생성-트랜잭션-안에서-실패-db-insert도-안-됨">🔸 (A) 주문 생성 트랜잭션 안에서 실패 (DB insert도 안 됨)</h3>
<ul>
<li>예: 유효성 검증 실패, DB 제약조건 위반 등으로 <strong>주문 자체가 롤백</strong>.</li>
<li>이 경우엔 <strong>애초에 <code>OrderAfterCreate</code> 이벤트가 발행되지 않도록</strong> 트랜잭션을 묶어야 함.
⇒ 보상 트랜잭션 필요 X (이벤트 안 나갔으니 delivery-server도 아무것도 안 함)</li>
</ul>
</br>

<h3 id="🔸-b-주문은-생성됐는데-이후-단계에서-비즈니스적으로-실패">🔸 (B) 주문은 생성됐는데, 이후 단계에서 비즈니스적으로 실패</h3>
<p>대표 예:</p>
<ul>
<li>결제 실패</li>
<li>사장님이 주문 직후 “바로 취소”</li>
<li>재고 체크 후 불가 판정</li>
</ul>
<p>이 경우에는 이미:</p>
<ul>
<li><code>OrderAfterCreate</code>가 발행되어,</li>
<li>허브 경로 계산 + <code>HubRouteAfterCreate</code></li>
<li>delivery-server에서 HubDelivery/FirmDelivery까지 만들어졌을 가능성이 높음.</li>
</ul>
<p>👉 이 상황에서 <strong>보상 트랜잭션 = “이미 만들어진 배송을 취소/무효화”</strong> 하는 것.</p>
<p>그래서 내가 설계해야 할 건 주로 <strong>(B) 케이스</strong>다.</p>
<hr>
</br>

<h2 id="🔹-가장-현실적인-전략-주문-상태-이벤트-기반-보상">🔹 가장 현실적인 전략: “주문 상태 이벤트 기반 보상”</h2>
<p>핵심 아이디어는 간단해:</p>
<blockquote>
<p><strong>“주문이 실패/취소로 바뀌면, 그걸 이벤트로 흘려보내고,
delivery-server는 그걸 받아서 관련 배송들을 전부 ‘취소’로 바꾼다.”</strong></p>
</blockquote>
</br>

<h3 id="🔸-주문-상태-모델-정리">🔸 주문 상태 모델 정리</h3>
<p><code>order-server</code>에서:</p>
<pre><code class="language-java">public enum OrderStatus {
    CREATED,     // 생성 완료 (아직 결제 전 혹은 준비 상태)
    CONFIRMED,   // 결제/검증까지 OK
    FAILED,      // 비즈니스적으로 실패 (결제 실패 등)
    CANCELED     // 사용자 취소
}</code></pre>
<p>그리고 주문 상태가 <code>FAILED</code>, <code>CANCELED</code>로 전이될 때마다 이벤트를 발생:</p>
<ul>
<li><code>OrderFailedV1</code></li>
<li><code>OrderCanceledV1</code></li>
</ul>
<p>이벤트 payload 예:</p>
<pre><code class="language-java">public record OrderStatusChangedV1(
    UUID orderId,
    OrderStatus status,
    String reason
) {}</code></pre>
<p>혹은 이벤트 타입을 분리해도 되고, <code>status</code> 필드로 한 번에 처리해도 됨.</p>
</br>

<h3 id="🔸-delivery-server에서의-보상-트랜잭션">🔸 delivery-server에서의 보상 트랜잭션</h3>
<p>delivery-server는 이미 <code>orderId</code> 기준으로 HubDelivery/FirmDelivery를 가지고 있으니,</p>
<p>그럼 간단하게:</p>
<pre><code class="language-java">@Service
@RequiredArgsConstructor
public class OrderCompensationService {

    private final HubDeliveryRepository hubDeliveryRepository;
    private final FirmDeliveryRepository firmDeliveryRepository;

    @Transactional
    public void compensateDeliveriesForOrder(UUID orderId, String reason) {
        // 1) 허브 배송 취소
        List&lt;HubDelivery&gt; hubDeliveries = hubDeliveryRepository.findByOrderId(orderId);
        hubDeliveries.forEach(hd -&gt; hd.cancel(reason)); // 엔티티 메서드에서 상태 변경 + 로그 남기기

        // 2) 업체 배송 취소
        List&lt;FirmDelivery&gt; firmDeliveries = firmDeliveryRepository.findByOrderId(orderId);
        firmDeliveries.forEach(fd -&gt; fd.cancel(reason));
    }
}</code></pre>
<p>그리고 Kafka Consumer 예시:</p>
<pre><code class="language-java">@KafkaListener(topics = &quot;order-status-changed&quot;)
public void handleOrderStatusChanged(OrderStatusChangedV1 event) {
    if (event.status() == OrderStatus.FAILED || event.status() == OrderStatus.CANCELED) {
        compensationService.compensateDeliveriesForOrder(event.orderId(), event.reason());
    }
}</code></pre>
</br>

<h3 id="🔸-배송-상태-enum에-취소-추가">🔸 배송 상태 Enum에 “취소” 추가</h3>
<p><code>DeliveryStatus</code>에 최소 이 정도:</p>
<pre><code class="language-java">public enum DeliveryStatus {
    CREATED,        // 생성됨
    READY,          // 출고 준비
    IN_TRANSIT,     // 이동 중
    COMPLETED,      // 배송 완료
    CANCELLED       // 주문 취소/실패로 인한 취소
}</code></pre>
<p>그리고 엔티티 메서드:</p>
<pre><code class="language-java">public void cancel(String reason) {
    if (this.status == DeliveryStatus.IN_TRANSIT || this.status == DeliveryStatus.COMPLETED) {
        // 이미 너무 진행된 건 어떻게 할지 정책 결정 필요
        return;
    }
    this.status = DeliveryStatus.CANCELLED;
    this.cancellationReason = reason; // 컬럼 추가
}</code></pre>
<p><strong>정책 포인트</strong></p>
<ul>
<li><p>이미 <code>IN_TRANSIT</code> 이상이면 어떻게 할지?</p>
<ul>
<li><code>CANCEL_REQUESTED</code> 같은 중간 상태를 둘지,</li>
<li>아니면 그냥 무시할지(“이미 나간 건 도로 못 가져온다”).</li>
</ul>
</li>
<li><p>이건 도메인 정책이니까, 나중에 고도화 단계에서 결정해도 됨.</p>
</li>
</ul>
<hr>
</br>

<h2 id="🔹-이-때-꼭-챙겨야-할-것들">🔹 이 때 꼭 챙겨야 할 것들</h2>
<h3 id="🔸-outbox-패턴-order-server-쪽">🔸 Outbox 패턴 (order-server 쪽)</h3>
<blockquote>
<p>“주문 row는 생성됐는데, 이벤트 발행이 실패하면?”
→ 또 다른 일관성 깨짐.</p>
</blockquote>
<p>그래서 order-server에서는:</p>
<ol>
<li>주문 테이블 insert/update</li>
<li>같은 트랜잭션에서 <strong>outbox 테이블</strong>에 메시지 row insert</li>
<li>별도 Outbox Publisher가 outbox 테이블에서 읽어서 Kafka로 발행 후, 성공 시 outbox row 삭제</li>
</ol>
<p>이렇게 하면:</p>
<ul>
<li>DB에 있는 상태(주문 상태)와</li>
<li>밖으로 나가는 이벤트
를 <strong>최소한 같은 DB 트랜잭션으로 보장</strong>할 수 있음.</li>
</ul>
<p>delivery-server 쪽도 중요 이벤트라면 동일하게 outbox 써도 좋고.</p>
</br>

<h3 id="🔸-보상-로직의-멱등성idempotency">🔸 보상 로직의 “멱등성(idempotency)”</h3>
<p>Kafka는 기본적으로 “at-least-once” 쪽이라
<code>OrderStatusChangedV1</code>가 중복으로 올 수 있음.</p>
<p>그러면 <code>cancel()</code> 여러 번 호출해도 문제가 없어야 함:</p>
<ul>
<li><code>status</code>가 이미 <code>CANCELLED</code>이면 그냥 return</li>
<li>이벤트 처리할 때 <strong>이벤트 ID 기준으로 처리 여부 기록</strong>하는 것도 한 방법</li>
</ul>
<p>간단하게는:</p>
<pre><code class="language-java">public void cancel(String reason) {
    if (this.status == DeliveryStatus.CANCELLED) return;
    ...
}</code></pre>
<p>이 정도만 해도 MVP/고도화 1단계에서는 충분함.</p>
<hr>
</br>

<h2 id="🔹-조금-더-제대로-하고-싶을-때-orchestrator-기반">🔹 “조금 더 제대로” 하고 싶을 때: Orchestrator 기반</h2>
<p>지금 구조는 사실상 <strong>Choreography Saga</strong>에 가깝다:</p>
<ul>
<li>order-server → 이벤트 → hub-server → 이벤트 → delivery-server</li>
</ul>
<p>근데 나중에:</p>
<ul>
<li>“주문 생성 + 허브 경로 계산 + 배송 생성 + AI + Slack/Discord”
이렇게 플로우가 더 복잡해지면,</li>
<li><strong>orchestrator-service</strong>를 하나 두고 중앙에서 단계별로 명령/보상하는 방식도 고려할 수 있다.</li>
</ul>
<p>예를 들면:</p>
<pre><code class="language-mermaid">sequenceDiagram
    participant Orchestrator
    participant Order as order-server
    participant Delivery as delivery-server

    Orchestrator-&gt;&gt;Order: createOrder()
    Order--&gt;&gt;Orchestrator: OrderCreated(orderId)

    Orchestrator-&gt;&gt;Delivery: createDeliveries(orderId)
    Delivery--&gt;&gt;Orchestrator: DeliveriesCreated

    Orchestrator-&gt;&gt;Orchestrator: Saga 완료 처리

    %% 실패 케이스
    Orchestrator-&gt;&gt;Delivery: createDeliveries(orderId)
    Delivery--&gt;&gt;Orchestrator: CreateFailed
    Orchestrator-&gt;&gt;Order: compensateOrder(orderId)  // 주문 상태 FAILED/CANCELED로</code></pre>
<p>이 패턴에서는:</p>
<ul>
<li><p>각 스텝마다 <strong>“정방향 작업” + “보상 작업”</strong>을 쌍으로 정의</p>
<ul>
<li>예: <code>createDeliveries</code> ↔ <code>cancelDeliveries</code></li>
<li>예: <code>createOrder</code> ↔ <code>markOrderFailed</code></li>
</ul>
</li>
<li><p>Orchestrator가 상태 머신처럼
“어디까지 성공했는지” 기억하고,
중간에 실패하면 역순으로 보상 호출.</p>
</li>
</ul>
<p>다만, 지금은 Kafka 이벤트 기반 설계로 많이 가고 있어서
<strong>“주문 상태 이벤트 기반 보상(위 2번 방안)”을 먼저 적용</strong>하고,
나중에 진짜 복잡해졌을 때 orchestrator-service로 승격하는 구조가 현실적인 단계 같다.</p>
<hr>
</br>

<h2 id="🔹-정리">🔹 정리</h2>
<ol>
<li><p><strong>진짜로 보상 필요한 구간은 “주문은 살아 있는데, 배송이 이미 생성된 후 주문이 실패/취소되는 구간”</strong>이다.</p>
</li>
<li><p>그때는 <strong>order-server를 진실의 근원(Single Source of Truth)</strong>로 두고,
주문이 <code>FAILED/CANCELED</code>로 바뀔 때마다 이벤트 → delivery-server에서 관련 배송 전체 <code>CANCELLED</code>로 만드는 보상 로직을 두는 게 가장 깔끔.</p>
</li>
<li><p>이를 위해:</p>
<ul>
<li>주문/배송 상태 Enum 정리</li>
<li><code>OrderStatusChanged</code> 이벤트 정의</li>
<li>delivery-server 보상 서비스 구현</li>
<li>outbox + idempotency 챙기기</li>
</ul>
</li>
<li><p>나중에 플로우가 더 복잡해지면 orchestrator-service로 승격해서
진짜 Saga 상태 머신을 도입하면 됨.</p>
</li>
</ol>
]]></description>
        </item>
        <item>
            <title><![CDATA[12/10]]></title>
            <link>https://velog.io/@jess_kim/1210</link>
            <guid>https://velog.io/@jess_kim/1210</guid>
            <pubDate>Wed, 10 Dec 2025 02:46:11 GMT</pubDate>
            <description><![CDATA[<h2 id="🔹-허브-n-row-생성--각-row-마다-배송담당자-배정">🔹 허브 N row 생성 &amp; 각 row 마다 배송담당자 배정</h2>
<pre><code class="language-java">package chill_logistics.delivery_server.application;

import chill_logistics.delivery_server.application.dto.command.AssignedDeliveryPersonV1;
import chill_logistics.delivery_server.application.dto.command.HubRouteAfterCommandV1;
import chill_logistics.delivery_server.application.dto.command.HubRouteHubInfoV1;
import chill_logistics.delivery_server.domain.entity.DeliveryStatus;
import chill_logistics.delivery_server.domain.entity.FirmDelivery;
import chill_logistics.delivery_server.domain.entity.HubDelivery;
import chill_logistics.delivery_server.domain.repository.FirmDeliveryRepository;
import chill_logistics.delivery_server.domain.repository.HubDeliveryRepository;
import chill_logistics.delivery_server.presentation.ErrorCode;
import chill_logistics.delivery_server.presentation.dto.request.DeliveryCancelRequestV1;
import chill_logistics.delivery_server.presentation.dto.request.DeliveryStatusChangeRequestV1;
import java.util.List;
import java.util.UUID;
import lib.web.error.BusinessException;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Slf4j
@Service
@RequiredArgsConstructor
public class DeliveryCommandService {

    private final HubDeliveryRepository hubDeliveryRepository;
    private final FirmDeliveryRepository firmDeliveryRepository;
    private final AsyncAiService asyncAiService;
    private final DeliveryPersonAssignmentService deliveryPersonAssignmentService;

    /* [허브 배송 생성 메서드]
     * Kafka 메시지로 order 정보 받아와서 허브 배송 1 row 생성
     * pathHubIds를 기반으로 구간 계산 후 여러 번 호출 → N row 생성
     */
    @Transactional
    public void createHubDelivery(
        HubRouteAfterCommandV1 message,
        UUID segmentStartHubId,
        String segmentStartHubName,
        String segmentStartHubFullAddress,
        UUID segmentEndHubId,
        String segmentEndHubName,
        String segmentEndHubFullAddress,
        Integer expectedDeliveryDuration,  // 첫 구간만 값, 나머지는 null
        UUID hubDeliveryPersonId,
        int deliverySequenceNum) {

        log.info(&quot;[허브 배송 생성 시작] orderId={}, deliverySequenceNum={}&quot;,
            message.orderId(), deliverySequenceNum);

        // 초기 배송 상태 셋팅
        DeliveryStatus deliveryStatus = DeliveryStatus.WAITING_FOR_HUB;

        // HubDelivery 엔티티 생성
        HubDelivery hubDelivery = HubDelivery.createFromSegment(
            message,
            segmentStartHubId,
            segmentStartHubName,
            segmentStartHubFullAddress,
            segmentEndHubId,
            segmentEndHubName,
            segmentEndHubFullAddress,
            expectedDeliveryDuration,
            hubDeliveryPersonId,
            deliverySequenceNum,
            deliveryStatus
        );

        HubDelivery savedHubDelivery = hubDeliveryRepository.save(hubDelivery);

        log.info(&quot;[허브 배송 생성 완료] hubDeliveryId={}, orderId={}, deliverySequenceNum={}&quot;,
            savedHubDelivery.getId(),
            savedHubDelivery.getOrderId(),
            savedHubDelivery.getDeliverySequenceNum());
    }

    /* [업체 배송 생성 메서드]
     * Kafka 메시지로 order 정보 받아와서 업체 배송 생성
     */
    @Transactional
    public void createFirmDelivery(
        HubRouteAfterCommandV1 message,
        UUID firmDeliveryPersonId,
        int deliverySequenceNum) {

        log.info(&quot;[업체 배송 생성 시작] orderId={}, deliverySequenceNum={}&quot;,
            message.orderId(), deliverySequenceNum);

        // 초기 배송 상태 셋팅
        DeliveryStatus deliveryStatus = DeliveryStatus.MOVING_TO_FIRM;

        List&lt;HubRouteHubInfoV1&gt; pathHubs = message.pathHubs();

        if (pathHubs == null || pathHubs.isEmpty()) {
            throw new BusinessException(ErrorCode.INVALID_HUB_ROUTE_PATH);
        }

        HubRouteHubInfoV1 lastHub = pathHubs.get(pathHubs.size() - 1);
        UUID endHubId = lastHub.hubId();

        // FirmDelivery 엔티티 생성
        FirmDelivery firmDelivery = FirmDelivery.createFrom(
            message,
            endHubId,
            firmDeliveryPersonId,
            deliverySequenceNum,
            deliveryStatus
        );

        // 업체 배송 저장
        FirmDelivery savedFirmDelivery = firmDeliveryRepository.save(firmDelivery);

        log.info(&quot;[업체 배송 생성 완료] firmDeliveryId={}, orderId={}, deliverySequenceNum={}&quot;,
            savedFirmDelivery.getId(),
            savedFirmDelivery.getOrderId(),
            savedFirmDelivery.getDeliverySequenceNum());
    }

    /* [전체 배송 생성]
     * pathHubs 기반 허브 구간 (row) 여러 개 생성 + 업체 배송 생성 = 전체 배송 생성
     * 전체 배송 생성 + AI + Discord 비동기 호출
     */
    @Transactional
    public void createDelivery(HubRouteAfterCommandV1 message) {

        log.info(&quot;[배송 생성 시작] orderId={}&quot;, message.orderId());

        List&lt;HubRouteHubInfoV1&gt; pathHubs = message.pathHubs();

        if (pathHubs == null || pathHubs.size() &lt; 2) {
            throw new BusinessException(ErrorCode.INVALID_HUB_ROUTE_PATH);
        }

        // pthHubs 기반 허브 구간 수 계산
        int hubSegmentCount = pathHubs.size() - 1;

        log.info(&quot;[허브 구간 수 계산] orderId={}, hubSegmentCount={}&quot;, message.orderId(), hubSegmentCount);

        // 업체 배송 담당자
        UUID lastHubId = pathHubs.get(pathHubs.size() - 1).hubId();
        AssignedDeliveryPersonV1 firmDeliveryPerson =
            deliveryPersonAssignmentService.assignFirmDeliveryPerson(lastHubId);

        UUID firmDeliveryPersonId = firmDeliveryPerson.userId();

        // AI 메시지에 넣어줄 첫 구간 허브배송 담당자 이름
        String firstHubDeliveryPersonName = null;

        // 허브 구간 수 만큼 HubDelivery row 생성
        for (int i = 0; i &lt; hubSegmentCount; i++) {

            // 1부터 시작
            int hubDeliverySequenceNum = i + 1;

            // 첫 허브 구간에만 총 예상 소요시간 기록, 나머지는 null
            Integer segmentExpectedDeliveryDuration =
                (i == 0) ? message.expectedDeliveryDuration() : null;

            HubRouteHubInfoV1 startHub = pathHubs.get(i);
            HubRouteHubInfoV1 endHub = pathHubs.get(i + 1);

            // 각 구간 시작 허브 기준으로 허브배송 담당자 배정
            AssignedDeliveryPersonV1 hubDeliveryPerson =
                deliveryPersonAssignmentService.assignHubDeliveryPerson(startHub.hubId());

            UUID hubDeliveryPersonId = hubDeliveryPerson.userId();
            String hubDeliveryPersonName = hubDeliveryPerson.userName();

            // 첫 구간 담당자 이름은 따로 저장 (AI용)
            if (i == 0) {
                firstHubDeliveryPersonName = hubDeliveryPersonName;
            }

            createHubDelivery(
                message,
                startHub.hubId(),
                startHub.hubName(),
                startHub.hubFullAddress(),
                endHub.hubId(),
                endHub.hubName(),
                endHub.hubFullAddress(),
                segmentExpectedDeliveryDuration,
                hubDeliveryPersonId,
                hubDeliverySequenceNum
            );
        }

        // 업체 배송 sequenceNum = 마지막 허브 구간 + 1
        int firmDeliverySequenceNum = hubSegmentCount + 1;

        createFirmDelivery(message, firmDeliveryPersonId, firmDeliverySequenceNum);

        log.info(
            &quot;[배송 생성 완료 &amp; 배송 담당자 배정 완료] orderId={}&quot;, message.orderId());

        // AI + Discord 비동기 체인 호출
        asyncAiService.sendDeadlineRequest(message, firstHubDeliveryPersonName);
    }

    /* [배송 상태 변경]
     * 허브 배송 / 업체 배송 상태 변경
     */
    @Transactional
    public void changeDeliveryStatus(UUID deliveryId, DeliveryStatusChangeRequestV1 request) {

        if (request.deliveryType() == DeliveryType.HUB) {
            HubDelivery hubDelivery = getHubDeliveryByIdOrThrow(deliveryId);

            hubDelivery.changeStatus(request.nextDeliveryStatus());
            log.info(&quot;[허브 배송 상태 변경] deliveryId={}, nextDeliveryStatus={}&quot;,
                deliveryId, request.nextDeliveryStatus());
        }

        if (request.deliveryType() == DeliveryType.FIRM) {
            FirmDelivery firmDelivery = getFirmDeliveryByIdOrThrow(deliveryId);

            firmDelivery.changeStatus(request.nextDeliveryStatus());
            log.info(&quot;[업체 배송 상태 변경] deliveryId={}, nextDeliveryStatus={}&quot;,
                deliveryId, request.nextDeliveryStatus());
        }
    }

    /* [배송 취소]
     * 허브 배송 / 업체 배송 취소
     */
    @Transactional
    public void cancelDelivery(UUID deliveryId, DeliveryCancelRequestV1 request) {

        if (request.deliveryType() == DeliveryType.HUB) {
            HubDelivery hubDelivery = getHubDeliveryByIdOrThrow(deliveryId);

            hubDelivery.cancelDelivery();

            log.info(&quot;[허브 배송 취소] deliveryId={}, orderId={}&quot;, deliveryId, hubDelivery.getOrderId());
        }

        if (request.deliveryType() == DeliveryType.FIRM) {
            FirmDelivery firmDelivery = getFirmDeliveryByIdOrThrow(deliveryId);

            firmDelivery.cancelDelivery();

            log.info(&quot;[업체 배송 취소] deliveryId={}, orderId={}&quot;, deliveryId, firmDelivery.getOrderId());
        }
    }

    private HubDelivery getHubDeliveryByIdOrThrow(UUID hubDeliveryId) {

        return hubDeliveryRepository.findById(hubDeliveryId)
            .orElseThrow(() -&gt; new BusinessException(ErrorCode.HUB_DELIVERY_NOT_FOUND));
    }

    private FirmDelivery getFirmDeliveryByIdOrThrow(UUID firmDeliveryId) {

        return firmDeliveryRepository.findById(firmDeliveryId)
            .orElseThrow(() -&gt; new BusinessException(ErrorCode.FIRM_DELIVERY_NOT_FOUND));
    }
}
</code></pre>
]]></description>
        </item>
        <item>
            <title><![CDATA[12/9]]></title>
            <link>https://velog.io/@jess_kim/129</link>
            <guid>https://velog.io/@jess_kim/129</guid>
            <pubDate>Tue, 09 Dec 2025 02:24:35 GMT</pubDate>
            <description><![CDATA[<h2 id="🔹-deliverysequencenum-계산-기능-구현">🔹 deliverySequenceNum 계산 기능 구현</h2>
<h3 id="🔸-허브-배송-단일-row-생성-방식">🔸 허브 배송 단일 row 생성 방식</h3>
<pre><code class="language-java">package chill_logistics.delivery_server.application;

import chill_logistics.delivery_server.application.dto.command.AssignedDeliveryPersonV1;
import chill_logistics.delivery_server.application.dto.command.HubRouteAfterCommandV1;
import chill_logistics.delivery_server.domain.entity.DeliveryStatus;
import chill_logistics.delivery_server.domain.entity.FirmDelivery;
import chill_logistics.delivery_server.domain.entity.HubDelivery;
import chill_logistics.delivery_server.domain.repository.FirmDeliveryRepository;
import chill_logistics.delivery_server.domain.repository.HubDeliveryRepository;
import chill_logistics.delivery_server.presentation.ErrorCode;
import chill_logistics.delivery_server.presentation.dto.request.DeliveryCancelRequestV1;
import chill_logistics.delivery_server.presentation.dto.request.DeliveryStatusChangeRequestV1;
import java.util.List;
import java.util.UUID;
import lib.web.error.BusinessException;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Slf4j
@Service
@RequiredArgsConstructor
public class DeliveryCommandService {

    private final HubDeliveryRepository hubDeliveryRepository;
    private final FirmDeliveryRepository firmDeliveryRepository;
    private final AsyncAiService asyncAiService;
    private final DeliveryPersonAssignmentService deliveryPersonAssignmentService;

    /* [허브 배송 생성 메서드]
     * Kafka 메시지로 order 정보 받아와서 허브 배송 생성
     */
    @Transactional
    public void createHubDelivery(
        HubRouteAfterCommandV1 message,
        UUID hubDeliveryPersonId,
        int deliverySequenceNum) {

        log.info(&quot;[허브 배송 생성 시작] orderId={}, deliverySequenceNum={}&quot;,
            message.orderId(), deliverySequenceNum);

        // 초기 배송 상태 셋팅
        DeliveryStatus deliveryStatus = DeliveryStatus.WAITING_FOR_HUB;

        // HubDelivery 엔티티 생성
        HubDelivery hubDelivery = HubDelivery.createFrom(
            message,
            hubDeliveryPersonId,
            deliverySequenceNum,
            deliveryStatus
        );

        // 허브 배송 저장
        HubDelivery savedHubDelivery = hubDeliveryRepository.save(hubDelivery);

        log.info(&quot;[허브 배송 생성 완료] hubDeliveryId={}, orderId={}, deliverySequenceNum={}&quot;,
            savedHubDelivery.getId(),
            savedHubDelivery.getOrderId(),
            savedHubDelivery.getDeliverySequenceNum());
    }

    /* [업체 배송 생성 메서드]
     * Kafka 메시지로 order 정보 받아와서 업체 배송 생성
     */
    @Transactional
    public void createFirmDelivery(
        HubRouteAfterCommandV1 message,
        UUID firmDeliveryPersonId,
        int deliverySequenceNum) {

        log.info(&quot;[업체 배송 생성 시작] orderId={}, deliverySequenceNum={}&quot;,
            message.orderId(), deliverySequenceNum);

        // 초기 배송 상태 셋팅
        DeliveryStatus deliveryStatus = DeliveryStatus.MOVING_TO_FIRM;

        // FirmDelivery 엔티티 생성
        FirmDelivery firmDelivery = FirmDelivery.createFrom(
            message,
            firmDeliveryPersonId,
            deliverySequenceNum,
            deliveryStatus
        );

        // 업체 배송 저장
        FirmDelivery savedFirmDelivery = firmDeliveryRepository.save(firmDelivery);

        log.info(&quot;[업체 배송 생성 완료] firmDeliveryId={}, orderId={}, deliverySequenceNum={}&quot;,
            savedFirmDelivery.getId(),
            savedFirmDelivery.getOrderId(),
            savedFirmDelivery.getDeliverySequenceNum());
    }

    /* [전체 배송 생성]
     * 허브 배송 + 업체 배송 = 전체 배송 생성
     * 전체 배송 생성 + AI + Discord 비동기 호출
     */
    @Transactional
    public void createDelivery(HubRouteAfterCommandV1 message) {

        log.info(&quot;[배송 생성 시작] orderId={}&quot;, message.orderId());

        // 허브 배송 담당자 배정
        AssignedDeliveryPersonV1 hubDeliveryPerson =
            deliveryPersonAssignmentService.assignHubDeliveryPerson();

        // 업체 배송 담당자 배정
        AssignedDeliveryPersonV1 firmDeliveryPerson =
            deliveryPersonAssignmentService.assignFirmDeliveryPerson();

        UUID hubDeliveryPersonId = hubDeliveryPerson.userId();
        String hubDeliveryPersonName = hubDeliveryPerson.userName();
        UUID firmDeliveryPersonId = firmDeliveryPerson.userId();

        // pathHubIds 기반 deliverySequenceNum 계산
        int hubDeliverySequenceNum = calculateHubSequenceNum(message.pathHubIds());
        int firmDeliverySequenceNum = hubDeliverySequenceNum + 1;

        createHubDelivery(message, hubDeliveryPersonId, hubDeliverySequenceNum);
        createFirmDelivery(message, firmDeliveryPersonId, firmDeliverySequenceNum);

        log.info(
            &quot;[배송 생성 완료 &amp; 배송 담당자 배정 완료] orderId={}, hubDeliveryPersonId={}, firmDeliveryPersonId={}&quot;,
            message.orderId(), hubDeliveryPersonId, firmDeliveryPersonId);

        // AI + Discord 비동기 체인 호출
        asyncAiService.sendDeadlineRequest(message, hubDeliveryPersonName);
    }

    /* [배송 상태 변경]
     * 허브 배송 / 업체 배송 상태 변경
     */
    @Transactional
    public void changeDeliveryStatus(UUID deliveryId, DeliveryStatusChangeRequestV1 request) {

        if (request.deliveryType() == DeliveryType.HUB) {
            HubDelivery hubDelivery = getHubDeliveryByIdOrThrow(deliveryId);

            hubDelivery.changeStatus(request.nextDeliveryStatus());
            log.info(&quot;[허브 배송 상태 변경] deliveryId={}, nextDeliveryStatus={}&quot;,
                deliveryId, request.nextDeliveryStatus());
        }

        if (request.deliveryType() == DeliveryType.FIRM) {
            FirmDelivery firmDelivery = getFirmDeliveryByIdOrThrow(deliveryId);

            firmDelivery.changeStatus(request.nextDeliveryStatus());
            log.info(&quot;[업체 배송 상태 변경] deliveryId={}, nextDeliveryStatus={}&quot;,
                deliveryId, request.nextDeliveryStatus());
        }
    }

    /* [배송 취소]
     * 허브 배송 / 업체 배송 취소
     */
    @Transactional
    public void cancelDelivery(UUID deliveryId, DeliveryCancelRequestV1 request) {

        if (request.deliveryType() == DeliveryType.HUB) {
            HubDelivery hubDelivery = getHubDeliveryByIdOrThrow(deliveryId);

            hubDelivery.cancelDelivery();

            log.info(&quot;[허브 배송 취소] deliveryId={}, orderId={}&quot;, deliveryId, hubDelivery.getOrderId());
        }

        if (request.deliveryType() == DeliveryType.FIRM) {
            FirmDelivery firmDelivery = getFirmDeliveryByIdOrThrow(deliveryId);

            firmDelivery.cancelDelivery();

            log.info(&quot;[업체 배송 취소] deliveryId={}, orderId={}&quot;, deliveryId, firmDelivery.getOrderId());
        }
    }

    private HubDelivery getHubDeliveryByIdOrThrow(UUID hubDeliveryId) {

        return hubDeliveryRepository.findById(hubDeliveryId)
            .orElseThrow(() -&gt; new BusinessException(ErrorCode.HUB_DELIVERY_NOT_FOUND));
    }

    private FirmDelivery getFirmDeliveryByIdOrThrow(UUID firmDeliveryId) {

        return firmDeliveryRepository.findById(firmDeliveryId)
            .orElseThrow(() -&gt; new BusinessException(ErrorCode.FIRM_DELIVERY_NOT_FOUND));
    }

    /* [pathHubIds 기반 허브 배송 sequenceNum 계산 메서드]
     * 허브 경로가 순서대로 들어있는 pathHybIds [hub1, hub2, hub3] 가 있는 경우
     * 허브 구간 수 = 2 (hub1→hub2, hub2→hub3)
     * 허브 간 이동 구간 수 = pathHubIds.size() - 1
     * 허브 간 이동 구간 수 없거나 1개 이하면 1로 처리
     * 업체 배송 시퀀스 번호 = hubSequence + 1 (항상 허브 배송 뒤의 마지막 단계이므로)
     */
    private int calculateHubSequenceNum(List&lt;UUID&gt; pathHubIds) {

        // 허브 정보 없거나, 경유 허브 없는 경우 최소 1
        if (pathHubIds == null || pathHubIds.size() &lt; 2) {
            return 1;
        }

        // 허브 간 이동 구간 수 = 노드 수 - 1
        return pathHubIds.size() - 1;
    }
}
</code></pre>
</br>

<h3 id="🔸-허브-배송-1-row-→-n-row-생성-방식">🔸 허브 배송 1 row → N row 생성 방식</h3>
<pre><code class="language-java">package chill_logistics.delivery_server.application;

import chill_logistics.delivery_server.application.dto.command.AssignedDeliveryPersonV1;
import chill_logistics.delivery_server.application.dto.command.HubRouteAfterCommandV1;
import chill_logistics.delivery_server.application.dto.command.HubRouteHubInfoV1;
import chill_logistics.delivery_server.domain.entity.DeliveryStatus;
import chill_logistics.delivery_server.domain.entity.FirmDelivery;
import chill_logistics.delivery_server.domain.entity.HubDelivery;
import chill_logistics.delivery_server.domain.repository.FirmDeliveryRepository;
import chill_logistics.delivery_server.domain.repository.HubDeliveryRepository;
import chill_logistics.delivery_server.presentation.ErrorCode;
import chill_logistics.delivery_server.presentation.dto.request.DeliveryCancelRequestV1;
import chill_logistics.delivery_server.presentation.dto.request.DeliveryStatusChangeRequestV1;
import java.util.List;
import java.util.UUID;
import lib.web.error.BusinessException;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Slf4j
@Service
@RequiredArgsConstructor
public class DeliveryCommandService {

    private final HubDeliveryRepository hubDeliveryRepository;
    private final FirmDeliveryRepository firmDeliveryRepository;
    private final AsyncAiService asyncAiService;
    private final DeliveryPersonAssignmentService deliveryPersonAssignmentService;

    /* [허브 배송 생성 메서드]
     * Kafka 메시지로 order 정보 받아와서 허브 배송 1 row 생성
     * pathHubIds를 기반으로 구간 계산 후 여러 번 호출 → N row 생성
     */
    @Transactional
    public void createHubDelivery(
        HubRouteAfterCommandV1 message,
        UUID segmentStartHubId,
        String segmentStartHubName,
        String segmentStartHubFullAddress,
        UUID segmentEndHubId,
        String segmentEndHubName,
        String segmentEndHubFullAddress,
        Integer expectedDeliveryDuration,  // 첫 구간만 값, 나머지는 null
        UUID hubDeliveryPersonId,
        int deliverySequenceNum) {

        log.info(&quot;[허브 배송 생성 시작] orderId={}, deliverySequenceNum={}&quot;,
            message.orderId(), deliverySequenceNum);

        // 초기 배송 상태 셋팅
        DeliveryStatus deliveryStatus = DeliveryStatus.WAITING_FOR_HUB;

        // HubDelivery 엔티티 생성
        HubDelivery hubDelivery = HubDelivery.createFromSegment(
            message,
            segmentStartHubId,
            segmentStartHubName,
            segmentStartHubFullAddress,
            segmentEndHubId,
            segmentEndHubName,
            segmentEndHubFullAddress,
            expectedDeliveryDuration,
            hubDeliveryPersonId,
            deliverySequenceNum,
            deliveryStatus
        );

        HubDelivery savedHubDelivery = hubDeliveryRepository.save(hubDelivery);

        log.info(&quot;[허브 배송 생성 완료] hubDeliveryId={}, orderId={}, deliverySequenceNum={}&quot;,
            savedHubDelivery.getId(),
            savedHubDelivery.getOrderId(),
            savedHubDelivery.getDeliverySequenceNum());
    }

    /* [업체 배송 생성 메서드]
     * Kafka 메시지로 order 정보 받아와서 업체 배송 생성
     */
    @Transactional
    public void createFirmDelivery(
        HubRouteAfterCommandV1 message,
        UUID firmDeliveryPersonId,
        int deliverySequenceNum) {

        log.info(&quot;[업체 배송 생성 시작] orderId={}, deliverySequenceNum={}&quot;,
            message.orderId(), deliverySequenceNum);

        // 초기 배송 상태 셋팅
        DeliveryStatus deliveryStatus = DeliveryStatus.MOVING_TO_FIRM;

        List&lt;HubRouteHubInfoV1&gt; pathHubs = message.pathHubs();

        if (pathHubs == null || pathHubs.isEmpty()) {
            throw new BusinessException(ErrorCode.INVALID_HUB_ROUTE_PATH);
        }

        HubRouteHubInfoV1 lastHub = pathHubs.get(pathHubs.size() - 1);
        UUID endHubId = lastHub.hubId();

        // FirmDelivery 엔티티 생성
        FirmDelivery firmDelivery = FirmDelivery.createFrom(
            message,
            endHubId,
            firmDeliveryPersonId,
            deliverySequenceNum,
            deliveryStatus
        );

        // 업체 배송 저장
        FirmDelivery savedFirmDelivery = firmDeliveryRepository.save(firmDelivery);

        log.info(&quot;[업체 배송 생성 완료] firmDeliveryId={}, orderId={}, deliverySequenceNum={}&quot;,
            savedFirmDelivery.getId(),
            savedFirmDelivery.getOrderId(),
            savedFirmDelivery.getDeliverySequenceNum());
    }

    /* [전체 배송 생성]
     * pathHubs 기반 허브 구간 (row) 여러 개 생성 + 업체 배송 생성 = 전체 배송 생성
     * 전체 배송 생성 + AI + Discord 비동기 호출
     */
    @Transactional
    public void createDelivery(HubRouteAfterCommandV1 message) {

        log.info(&quot;[배송 생성 시작] orderId={}&quot;, message.orderId());

        // 허브 배송 담당자 배정
        AssignedDeliveryPersonV1 hubDeliveryPerson =
            deliveryPersonAssignmentService.assignHubDeliveryPerson();

        // 업체 배송 담당자 배정
        AssignedDeliveryPersonV1 firmDeliveryPerson =
            deliveryPersonAssignmentService.assignFirmDeliveryPerson();

        UUID hubDeliveryPersonId = hubDeliveryPerson.userId();
        String hubDeliveryPersonName = hubDeliveryPerson.userName();
        UUID firmDeliveryPersonId = firmDeliveryPerson.userId();

        List&lt;HubRouteHubInfoV1&gt; pathHubs = message.pathHubs();

        if (pathHubs == null || pathHubs.size() &lt; 2) {
            throw new BusinessException(ErrorCode.INVALID_HUB_ROUTE_PATH);
        }

        // pthHubs 기반 허브 구간 수 계산
        int hubSegmentCount = pathHubs.size() - 1;

        log.info(&quot;[허브 구간 수 계산] orderId={}, hubSegmentCount={}&quot;, message.orderId(), hubSegmentCount);

        // 허브 구간 수 만큼 HubDelivery row 생성
        for (int i = 0; i &lt; hubSegmentCount; i++) {

            // 1부터 시작
            int hubDeliverySequenceNum = i + 1;

            // 첫 허브 구간에만 총 예상 소요시간 기록, 나머지는 null
            Integer segmentExpectedDeliveryDuration =
                (i == 0) ? message.expectedDeliveryDuration() : null;

            HubRouteHubInfoV1 startHub = pathHubs.get(i);
            HubRouteHubInfoV1 endHub = pathHubs.get(i + 1);

            createHubDelivery(
                message,
                startHub.hubId(),
                startHub.hubName(),
                startHub.hubFullAddress(),
                endHub.hubId(),
                endHub.hubName(),
                endHub.hubFullAddress(),
                segmentExpectedDeliveryDuration,
                hubDeliveryPersonId,
                hubDeliverySequenceNum
            );
        }

        // 업체 배송 sequenceNum = 마지막 허브 구간 + 1
        int firmDeliverySequenceNum = hubSegmentCount + 1;

        createFirmDelivery(message, firmDeliveryPersonId, firmDeliverySequenceNum);

        log.info(
            &quot;[배송 생성 완료 &amp; 배송 담당자 배정 완료] orderId={}, hubDeliveryPersonId={}, firmDeliveryPersonId={}&quot;,
            message.orderId(), hubDeliveryPersonId, firmDeliveryPersonId);

        // AI + Discord 비동기 체인 호출
        asyncAiService.sendDeadlineRequest(message, hubDeliveryPersonName);
    }

    /* [배송 상태 변경]
     * 허브 배송 / 업체 배송 상태 변경
     */
    @Transactional
    public void changeDeliveryStatus(UUID deliveryId, DeliveryStatusChangeRequestV1 request) {

        if (request.deliveryType() == DeliveryType.HUB) {
            HubDelivery hubDelivery = getHubDeliveryByIdOrThrow(deliveryId);

            hubDelivery.changeStatus(request.nextDeliveryStatus());
            log.info(&quot;[허브 배송 상태 변경] deliveryId={}, nextDeliveryStatus={}&quot;,
                deliveryId, request.nextDeliveryStatus());
        }

        if (request.deliveryType() == DeliveryType.FIRM) {
            FirmDelivery firmDelivery = getFirmDeliveryByIdOrThrow(deliveryId);

            firmDelivery.changeStatus(request.nextDeliveryStatus());
            log.info(&quot;[업체 배송 상태 변경] deliveryId={}, nextDeliveryStatus={}&quot;,
                deliveryId, request.nextDeliveryStatus());
        }
    }

    /* [배송 취소]
     * 허브 배송 / 업체 배송 취소
     */
    @Transactional
    public void cancelDelivery(UUID deliveryId, DeliveryCancelRequestV1 request) {

        if (request.deliveryType() == DeliveryType.HUB) {
            HubDelivery hubDelivery = getHubDeliveryByIdOrThrow(deliveryId);

            hubDelivery.cancelDelivery();

            log.info(&quot;[허브 배송 취소] deliveryId={}, orderId={}&quot;, deliveryId, hubDelivery.getOrderId());
        }

        if (request.deliveryType() == DeliveryType.FIRM) {
            FirmDelivery firmDelivery = getFirmDeliveryByIdOrThrow(deliveryId);

            firmDelivery.cancelDelivery();

            log.info(&quot;[업체 배송 취소] deliveryId={}, orderId={}&quot;, deliveryId, firmDelivery.getOrderId());
        }
    }

    private HubDelivery getHubDeliveryByIdOrThrow(UUID hubDeliveryId) {

        return hubDeliveryRepository.findById(hubDeliveryId)
            .orElseThrow(() -&gt; new BusinessException(ErrorCode.HUB_DELIVERY_NOT_FOUND));
    }

    private FirmDelivery getFirmDeliveryByIdOrThrow(UUID firmDeliveryId) {

        return firmDeliveryRepository.findById(firmDeliveryId)
            .orElseThrow(() -&gt; new BusinessException(ErrorCode.FIRM_DELIVERY_NOT_FOUND));
    }
}
</code></pre>
]]></description>
        </item>
        <item>
            <title><![CDATA[12/8]]></title>
            <link>https://velog.io/@jess_kim/128</link>
            <guid>https://velog.io/@jess_kim/128</guid>
            <pubDate>Mon, 08 Dec 2025 04:07:26 GMT</pubDate>
            <description><![CDATA[<h2 id="🔹-배송-담당자-배정-기능-구현">🔹 배송 담당자 배정 기능 구현</h2>
<p>비교적 간단한 roundRobin 방식으로 먼저 구현을 해보았다.</p>
<pre><code class="language-java">package chill_logistics.delivery_server.application;

import chill_logistics.delivery_server.application.dto.command.AssignedDeliveryPersonV1;
import chill_logistics.delivery_server.infrastructure.user.UserClient;
import chill_logistics.delivery_server.infrastructure.user.dto.UserForDeliveryResponseV1;
import chill_logistics.delivery_server.presentation.ErrorCode;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicLong;
import lib.web.error.BusinessException;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;

@Service
@RequiredArgsConstructor
public class DeliveryPersonAssignmentService {

    private static final String ROLE_HUB_DELIVERY_PERSON = &quot;HUB_DELIVERY_PERSON&quot;;
    private static final String ROLE_FIRM_DELIVERY_PERSON = &quot;FIRM_DELIVERY_PERSON&quot;;

    private final UserClient userClient;

    // 역할별 라운드 로빈 인덱스 저장 (멀티스레드 환경 대응)
    private final Map&lt;String, AtomicLong&gt; roundRobinIndex = new ConcurrentHashMap&lt;&gt;();

    // 허브 배송 담당자 배정
    public AssignedDeliveryPersonV1 assignHubDeliveryPerson() {
        return assignByRole(ROLE_HUB_DELIVERY_PERSON);
    }

    // 업체 배송 담당자 배정
    public AssignedDeliveryPersonV1 assignFirmDeliveryPerson() {
        return assignByRole(ROLE_FIRM_DELIVERY_PERSON);
    }

    private AssignedDeliveryPersonV1 assignByRole(String role) {

        // user-server에서 role 별 유저 목록 조회
        List&lt;UserForDeliveryResponseV1&gt; deliveryCandidates = userClient.getUsersByRole(role);

        if (deliveryCandidates == null || deliveryCandidates.isEmpty()) {
            throw new BusinessException(ErrorCode.DELIVERT_PERSON_NOT_FOUND);
        }

        // 해당 role에 대한 라운드 로빈 인덱스 가져오고, 없으면 생성
        AtomicLong counter = roundRobinIndex.computeIfAbsent(role, key -&gt; new AtomicLong(0L));

        // 현재 인덱스를 가져오고 증가시키는 연산
        long index = counter.incrementAndGet();

        // 후보자 수로 나머지 연산하려 실제 선택 위치 계산
        int selectedIndex = (int) (index % deliveryCandidates.size());

        // 최종 선택된 담당자
        UserForDeliveryResponseV1 chosen = deliveryCandidates.get(selectedIndex);

        return new AssignedDeliveryPersonV1(
            chosen.userId(),
            chosen.userName()
        );
    }
}
</code></pre>
]]></description>
        </item>
        <item>
            <title><![CDATA[12/5]]></title>
            <link>https://velog.io/@jess_kim/125</link>
            <guid>https://velog.io/@jess_kim/125</guid>
            <pubDate>Fri, 05 Dec 2025 02:31:53 GMT</pubDate>
            <description><![CDATA[<h2 id="🔹-async-실전-적용">🔹 Async 실전 적용</h2>
<blockquote>
<p>📌 시나리오:</p>
</blockquote>
<ul>
<li><strong>DeliveryCommandService</strong> 가 허브/업체 배송을 생성하고</li>
<li>동시에 <strong>AsyncAiService</strong> 를 통해</li>
<li><strong>OpenAiClient(AiClient 구현체)</strong> 에게 “이 주문, 언제까지 보내야 하는지 + Discord 메시지” 생성을 시키고</li>
<li>그 결과를 <strong>AiDeadlineResponseV1</strong> 로 받아서 (지금은 로그만 찍고, 나중에 Discord 서비스로 보낼 예정)</li>
<li>이 전체 흐름에서 프롬프트 템플릿은 <strong>OpenAiConstants.DISCORD_FORMATTED_DEADLINE_PROMPT</strong> 가 담당.</li>
</ul>
</br>

<h3 id="🔸-hubrouteaftercommandv1-메시지-수신">🔸 HubRouteAfterCommandV1 메시지 수신</h3>
<p>Kafka 리스너로 <code>HubRouteAfterCommandV1</code> 라는 DTO를 들고 <code>DeliveryCommandService.createDelivery(...)</code> 를 호출.</p>
</br>

<h3 id="🔸-deliverycommandservice">🔸 DeliveryCommandService</h3>
<p>DeliveryCommandService.createDelivery(...) 호출</p>
<pre><code class="language-java">   @Transactional
   public void createDelivery(
       HubRouteAfterCommandV1 message,
       UUID hubDeliveryPersonId,
       UUID firmDeliveryPersonId) {

       log.info(&quot;[배송 생성 시작] orderId={}&quot;, message.orderId());

       createHubDelivery(message, hubDeliveryPersonId);  // 허브 배송 생성
       createFirmDelivery(message, firmDeliveryPersonId); // 업체 배송 생성

       log.info(&quot;[배송 생성 완료] orderId={}&quot;, message.orderId());

       asyncAiService.sendDeadlineRequest(message); // ★ AI 비동기 호출
   }</code></pre>
<ul>
<li><strong>DB 트랜잭션 안에서</strong> 허브 배송 + 업체 배송 엔티티를 각각 생성 &amp; 저장.</li>
<li>그 후 <strong>AsyncAiService.sendDeadlineRequest(message)</strong> 로 AI 호출을 비동기로 날림.</li>
</ul>
</br>

<h3 id="🔸-asyncaiservice">🔸 AsyncAiService</h3>
<p>AsyncAiService.sendDeadlineRequest(...)</p>
<pre><code class="language-java">   @Async
   public void sendDeadlineRequest(HubRouteAfterCommandV1 message) {
       log.info(&quot;[AI 비동기 호출 시작] orderId={}&quot;, message.orderId());

       AiDeadlineRequestV1 request = new AiDeadlineRequestV1(
           message.orderId(),
           message.startHubId(),
           message.startHubName(),
           message.startHubFullAddress(),
           message.endHubId(),
           message.endHubName(),
           message.endHubFullAddress(),
           message.receiverFirmId(),
           message.receiverFirmFullAddress(),
           message.receiverFirmOwnerName(),
           message.requestNote(),
           message.productName(),
           message.productQuantity(),
           message.orderCreatedAt(),
           message.expectedDeliveryDuration()
       );

       AiDeadlineResponseV1 response = aiClient.generateDeadlineMessage(request);

       log.info(&quot;[AI 결과] orderId={}, finalDeadline={}&quot;, message.orderId(),response.finalDeadline());

       // TODO: Discord 서비스로 response.discordMessage() 전달
   }</code></pre>
<ul>
<li><code>@Async</code> 덕분에 <strong>별도의 스레드에서 실행</strong> → 배송 생성 트랜잭션과 분리.</li>
<li><code>HubRouteAfterCommandV1</code> → <code>AiDeadlineRequestV1</code> 로 변환 (AI에게 줄 데이터 정제).</li>
<li><code>AiClient.generateDeadlineMessage(request)</code> 호출 → 실제 OpenAI 호출은 구현체(OpenAiClient)가 담당.</li>
<li>AI 응답으로 받은 <code>AiDeadlineResponseV1</code> 에서 <code>finalDeadline</code> 을 로그에 남김.</li>
<li>나중에는 <code>response.discordMessage()</code> 를 Discord 서비스에 보내서 실제 DM/알림으로 사용할 예정.</li>
</ul>
</br>

<h3 id="🔸-openaiclient">🔸 OpenAiClient</h3>
<p>OpenAiClient.generateDeadlineMessage(...)</p>
<pre><code class="language-java">   @Override
   public AiDeadlineResponseV1 generateDeadlineMessage(AiDeadlineRequestV1 request) {

       String prompt = buildPrompt(request);

       log.info(&quot;[AI 요청] 배송 최종 발송 시한 계산, orderId={}&quot;, request.orderId());

       String outputText = chatClient
           .prompt()
           .user(prompt)
           .call()
           .content();

       log.info(&quot;[AI 응답 수신] 배송 최종 발송 시한 계산 완료, orderId={}&quot;, request.orderId());

       String finalDeadline = extractDeadline(outputText);

       return new AiDeadlineResponseV1(outputText, finalDeadline);
   }</code></pre>
<ul>
<li><p><code>buildPrompt(request)</code> 로 <strong>거대한 텍스트 프롬프트</strong> 생성.</p>
</li>
<li><p><code>chatClient.prompt().user(prompt).call().content()</code> 로 <strong>Spring AI의 ChatClient</strong> 를 통해 OpenAI 호출.</p>
</li>
<li><p>전체 응답(<code>outputText</code>)에서 <code>extractDeadline(...)</code> 으로 <code>&quot;yyyy-MM-dd HH:mm&quot;</code> 형식의 <strong>최종 발송 시한만 파싱</strong>.</p>
</li>
<li><p><code>AiDeadlineResponseV1</code> 에</p>
<ul>
<li><code>discordMessage</code>: Discord 코드블록 그대로</li>
<li><code>finalDeadline</code>: 파싱된 마지막 줄 시간만
담아서 리턴.</li>
</ul>
</li>
</ul>
</br>

<h3 id="🔸-openaiconstants">🔸 OpenAiConstants</h3>
<p>OpenAiConstants.DISCORD_FORMATTED_DEADLINE_PROMPT</p>
<ul>
<li>AI에게 전달하는 <strong>전체 템플릿 문자열</strong>.</li>
<li>예시 1, 2, 3 을 포함해서 <strong>“이런 형식으로 Discord 코드블록을 만들어라”</strong> 를 강하게 가이드.</li>
<li>마지막에 실제 주문 정보 위치에 <code>&quot;%s&quot; / &quot;%d&quot;</code> 자리로 템플릿 변수를 만들어 두고,</li>
<li><code>OpenAiClient.buildPrompt(...)</code> 에서 <code>formatted(...)</code> 호출로 실제 값 채워 넣음.</li>
</ul>
<hr>
</br>

<h2 id="🔹-각-클래스의-역할">🔹 각 클래스의 역할</h2>
<h3 id="🔸-openaiconstants-1">🔸 <code>OpenAiConstants</code></h3>
<ul>
<li><p><strong>순수 상수 모음 클래스</strong>.</p>
</li>
<li><p>생성자를 <code>private</code> 으로 막아서 인스턴스 생성 불가.</p>
</li>
<li><p>현재 역할:</p>
<ul>
<li><strong>Discord용 메시지 + 발송 시한 계산 규칙 전체를 담은 프롬프트 템플릿</strong> 제공.</li>
<li>예시 1, 2, 3 포함.</li>
<li>마지막 부분에 실제 주문 정보 <code>%s</code>, <code>%d</code> 자리 (<code>formatted()</code>로 채움)를 제공.</li>
</ul>
</li>
</ul>
</br>

<h3 id="🔸-aiclient-인터페이스">🔸 <code>AiClient</code> (인터페이스)</h3>
<pre><code class="language-java">public interface AiClient {
    AiDeadlineResponseV1 generateDeadlineMessage(AiDeadlineRequestV1 request);
}</code></pre>
<ul>
<li><p>AI 호출을 추상화한 <strong>인터페이스</strong>.</p>
</li>
<li><p>장점:</p>
<ul>
<li><p>나중에 OpenAI가 아니라 <strong>다른 모델/다른 구현체</strong> 로 교체해도</p>
<ul>
<li><code>AsyncAiService</code> 입장에서는 <code>AiClient</code> 만 보면 됨.</li>
</ul>
</li>
<li><p>테스트 시에는 <strong>FakeAiClient / StubAiClient</strong> 를 주입하기 쉬움.</p>
</li>
</ul>
</li>
</ul>
</br>

<h3 id="🔸-openaiclient-aiclient-구현체">🔸 <code>OpenAiClient</code> (AiClient 구현체)</h3>
<ul>
<li><p><code>AiClient</code> 의 <strong>실제 구현체</strong>, Spring Bean (<code>@Component</code>).</p>
</li>
<li><p><code>ChatClient</code> (Spring AI)를 주입받아서 OpenAI와 통신.</p>
</li>
<li><p><code>buildPrompt(...)</code>:</p>
<ul>
<li><p><code>request</code> 를 보고</p>
<ul>
<li><code>requestNote</code> 가 비어 있으면 <code>&quot;없음&quot;</code> 으로 치환.</li>
<li>경유지(route)는 아직 <code>&quot;없음&quot;</code> 으로 고정 (TODO 주석 있음).</li>
</ul>
</li>
<li><p><code>OpenAiConstants.DISCORD_FORMATTED_DEADLINE_PROMPT.formatted(...)</code> 로 <strong>실제 주문 값 바인딩</strong>.</p>
</li>
</ul>
</li>
<li><p><code>extractDeadline(...)</code>:</p>
<ul>
<li>줄 단위로 쪼갠 뒤</li>
<li><code>&quot;위 내용을 기반으로 도출된 최종 발송 시한은&quot;</code> 으로 시작하는 줄을 찾아서</li>
<li>앞/뒤 고정 문구를 제거 → <code>&quot;yyyy-MM-dd HH:mm&quot;</code> 부분만 남김.</li>
<li>찾지 못하면 <code>null</code>.</li>
</ul>
</li>
</ul>
</br>

<h3 id="🔸-asyncaiservice-1">🔸 <code>AsyncAiService</code></h3>
<ul>
<li><p><strong>애플리케이션 레벨에서 AI 호출을 담당하는 비동기 서비스</strong>.</p>
</li>
<li><p>역할:</p>
<ol>
<li><code>HubRouteAfterCommandV1</code> → <code>AiDeadlineRequestV1</code> 로 매핑.</li>
<li><code>AiClient</code> 를 통해 실제 AI 호출.</li>
<li>결과 로그 남기고, 나중에 Discord 서비스로 메시지 전달 예정.</li>
</ol>
</li>
<li><p><code>@Async</code> 덕분에:</p>
<ul>
<li><code>DeliveryCommandService.createDelivery(...)</code> 처리가 <strong>AI 응답을 기다리지 않고 바로 끝날 수 있음</strong>.</li>
<li>배송 생성 로직과 AI 호출을 느슨하게 연결.</li>
</ul>
</li>
</ul>
</br>

<h3 id="🔸-deliverycommandservice-1">🔸 <code>DeliveryCommandService</code></h3>
<ul>
<li><p><strong>배송과 관련된 “명령(Command)” 책임을 한 데 모은 서비스</strong>.</p>
</li>
<li><p>주요 기능:</p>
<ul>
<li>배송 생성 + AI 호출 (<code>createDelivery</code>)</li>
<li>배송 상태 변경 (<code>changeDeliveryStatus</code>)</li>
<li>배송 취소 (<code>cancelDelivery</code>)</li>
</ul>
</li>
<li><p><code>createDelivery()</code> 가 <strong>도메인 관점의 “전체 배송 생성” 유즈케이스</strong>:</p>
<ul>
<li>HubDelivery + FirmDelivery 를 모두 생성.</li>
<li>그다음에 AI에게 이 주문의 <strong>“최종 발송 시한 + Discord 메시지”를 비동기 요청</strong>.</li>
</ul>
</li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[Kafka Skeleton Code]]></title>
            <link>https://velog.io/@jess_kim/Kafka-Skeleton-Code</link>
            <guid>https://velog.io/@jess_kim/Kafka-Skeleton-Code</guid>
            <pubDate>Thu, 04 Dec 2025 07:50:37 GMT</pubDate>
            <description><![CDATA[<h2 id="🔹-kafka란">🔹 Kafka란?</h2>
<blockquote>
<p><em><strong>고속 대용량 로그/이벤트를 토픽 단위로 쌓아두고, 여러 서비스가 비동기로 구독해서 처리하는 분산 메시징/스트리밍 플랫폼.</strong></em></p>
</blockquote>
<p>조금 자세히 보면:</p>
<h3 id="🔸-핵심-개념">🔸 핵심 개념</h3>
<ol>
<li><p><strong>Broker</strong></p>
<ul>
<li>Kafka 서버 인스턴스 하나.</li>
<li>여러 대를 띄워서 클러스터를 구성.</li>
</ul>
</li>
<li><p><strong>Topic</strong></p>
<ul>
<li>메시지를 쌓아두는 “채널” 이름.</li>
<li>예: <code>order-created</code>, <code>payment-completed</code>, <code>user-signup</code> 등.</li>
</ul>
</li>
<li><p><strong>Partition</strong></p>
<ul>
<li>Topic을 물리적으로 쪼개놓은 단위.</li>
<li>병렬 처리(Consumer 여러 개)와 확장성(스케일 아웃)을 위해 존재.</li>
<li>각 메시지는 파티션 안에서 순서를 가짐(Offset).</li>
</ul>
</li>
<li><p><strong>Producer</strong></p>
<ul>
<li>메시지를 “보내는 쪽” 애플리케이션.</li>
<li><code>KafkaTemplate</code>, REST API 서비스 등.</li>
</ul>
</li>
<li><p><strong>Consumer</strong></p>
<ul>
<li>메시지를 “받아서 처리하는 쪽”.</li>
<li>메시지 핸들링 로직을 가짐.</li>
</ul>
</li>
<li><p><strong>Consumer Group</strong></p>
<ul>
<li>같은 그룹에 속한 Consumer들이 <strong>하나의 Topic 파티션들을 나눠서 병렬 처리</strong>.</li>
<li>그룹 단위로 “한 메시지는 그룹 내에서 딱 한 Consumer만” 처리.</li>
</ul>
</li>
<li><p><strong>Offset</strong></p>
<ul>
<li>파티션 내에서 메시지의 위치(일련 번호).</li>
<li>Consumer는 마지막으로 읽은 Offset을 기억해, 재시작 시 이어서 처리.</li>
</ul>
</li>
</ol>
</br>

<h3 id="🔸-kafka-왜-사용하는가">🔸 Kafka 왜 사용하는가?</h3>
<ul>
<li><strong>느슨한 결합</strong>: 주문 서비스 → 결제 서비스 → 알림 서비스 간을 동기 HTTP 호출 대신, Kafka Topic으로 느슨하게 연결.</li>
<li><strong>비동기 처리</strong>: 주문 요청에 바로 응답하고, 뒤에서 배송/알림/포인트 적립 처리.</li>
<li><strong>버퍼 역할</strong>: 트래픽이 몰려도 Kafka가 일단 적재하고, Consumer가 천천히 따라잡을 수 있음.</li>
<li><strong>재처리 가능</strong>: 특정 Offset부터 다시 읽어서 “replay” 가능 (이벤트 소싱/로그 분석에 좋음).</li>
</ul>
</br>

<h2 id="🔹-outbox">🔹 outBox</h2>
<p>outbox는 kafka 메시지 전송에 실패했을 때에 대한 처리를 위해 필요하므로, 추후에 적용 예정.</p>
<p>outbox 역할:</p>
<ul>
<li>실패했을 때 트래킹을 위해서 필요</li>
<li>지금은 Kafka로 바로 보내는데, 만약 처음부터 outbox 적용한다고 하면, order에서 createAfterOrder 보낼 때 outbox 테이블로 구성해서 여기에 메시지 정보를 저장해두고, 스케줄러가 outbox 데이터를 읽어서 주기적으로 보내주는 형태로 구현.</li>
<li>outbox 적용함으로써 주기적인 retry 가능 (실패에 대한 fallback 역할)</li>
</ul>
<p>지금은 MVP 개발에 집중하고, 고도화 기간에 실패했을 때 어떡하지?에 대한 처리로 적용하면 베스트</p>
<hr>
</br>

<h1 id="spring-boot에서-kafka-적용-예시">Spring Boot에서 Kafka 적용 예시</h1>
<p>“주문 생성 이벤트”를 Kafka로 발행하고, 다른 서비스가 그걸 구독해서 처리하는 시나리오로 생각해보았다.</p>
<p>Docker Compose로 Kafka가 localhost:9092(기본 포트번호)로 떠 있다고 가정했을 때,</p>
<blockquote>
<p>📌 시나리오</p>
<ul>
<li><strong><code>order-server</code></strong> (Producer) : 주문 생성 API → Kafka <code>OrderAfterCreate</code> 이벤트 발행 </li>
</ul>
</blockquote>
<ul>
<li><strong><code>hub-server</code></strong> (Consumer) : <code>OrderAfterCreate</code> 구독 → 메시지에서 받아온 주문 정보 기반으로 알고리즘 적용 후 예상 소요시간 계산</li>
<li><strong><code>hub-server</code></strong> (Producer) : Kafka <code>HubRouteAfterCrete</code>(주문 정보 + 예상 소요 시간) 이벤트 발행<blockquote>
<ul>
<li><strong><code>delivery-server</code></strong> (Consumer) : Kafka <code>HubRouteAfterCrete</code> 구독 → 배송 생성 </li>
</ul>
</blockquote>
</li>
</ul>
</br>

<h2 id="🔹-order-server-producer">🔹 order-server (Producer)</h2>
</br>

<h3 id="🔸-order-server-buildgradle">🔸 order-server: build.gradle</h3>
<pre><code class="language-java">implementation &#39;org.springframework.kafka:spring-kafka&#39;
testImplementation &#39;org.springframework.kafka:spring-kafka-test&#39;</code></pre>
</br>

<h3 id="🔸-order-server-applicationyml">🔸 order-server: application.yml</h3>
<pre><code class="language-yml">spring:
  application:
    name: order-server

  kafka:
    bootstrap-servers: localhost:9092

app:
  kafka:
    topic:
      order-after-create: order-after-create</code></pre>
</br>

<h3 id="🔸-order-server-infrastructureconfig-kafkaproducerconfig">🔸 order-server: (infrastructure.config) KafkaProducerConfig</h3>
<pre><code class="language-java">package chill_logistics.order_server.infrastructure.config;

import chill_logistics.order_server.infrastructure.kafka.dto.OrderAfterCreateV1;
import java.util.HashMap;
import java.util.Map;
import org.apache.kafka.clients.producer.ProducerConfig;
import org.apache.kafka.common.serialization.StringSerializer;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.kafka.core.DefaultKafkaProducerFactory;
import org.springframework.kafka.core.KafkaTemplate;
import org.springframework.kafka.core.ProducerFactory;
import org.springframework.kafka.support.serializer.JsonSerializer;

@Configuration
public class KafkaProducerConfig {

    @Bean
    public ProducerFactory&lt;String, OrderAfterCreateV1&gt; orderAfterCreateProducerFactory() {

        Map&lt;String, Object&gt; props = new HashMap&lt;&gt;();

        // Kafka Broker
        props.put(ProducerConfig.BOOTSTRAP_SERVERS_CONFIG, &quot;localhost:9092&quot;);

        // 직렬화 설정
        props.put(ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG, StringSerializer.class);
        props.put(ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG, JsonSerializer.class);

        // 필요시 기타 옵션 (acks, retries 등) 추가

        return new DefaultKafkaProducerFactory&lt;&gt;(props);
    }

    @Bean
    public KafkaTemplate&lt;String, OrderAfterCreateV1&gt; orderAfterCreateKafkaTemplate() {
        return new KafkaTemplate&lt;&gt;(orderAfterCreateProducerFactory());
    }
}
</code></pre>
</br>

<h3 id="🔸-order-server-infrastructurekafkadto-orderaftercreatev1">🔸 order-server: (infrastructure.kafka.dto) OrderAfterCreateV1</h3>
<pre><code class="language-java">package chill_logistics.order_server.infrastructure.kafka.dto;

import java.time.LocalDateTime;
import java.util.UUID;

public record OrderAfterCreateV1(
    UUID orderId,
    UUID supplierHubId,
    UUID receiverHubId,
    UUID receiverFirmId,
    String receiverFirmFullAddress,
    String receiverFirmOwnerName,
    String requestNote,
    String productName,
    int productQuantity,
    LocalDateTime orderCreatedAt
) {}
</code></pre>
</br>

<h3 id="🔸-order-server-orderaftercreteproducer">🔸 order-server: OrderAfterCreteProducer</h3>
<pre><code class="language-java">package chill_logistics.order_server.infrastructure.kafka;

import chill_logistics.order_server.infrastructure.kafka.dto.OrderAfterCreateV1;
import java.util.UUID;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.kafka.core.KafkaTemplate;
import org.springframework.stereotype.Service;

@Slf4j
@Service
@RequiredArgsConstructor
public class OrderAfterCreateProducer {

    private final KafkaTemplate&lt;String, OrderAfterCreateV1&gt; orderAfterCreateKafkaTemplate;

    @Value(&quot;${app.kafka.topic.order-after-create}&quot;)
    private String orderAfterCreateTopic;

    /* [주문 생성 후 Kafka로 OrderAfterCreate 이벤트 발행]
     */
    public void sendOrderAfterCreate(OrderAfterCreateV1 message) {

        String key = message.orderId().toString();

        log.info(&quot;[Kafka] OrderAfterCreate 메시지 발행, topic={}, key={}, message={}&quot;,
            orderAfterCreateTopic, key, message);

        orderAfterCreateKafkaTemplate
            .send(orderAfterCreateTopic, key, message)
            .whenComplete((result, ex) -&gt; {
                if (ex != null) {
                    log.error(&quot;[Kafka] OrderAfterCreate 메시지 전송 실패, key={}&quot;, key, ex);
                } else {
                    log.info(&quot;[Kafka] OrderAfterCreate 메시지 전송 성공, orderId={}, offset={}&quot;,
                        key, result.getRecordMetadata().offset());
                }
            });
    }
}
</code></pre>
</br>

<h3 id="🔸-order-server-orderservice에서-사용-예시">🔸 order-server: OrderService에서 사용 예시</h3>
<pre><code class="language-java">package chill_logistics.order_server.application;

import chill_logistics.order_server.domain.entity.Order;
import chill_logistics.order_server.domain.repository.OrderRepository;
import chill_logistics.order_server.infrastructure.kafka.OrderAfterCreateProducer;
import chill_logistics.order_server.infrastructure.kafka.dto.OrderAfterCreateV1;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Slf4j
@Service
@RequiredArgsConstructor
public class OrderService {

    private final OrderRepository orderRepository;
    private final OrderAfterCreateProducer orderAfterCreateProducer;

    @Transactional
    public UUID createOrder(CreateOrderRequest request) {

        // 1. 주문 엔티티 생성
        Order order = Order.create(
            request.supplierHubId(),
            request.receiverHubId(),
            request.receiverFirmId(),
            request.receiverFirmFullAddress(),
            request.receiverFirmOwnerName(),
            request.requestNote(),
            request.productName(),
            request.productQuantity()
        );

        // 2. 저장
        orderRepository.save(order);

        // 3. Kafka 메시지 생성
        OrderAfterCreateV1 message = new OrderAfterCreateV1(
            order.getId(),
            order.getSupplierHubId(),
            order.getReceiverHubId(),
            order.getReceiverFirmId(),
            order.getReceiverFirmFullAddress(),
            order.getReceiverFirmOwnerName(),
            order.getRequestNote(),
            order.getProductName(),
            order.getProductQuantity(),
            order.getCreatedAt()
        );

        // 4. Kafka 메시지 발행
        orderAfterCreateProducer.sendOrderAfterCreate(message);

        log.info(&quot;주문 생성 완료 + Kafka 메시지 발행 완료. orderId={}&quot;, order.getId());

        return order.getId();
    }
}
</code></pre>
</br>

<hr>
</br>

<h2 id="🔹-hub-server-consumer">🔹 hub-server (Consumer)</h2>
</br>

<h3 id="🔸-hub-server-buildgradle">🔸 hub-server: build.gradle</h3>
<pre><code class="language-java">implementation &#39;org.springframework.kafka:spring-kafka&#39;
testImplementation &#39;org.springframework.kafka:spring-kafka-test&#39;</code></pre>
</br>

<h3 id="🔸-hub-server-applicationyml">🔸 hub-server: application.yml</h3>
<pre><code class="language-yml">spring:
  application:
    name: hub-server

  kafka:
    bootstrap-servers: localhost:9092</code></pre>
</br>

<h3 id="🔸-hub-server-infrastructureconfig-kafkaconsumerconfig">🔸 hub-server: (infrastructure.config) KafkaConsumerConfig</h3>
<pre><code class="language-java">package chill_logistics.hub_server.infrastructure.config;

import chill_logistics.hub_server.infrastructure.kafka.dto.OrderAfterCreateV1;
import java.util.HashMap;
import java.util.Map;
import org.apache.kafka.clients.consumer.ConsumerConfig;
import org.apache.kafka.common.serialization.StringDeserializer;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.kafka.config.ConcurrentKafkaListenerContainerFactory;
import org.springframework.kafka.core.ConsumerFactory;
import org.springframework.kafka.core.DefaultKafkaConsumerFactory;
import org.springframework.kafka.support.serializer.JsonDeserializer;

@Configuration
public class KafkaConsumerConfig {

    @Bean
    public ConsumerFactory&lt;String, OrderAfterCreateV1&gt; orderConsumerFactory() {

        // JSON → OrderAfterCreateV1 역직렬화를 위한 Deserializer
        JsonDeserializer&lt;OrderAfterCreateV1&gt; deserializer =
            new JsonDeserializer&lt;&gt;(OrderAfterCreateV1.class, false);

        // Kafka 메시지 역직렬화 시 허용할 패키지를 명시적으로 지정
        deserializer.addTrustedPackages(
            &quot;chill_logistics.delivery_server.infrastructure.kafka.dto&quot;
        );

        // Kafka Consumer 설정 값
        Map&lt;String, Object&gt; properties = new HashMap&lt;&gt;();

        // Kafka Broker 주소 (Docker Compose에서 기본적으로 localhost:9092로 띄움)
        properties.put(ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG, &quot;localhost:9092&quot;);

        // Consumer Group ID (같은 Group으로 묶인 Consumer들은 같은 메시지를 중복 처리하지 않음)
        properties.put(ConsumerConfig.GROUP_ID_CONFIG, &quot;hub-server-group&quot;);

        // 메시지 Key 역직렬화 방식
        properties.put(ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class);

        // 메시지 Value 역직렬화 방식 (JsonDeserializer 사용)
        properties.put(ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG, JsonDeserializer.class);

        // 위 설정값 + Key/Value Deserializer를 기반으로 실제 Consumer 인스턴스를 만들어 KafkaListener에 제공
        return new DefaultKafkaConsumerFactory&lt;&gt;(
            properties,
            new StringDeserializer(),   // Key Deserializer (String)
            deserializer                // Value Deserializer (OrderAfterCreateV1)
        );
    }

    // @KafkaListener가 사용할 Listener 컨테이너 생성 (ConsumerFactory를 주입하여 실제 Kafka Consumer 동작을 구성)
    @Bean
    public ConcurrentKafkaListenerContainerFactory&lt;String, OrderAfterCreateV1&gt;
    orderKafkaListenerContainerFactory() {

        ConcurrentKafkaListenerContainerFactory&lt;String, OrderAfterCreateV1&gt; factory =
            new ConcurrentKafkaListenerContainerFactory&lt;&gt;();

        // ConsumerFactory 설정
        factory.setConsumerFactory(orderConsumerFactory());

        return factory;
    }
}
</code></pre>
</br>

<h3 id="🔸-hub-server-infrastructurekafkadto-orderaftercreatev1">🔸 hub-server: (infrastructure.kafka.dto) OrderAfterCreateV1</h3>
<pre><code class="language-java">package chill_logistics.hub_server.infrastructure.kafka.dto;

import java.time.LocalDateTime;
import java.util.UUID;

public record OrderAfterCreateV1(
    UUID orderId,
    UUID supplierHubId,
    UUID receiverHubId,
    UUID receiverFirmId,
    String receiverFirmFullAddress,
    String receiverFirmOwnerName,
    String requestNote,
    String productName,
    int productQuantity,
    LocalDateTime orderCreatedAt) {

    // application 계층의 command 변환 메서드
    public OrderAfterCommandV1 toCommand() {
        return new OrderAfterCommandV1(
            orderId(),
            supplierHubId(),
            receiverHubId(),
            receiverFirmId(),
            receiverFirmFullAddress(),
            receiverFirmOwnerName(),
            requestNote(),
            productName(),
            productQuantity(),
            orderCreatedAt(),
        );
    }
}</code></pre>
</br>

<h3 id="🔸-hub-server-applicationdto-orderaftercommandv1">🔸 hub-server: (application.dto) OrderAfterCommandV1</h3>
<pre><code class="language-java">package chill_logistics.hub_server.application.dto.command;

import java.time.LocalDateTime;
import java.util.UUID;

public record OrderAfterCommandV1(
    UUID orderId,
    UUID supplierHubId,
    UUID receiverHubId,
    UUID receiverFirmId,
    String receiverFirmFullAddress,
    String receiverFirmOwnerName,
    String requestNote,
    String productName,
    int productQuantity,
    LocalDateTime orderCreatedAt,
) {}
</code></pre>
</br>

<h3 id="🔸-hub-server-orderaftercreatelistener">🔸 hub-server: OrderAfterCreateListener</h3>
<pre><code class="language-java">package chill_logistics.hub_server.infrastructure.kafka;

import chill_logistics.hub_server.application.HubCommandService;
import chill_logistics.hub_server.application.dto.command.OrderAfterCommandV1;
import chill_logistics.hub_server.infrastructure.kafka.dto.OrderAfterCreateV1;
import java.util.UUID;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.kafka.annotation.KafkaListener;
import org.springframework.stereotype.Component;

@Slf4j
@Component
@RequiredArgsConstructor
public class OrderAfterCreateListener {

    private final HubCommandService hubCommandService;

    @KafkaListener(
        topics = &quot;order-after-create&quot;,
        containerFactory = &quot;orderKafkaListenerContainerFactory&quot;
    )
    public void listen(OrderAfterCreateV1 message) {

        log.info(&quot;Kafka 메시지 수신: {}&quot;, message);

        // DTO → Command 변환
        OrderAfterCommandV1 command = message.toCommand();

        hubCommandService.calculateExpectedDuration(command);
    }
}
</code></pre>
<hr>
</br>

<h2 id="🔹-hub-server-producer">🔹 hub-server (Producer)</h2>
</br>

<h3 id="🔸-hub-server-buildgradle-1">🔸 hub-server: build.gradle</h3>
<pre><code class="language-java">implementation &#39;org.springframework.kafka:spring-kafka&#39;
testImplementation &#39;org.springframework.kafka:spring-kafka-test&#39;</code></pre>
</br>

<h3 id="🔸-hub-server-applicationyml-1">🔸 hub-server: application.yml</h3>
<pre><code class="language-yml">spring:
  application:
    name: hub-server

  kafka:
    bootstrap-servers: localhost:9092

app:
  kafka:
    topic:
      hub-route-after-create: hub-route-after-create</code></pre>
</br>

<h3 id="🔸-hub-server-infrastructureconfig-kafkaproducerconfig">🔸 hub-server: (infrastructure.config) KafkaProducerConfig</h3>
<pre><code class="language-java">package chill_logistics.hub_server.infrastructure.config;

import chill_logistics.hub_server.infrastructure.kafka.dto.HubRouteAfterCreateV1;
import java.util.HashMap;
import java.util.Map;
import org.apache.kafka.clients.producer.ProducerConfig;
import org.apache.kafka.common.serialization.StringSerializer;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.kafka.core.DefaultKafkaProducerFactory;
import org.springframework.kafka.core.KafkaTemplate;
import org.springframework.kafka.core.ProducerFactory;
import org.springframework.kafka.support.serializer.JsonSerializer;

@Configuration
public class KafkaProducerConfig {

    @Bean
    public ProducerFactory&lt;String, HubRouteAfterCreateV1&gt; hubRouteAfterCreateProducerFactory() {

        Map&lt;String, Object&gt; props = new HashMap&lt;&gt;();

        // Kafka Broker
        props.put(ProducerConfig.BOOTSTRAP_SERVERS_CONFIG, &quot;localhost:9092&quot;);

        // 직렬화 설정
        props.put(ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG, StringSerializer.class);
        props.put(ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG, JsonSerializer.class);

        // 필요시 기타 옵션 (acks, retries 등) 추가 가능

        return new DefaultKafkaProducerFactory&lt;&gt;(props);
    }

    @Bean
    public KafkaTemplate&lt;String, HubRouteAfterCreateV1&gt; hubRouteAfterCreateKafkaTemplate() {
        return new KafkaTemplate&lt;&gt;(hubRouteAfterCreateProducerFactory());
    }
}
</code></pre>
</br>

<h3 id="🔸-hub-server-infrastructurekafkadto-hubrouteaftercreatev1">🔸 hub-server: (infrastructure.kafka.dto) HubRouteAfterCreateV1</h3>
<pre><code class="language-java">package chill_logistics.hub_server.infrastructure.kafka.dto;

import java.time.LocalDateTime;
import java.util.UUID;

public record HubRouteAfterCreateV1(
    UUID orderId,
    UUID startHubId,
    String startHubName,
    String startHubFullAddress,
    UUID endHubId,
    String endHubName,
    String endHubFullAddress,
    UUID receiverFirmId,
    String receiverFirmFullAddress,
    String receiverFirmOwnerName,
    String requestNote,
    String productName,
    int productQuantity,
    LocalDateTime orderCreatedAt,
    Integer expectedDeliveryDuration
    ) {}
</code></pre>
</br>

<h3 id="🔸-hub-server-hubrouteaftercreteproducer">🔸 hub-server: HubRouteAfterCreteProducer</h3>
<pre><code class="language-java">package chill_logistics.hub_server.infrastructure.kafka;

import chill_logistics.hub_server.infrastructure.kafka.dto.HubRouteAfterCreateV1;
import java.util.UUID;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.kafka.core.KafkaTemplate;
import org.springframework.stereotype.Service;

@Slf4j
@Service
@RequiredArgsConstructor
public class HubRouteAfterCreateProducer {

    private final KafkaTemplate&lt;String, HubRouteAfterCreateV1&gt; hubRouteAfterCreateKafkaTemplate;

    @Value(&quot;${app.kafka.topic.hub-route-after-create}&quot;)
    private String hubRouteAfterCreateTopic;

    /* [허브 경로 생성 후 Kafka로 HubRouteAfterCreate 이벤트 발행]
     */
    public void sendHubRouteAfterCreate(HubRouteAfterCreateV1 message) {

        String key = message.orderId().toString();

        log.info(&quot;[Kafka] HubRouteAfterCreate 메시지 발행, topic={}, key={}, message={}&quot;,
            hubRouteAfterCreateTopic, key, message);

        hubRouteAfterCreateKafkaTemplate
            .send(hubRouteAfterCreateTopic, key, message)
            .whenComplete((result, ex) -&gt; {
                if (ex != null) {
                    log.error(&quot;[Kafka] HubRouteAfterCreate 메시지 전송 실패, key={}&quot;, key, ex);
                } else {
                    log.info(&quot;[Kafka] HubRouteAfterCreate 메시지 전송 성공, orderId={}, offset={}&quot;,
                        key, result.getRecordMetadata().offset());
                }
            });
    }
}
</code></pre>
</br>

<h3 id="🔸-hub-server-hubservice에서-사용-예시">🔸 hub-server: HubService에서 사용 예시</h3>
<pre><code class="language-java">package chill_logistics.hub_server.application;

import chill_logistics.hub_server.domain.entity.Hub;
import chill_logistics.hub_server.domain.repository.HubRepository;
import chill_logistics.hub_server.infrastructure.kafka.HubRouteAfterCreateProducer;
import chill_logistics.hub_server.infrastructure.kafka.dto.HubRouteAfterCreateV1;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Slf4j
@Service
@RequiredArgsConstructor
public class HubService {

    private final HubRepository hubRepository;
    private final HubRouteAfterCreateProducer hubRouteAfterCreateProducer;

    @Transactional
    public calculateExpectedDuration() {

        // 1. 예상 소요 시간 계산

        // 2. 저장 (임의로 hub라고 명칭했습니다)
        hubRepository.save(hub);

        // 3. Kafka 메시지 생성
        HubRoutefterCreateV1 message = new HubRoutefterCreateV1(
            hub.getOrderId(),
            hub.getStartHubId(),
            hub.getStartHubName(),
            hub.getStartHubFullAddress(),
            hub.getEndHubId(),
            hub.getEndHubName(),
            hub.getEndHubFullAddress(),
            hub.getReceiverFirmId(),
            hub.getReceiverFirmFullAddress(),
            hub.getReceiverFirmOwnerName(),
            hub.getRequestNote(),
            hub.getProductName(),
            hub.getProductQuantity(),
            hub.getOrderCreatedAt(),
            hub.getExpectedDuration(),
        );

        // 4. Kafka 메시지 발행
        hubRouteAfterCreateProducer.sendHubRouteAfterCreate(message);

        log.info(&quot;예상 소요 시간 계산 생성 완료 + Kafka 메시지 발행 완료. orderId={}&quot;, hub.getOrderId());

        return hub.getOrderId();
    }
}</code></pre>
<hr>
</br>

<h2 id="🔹-delivery-server-consumer">🔹 delivery-server (Consumer)</h2>
</br>

<h3 id="🔸-delivery-server-buildgradle">🔸 delivery-server: build.gradle</h3>
<pre><code class="language-java">implementation &#39;org.springframework.kafka:spring-kafka&#39;
testImplementation &#39;org.springframework.kafka:spring-kafka-test&#39;</code></pre>
</br>

<h3 id="🔸-delivery-server-applicationyml">🔸 delivery-server: application.yml</h3>
<pre><code class="language-yml">spring:
  application:
    name: delivery-server

  kafka:
    bootstrap-servers: localhost:9092</code></pre>
</br>

<h3 id="🔸-delivery-server-infrastructureconfig-kafkaconsumerconfig">🔸 delivery-server: (infrastructure.config) KafkaConsumerConfig</h3>
<pre><code class="language-java">package chill_logistics.delivery_server.infrastructure.config;

import chill_logistics.delivery_server.infrastructure.kafka.dto.HubRouteAfterCreateV1;
import java.util.HashMap;
import java.util.Map;
import org.apache.kafka.clients.consumer.ConsumerConfig;
import org.apache.kafka.common.serialization.StringDeserializer;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.kafka.config.ConcurrentKafkaListenerContainerFactory;
import org.springframework.kafka.core.ConsumerFactory;
import org.springframework.kafka.core.DefaultKafkaConsumerFactory;
import org.springframework.kafka.support.serializer.JsonDeserializer;

@Configuration
public class KafkaConsumerConfig {

    @Bean
    public ConsumerFactory&lt;String, HubRouteAfterCreateV1&gt; hubConsumerFactory() {

        // JSON → HubRouteAfterCreateV1 역직렬화를 위한 Deserializer
        JsonDeserializer&lt;HubRouteAfterCreateV1&gt; deserializer =
            new JsonDeserializer&lt;&gt;(HubRouteAfterCreateV1.class, false);

        // Kafka 메시지 역직렬화 시 허용할 패키지를 명시적으로 지정
        deserializer.addTrustedPackages(
            &quot;chill_logistics.delivery_server.infrastructure.kafka.dto&quot;
        );

        // Kafka Consumer 설정 값
        Map&lt;String, Object&gt; properties = new HashMap&lt;&gt;();

        // Kafka Broker 주소 (Docker Compose에서 기본적으로 localhost:9092로 띄움)
        properties.put(ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG, &quot;localhost:9092&quot;);

        // Consumer Group ID (같은 Group으로 묶인 Consumer들은 같은 메시지를 중복 처리하지 않음)
        properties.put(ConsumerConfig.GROUP_ID_CONFIG, &quot;delivery-server-group&quot;);

        // 메시지 Key 역직렬화 방식
        properties.put(ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class);

        // 메시지 Value 역직렬화 방식 (JsonDeserializer 사용)
        properties.put(ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG, JsonDeserializer.class);

        // 위 설정값 + Key/Value Deserializer를 기반으로 실제 Consumer 인스턴스를 만들어 KafkaListener에 제공
        return new DefaultKafkaConsumerFactory&lt;&gt;(
            properties,
            new StringDeserializer(),   // Key Deserializer (String)
            deserializer                // Value Deserializer (HubRouteAfterCreateV1)
        );
    }

    // @KafkaListener가 사용할 Listener 컨테이너 생성 (ConsumerFactory를 주입하여 실제 Kafka Consumer 동작을 구성)
    @Bean
    public ConcurrentKafkaListenerContainerFactory&lt;String, HubRouteAfterCreateV1&gt;
    hubKafkaListenerContainerFactory() {

        ConcurrentKafkaListenerContainerFactory&lt;String, HubRouteAfterCreateV1&gt; factory =
            new ConcurrentKafkaListenerContainerFactory&lt;&gt;();

        // ConsumerFactory 설정
        factory.setConsumerFactory(hubConsumerFactory());

        return factory;
    }
}
</code></pre>
</br>

<h3 id="🔸-delivery-server-infrastructurekafkadto-hubrouteaftercreatev1">🔸 delivery-server: (infrastructure.kafka.dto) HubRouteAfterCreateV1</h3>
<pre><code class="language-java">package chill_logistics.delivery_server.infrastructure.kafka.dto;

import chill_logistics.delivery_server.application.dto.command.HubRouteAfterCommandV1;
import java.time.LocalDateTime;
import java.util.UUID;

public record HubRouteAfterCreateV1(
    UUID orderId,
    UUID startHubId,
    String startHubName,
    String startHubFullAddress,
    UUID endHubId,
    String endHubName,
    String endHubFullAddress,
    UUID receiverFirmId,
    String receiverFirmFullAddress,
    String receiverFirmOwnerName,
    String requestNote,
    String productName,
    int productQuantity,
    LocalDateTime orderCreatedAt,
    Integer expectedDeliveryDuration) {

    // application 계층의 command 변환 메서드
    public HubRouteAfterCommandV1 toCommand() {
        return new HubRouteAfterCommandV1(
            orderId(),
            startHubId(),
            startHubName(),
            startHubFullAddress(),
            endHubId(),
            endHubName(),
            endHubFullAddress(),
            receiverFirmId(),
            receiverFirmFullAddress(),
            receiverFirmOwnerName(),
            requestNote(),
            productName(),
            productQuantity(),
            orderCreatedAt(),
            expectedDeliveryDuration()
        );
    }
}
</code></pre>
</br>

<h3 id="🔸-delivery-server-applicationdto-hubrouteaftercommandv1">🔸 delivery-server: (application.dto) HubRouteAfterCommandV1</h3>
<pre><code class="language-java">package chill_logistics.delivery_server.application.dto.command;

import java.time.LocalDateTime;
import java.util.UUID;

public record HubRouteAfterCommandV1(
    UUID orderId,
    UUID startHubId,
    String startHubName,
    String startHubFullAddress,
    UUID endHubId,
    String endHubName,
    String endHubFullAddress,
    UUID receiverFirmId,
    String receiverFirmFullAddress,
    String receiverFirmOwnerName,
    String requestNote,
    String productName,
    int productQuantity,
    LocalDateTime orderCreatedAt,
    Integer expectedDeliveryDuration
) {}
</code></pre>
</br>

<h3 id="🔸-delivery-server-hubrouteaftercreatelistener">🔸 delivery-server: HubRouteAfterCreateListener</h3>
<pre><code class="language-java">package chill_logistics.delivery_server.infrastructure.kafka;

import chill_logistics.delivery_server.application.DeliveryCommandService;
import chill_logistics.delivery_server.application.dto.command.HubRouteAfterCommandV1;
import chill_logistics.delivery_server.infrastructure.kafka.dto.HubRouteAfterCreateV1;
import java.util.UUID;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.kafka.annotation.KafkaListener;
import org.springframework.stereotype.Component;

@Slf4j
@Component
@RequiredArgsConstructor
public class HubRouteAfterCreateListener {

    private final DeliveryCommandService deliveryCommandService;

    @KafkaListener(
        topics = &quot;hub-route-after-create&quot;,
        containerFactory = &quot;hubKafkaListenerContainerFactory&quot;
    )
    public void listen(HubRouteAfterCreateV1 message) {

        log.info(&quot;Kafka 메시지 수신: {}&quot;, message);

        // 허브/업체 배송 담당자 배정 (임시 스텁)
        UUID hubDeliveryPersonId = assignHubDeliveryPerson(message);
        UUID firmDeliveryPersonId = assignFirmDeliveryPerson(message);

        HubRouteAfterCommandV1 command = message.toCommand();

        deliveryCommandService.createDelivery(command, hubDeliveryPersonId, firmDeliveryPersonId);
    }

    // 허브 배송 담당자 배정 (임시 버전 - 이후 배정 로직으로 교체)
    private UUID assignHubDeliveryPerson(HubRouteAfterCreateV1 message) {

        return UUID.fromString(&quot;00000000-0000-0000-0000-000000000001&quot;);
    }

    // 업체 배송 담당자 배정 (임시 버전 - 이후 배정 로직으로 교체)
    private UUID assignFirmDeliveryPerson(HubRouteAfterCreateV1 message) {

        return UUID.fromString(&quot;00000000-0000-0000-0000-000000000002&quot;); // 임시값
    }
}
</code></pre>
]]></description>
        </item>
        <item>
            <title><![CDATA[12/4]]></title>
            <link>https://velog.io/@jess_kim/124</link>
            <guid>https://velog.io/@jess_kim/124</guid>
            <pubDate>Thu, 04 Dec 2025 02:47:41 GMT</pubDate>
            <description><![CDATA[<h2 id="🔹-kafka-code-skeleton">🔹 Kafka code skeleton</h2>
<blockquote>
<p><em><strong><a href="https://velog.io/@jess_kim/Kafka-Skeleton-Code">Kafka code skeleton 참고 링크</a></strong></em></p>
</blockquote>
]]></description>
        </item>
        <item>
            <title><![CDATA[Custom Pagination]]></title>
            <link>https://velog.io/@jess_kim/Custom-Pagination</link>
            <guid>https://velog.io/@jess_kim/Custom-Pagination</guid>
            <pubDate>Wed, 03 Dec 2025 15:13:03 GMT</pubDate>
            <description><![CDATA[<h1 id="ddd구조의-msa-프로젝트에-jpa-pageable-사용">DDD구조의 MSA 프로젝트에 JPA Pageable 사용?</h1>
<p>DDD 구조의 MSA 프로젝트에서는 JPA의 Pageable 객체를 사용하면 안 좋다고 생각되어 (DDD 규칙에 어긋나기 때문), 커스터마이징 한 Page 객체를 만들었다.</p>
</br>

<h2 id="🔹-왜-사용하면-안-되는가">🔹 왜 사용하면 안 되는가?</h2>
<p>결론부터 말하면:</p>
<blockquote>
<p><strong>“MSA + DDD 구조라서 <code>Page/Pageable</code>을 절대 쓰면 안 된다” 같은 건 없다.</strong>
다만, <strong>“어디까지 써도 되는지(레이어 경계)”</strong> 가 중요하다.</p>
</blockquote>
<ul>
<li><strong>✅ 인프라 레이어(Repository 구현체)</strong> 에서는 <code>Page/Pageable</code> 마음껏 써도 됨</li>
<li><strong>⚠️ 도메인/애플리케이션/공용 인터페이스</strong>까지 <code>Page/Pageable</code>이 새어 나오면 좀 곤란해짐</li>
<li>그래서 <strong>“내 페이징 모델(CustomPage, CustomPageRequest)”을 바깥 계약으로 쓰고, 안쪽에서만 Page/Pageable로 매핑</strong>하는 패턴을 많이 씀</li>
</ul>
<h2 id="🔸-dddhexagonal-관점에서의-문제점">🔸 DDD/Hexagonal 관점에서의 문제점</h2>
<h3 id="1-도메인-계층이-spring-data에-직접-의존하게-됨">1) 도메인 계층이 Spring Data에 직접 의존하게 됨</h3>
<p>DDD + 헥사고날(ports &amp; adapters)에서 보통 구조를 이렇게 잡는다:</p>
<ul>
<li><strong>domain/application 레이어</strong>: <code>HubRepository</code> 같은 <strong>포트 인터페이스</strong></li>
<li><strong>infra 레이어</strong>: <code>SpringDataHubRepository extends JpaRepository&lt;Hub, UUID&gt;</code> 같은 <strong>어댑터 구현체</strong></li>
</ul>
<p>이때 <strong>도메인 레이어의 포트 인터페이스에 <code>Page/Pageable</code>이 등장하면</strong>:</p>
<pre><code class="language-java">// (안 좋은 예) domain 레이어에 있는 인터페이스
public interface HubRepository {
    Page&lt;Hub&gt; searchByHubName(String hubName, Pageable pageable);
}</code></pre>
<ul>
<li>도메인 코드가 <strong>Spring Data JPA에 직접 의존</strong>하게 됨</li>
<li>나중에 저장소 기술을 바꾸고 싶을 때(몽고, 엘라스틱, R2DBC 등)
→ 인터페이스 자체를 갈아엎어야 함</li>
<li>“도메인은 기술에 독립적이어야 한다”는 DDD/헥사고날 철학이 깨짐</li>
</ul>
<p>그래서 보통은 이렇게 분리한다:</p>
<pre><code class="language-java">// domain/application 레이어: 기술 중립 포트
public interface HubRepository {

    MyPage&lt;Hub&gt; searchByHubName(String hubName, MyPageRequest pageRequest);
}</code></pre>
<pre><code class="language-java">// infra 레이어: Spring Data JPA 어댑터
public interface HubJpaRepository extends JpaRepository&lt;Hub, UUID&gt; {

    Page&lt;Hub&gt; findByHubNameAndDeletedAtIsNull(String hubName, Pageable pageable);
}</code></pre>
<pre><code class="language-java">// infra 레이어: 포트 구현체
@Repository
@RequiredArgsConstructor
public class HubRepositoryImpl implements HubRepository {

    private final HubJpaRepository hubJpaRepository;

    @Override
    public MyPage&lt;Hub&gt; searchByHubName(String hubName, MyPageRequest pageRequest) {
        Pageable pageable = PageRequest.of(pageRequest.page(), pageRequest.size());
        Page&lt;Hub&gt; page = hubJpaRepository.findByHubNameAndDeletedAtIsNull(hubName, pageable);

        return new MyPage&lt;&gt;(
            page.getContent(),
            page.getNumber(),
            page.getSize(),
            page.getTotalElements()
        );
    }
}</code></pre>
<ul>
<li><strong>도메인/애플리케이션 레이어는 MyPage/MyPageRequest만 알면 됨</strong></li>
<li><code>Page/Pageable</code>은 <strong>infra 안에서만 사용</strong> → 기술 교체/테스트 분리가 쉬워짐</li>
</ul>
</br>

<h2 id="🔸-msa-관점에서의-문제점">🔸 MSA 관점에서의 문제점</h2>
<h3 id="1-서비스-간-계약api-공용-모듈에-spring-data-타입이-새어-나옴">1) 서비스 간 계약(API, 공용 모듈)에 Spring Data 타입이 새어 나옴</h3>
<p>MSA에서는 보통:</p>
<ul>
<li>REST API 응답 DTO</li>
<li>공용 라이브러리(예: <code>lib-web</code>, <code>lib-pagination</code>)</li>
<li>다른 서비스와의 계약(Swagger/OpenAPI)</li>
</ul>
<p>이런 데서 <strong>Spring Data 타입이 바로 노출되는 것</strong>을 피하는 편이 좋다.</p>
<p>예를 들어, 이런 응답은 바람직하지 않음:</p>
<pre><code class="language-java">// (안 좋은 예) Controller에서 Page를 그대로 응답으로 내려버리는 경우
@GetMapping(&quot;/hubs&quot;)
public Page&lt;HubResponse&gt; searchHubs(...) { ... }</code></pre>
<p>이렇게 되면:</p>
<ul>
<li>외부(클라이언트, 다른 서비스)가 <strong>Spring Data의 Page 구조에 종속</strong></li>
<li>페이지 인덱스가 0-based인지, 필드명이 뭐인지까지 바깥 계약이 되어버림</li>
<li>나중에 페이지 규칙을 바꾸거나 라이브러리 교체하기 어려움</li>
</ul>
<p>그래서 보통은:</p>
<ul>
<li><strong>내가 정의한 페이징 응답 DTO</strong> (<code>HubPageResponseV1</code>)로 감싸고</li>
<li>그 안의 <code>content</code>, <code>page</code>, <code>size</code>, <code>totalElements</code>, <code>totalPages</code> 등을 명시적으로 설계</li>
</ul>
<pre><code class="language-java">public record HubPageResponseV1(
    List&lt;HubSummaryResponseV1&gt; content,
    int page,
    int size,
    long totalElements,
    int totalPages,
    boolean first,
    boolean last
) {}</code></pre>
<ul>
<li>이렇게 해두면 나중에 JPA → Mongo, JPA → MyBatis로 바꿔도
<strong>Controller 바깥 계약은 그대로 유지 가능</strong></li>
</ul>
<hr>
</br>

<h2 id="🔹-그렇다고-pagepageable이-나쁜-건-아니다">🔹 그렇다고 Page/Pageable이 나쁜 건 아니다</h2>
<p>어디서 쓰면 좋은지 정리하면:</p>
<h3 id="🔸-이런-곳에서는-적극-사용해도-됨">🔸 이런 곳에서는 적극 사용해도 됨</h3>
<ul>
<li><p><strong>infra 레이어(Spring Data JPA repository)</strong> 내부</p>
<ul>
<li><code>JpaRepository</code> 상속, <code>Page&lt;엔티티&gt; find...</code> 메서드</li>
<li><code>PageRequest.of(page, size, Sort...)</code> 로 정렬 포함 페이징</li>
</ul>
</li>
<li><p><strong>테스트 코드, 샘플 코드에서</strong> 빠르게 페이징 붙일 때</p>
</li>
</ul>
</br>

<h3 id="🔸-이런-곳에선-피하는-게-좋음">🔸 이런 곳에선 피하는 게 좋음</h3>
<ul>
<li><p><strong>domain/application 레이어 인터페이스</strong> (포트)</p>
<ul>
<li><code>Page</code>/<code>Pageable</code>이 올라오면 도메인이 Spring Data에 물림</li>
</ul>
</li>
<li><p><strong>서비스 간 API 계약(Controller 응답, 공용 DTO)</strong></p>
<ul>
<li>프론트/다른 서비스가 Spring Data 타입 구조에 종속됨</li>
</ul>
</li>
<li><p><strong>공용 모듈 (<code>lib-*</code>)</strong></p>
<ul>
<li>여러 서비스가 함께 쓰는 모듈에 Spring Data 타입을 넣어버리면
그 모듈을 쓰는 모든 서비스가 Spring Data를 강제 당함</li>
</ul>
</li>
</ul>
<hr>
</br>

<h2 id="🔹-그래서-msa--ddd에서-권장하는-타협안">🔹 그래서 “MSA + DDD”에서 권장하는 타협안</h2>
<ol>
<li><p><strong>도메인/애플리케이션/공용에서는</strong></p>
<ul>
<li><code>CustomPage</code>, <code>CustomPageRequest</code> 같은 <strong>기술 중립 페이징 모델</strong> 사용</li>
</ul>
</li>
<li><p><strong>infra에서는</strong></p>
<ul>
<li>Spring Data JPA의 <code>Page/Pageable</code> 마음껏 사용</li>
<li>그리고 거기서 <code>CustomPage</code>로 변환해서 위로 올려줌</li>
</ul>
</li>
<li><p>Controller/REST 응답에서는</p>
<ul>
<li><code>CustomPage</code> 기반 DTO (<code>PageResponseV1</code>)만 노출</li>
</ul>
</li>
</ol>
<p>이렇게 하면:</p>
<ul>
<li><strong>DDD/헥사고날 철학 유지</strong> (도메인은 기술에 독립)</li>
<li><strong>MSA 간 API 계약도 깔끔하게 유지</strong></li>
<li>하지만 <strong>내부 구현에서는 Page/Pageable의 생산성을 그대로 가져감</strong></li>
</ul>
<hr>
</br>

<h2 id="🔹-요약">🔹 요약</h2>
<ul>
<li><strong>“MSA + DDD라서 Page/Pageable 자체가 나쁘다” → ❌ (틀린 말)</strong></li>
<li><strong>“도메인/애플리케이션/API 계약에 Page/Pageable을 직접 노출하는 건 피하는 게 좋다” → ✅</strong></li>
<li>그래서 <strong>infra에서만 JPA Page/Pageable 쓰고, 바깥으로는 내가 정의한 MyPage/DTO로 감싸는 패턴</strong>이 제일 깔끔하다.</li>
</ul>
<hr>
<hr>
</br>


<h1 id="custom-페이지네이션-구현">Custom 페이지네이션 구현</h1>
<h2 id="🔹-lib-공통-모듈">🔹 lib (공통 모듈)</h2>
<p>분리된 공통 모듈에 커스텀 페이지 구현</p>
<h3 id="🔸-custompagerequest">🔸 CustomPageRequest</h3>
<pre><code class="language-java">package lib.pagination;

public record CustomPageRequest(
    int page,
    int size) {

    public CustomPageRequest {

        if (page &lt; 0) {
            throw new IllegalArgumentException(&quot;페이지는 반드시 0 이상이어야 합니다. page=&quot; + page);
        }

        if (size &lt;= 0) {
            throw new IllegalArgumentException(&quot;사이즈는 반드시 0보다 커야 합니다. size=&quot; + size);
        }
    }

    public int offset() {
        return page * size;
    }

    // null 및 이상값 방로용 헬퍼 메서드
    public static CustomPageRequest of(Integer page, Integer size, int defaultPage, int defaultSize) {

        int p = (page == null || page &lt; 0) ? defaultPage : page;
        int s = (size == null || size &lt;= 0) ? defaultSize : size;

        return new CustomPageRequest(p, s);
    }
}
</code></pre>
</br>

<h3 id="🔸-custompageresult">🔸 CustomPageResult</h3>
<pre><code class="language-java">package lib.pagination;

import java.util.List;
import lombok.Getter;

@Getter
public class CustomPageResult&lt;T&gt; {

    private final List&lt;T&gt; itemList;     // 현재 페이지 데이터
    private final int page;             // 현재 페이지 번호
    private final int size;             // 페이지 크기
    private final long totalCount;      // 전체 데이터 개수 (Slice면 -1 허용)
    private final boolean hasNext;      // 다음 페이지 존재 여부

    private CustomPageResult(List&lt;T&gt; itemList, int page, int size, long totalCount, boolean hasNext) {

        this.itemList = itemList;
        this.page = page;
        this.size = size;
        this.totalCount = totalCount;
        this.hasNext = hasNext;
    }

    // totalCount를 아는 일반적인 페이징용
    public static &lt;T&gt; CustomPageResult&lt;T&gt; of(List&lt;T&gt; itemList, int page, int size, long totalCount) {

        boolean hasNext = ((long) page + 1) * size &lt; totalCount;

        return new CustomPageResult&lt;&gt;(itemList, page, size, totalCount, hasNext);
    }

    // totalCount를 모르는 Slice 기반 페이징용
    public static &lt;T&gt; CustomPageResult&lt;T&gt; sliceOf(List&lt;T&gt; itemList, int page, int size, boolean hasNext) {
        return new CustomPageResult&lt;&gt;(itemList, page, size, -1, hasNext);
    }
}
</code></pre>
</br>

<h2 id="🔹-다른-모듈에-사용-예시">🔹 다른 모듈에 사용 예시</h2>
<h3 id="🔸-hubdeliveryrepository">🔸 HubDeliveryRepository</h3>
<pre><code class="language-java">package chill_logistics.delivery_server.domain.repository;

import chill_logistics.delivery_server.domain.entity.HubDelivery;
import lib.pagination.CustomPageRequest;
import lib.pagination.CustomPageResult;

public interface HubDeliveryRepository {

    CustomPageResult&lt;HubDelivery&gt; searchByStartHubName(String startHubName, CustomPageRequest customPageRequest);
}
</code></pre>
</br>

<h3 id="🔸hubdeliveryrepositoryadapter">🔸HubDeliveryRepositoryAdapter</h3>
<pre><code class="language-java">package chill_logistics.delivery_server.infrastructure.repository;

import chill_logistics.delivery_server.domain.entity.HubDelivery;
import chill_logistics.delivery_server.domain.repository.HubDeliveryRepository;
import lib.pagination.CustomPageRequest;
import lib.pagination.CustomPageResult;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;

@RequiredArgsConstructor
public class HubDeliveryRepositoryAdapter implements HubDeliveryRepository {

    private final JpaHubDeliveryRepository jpaHubDeliveryRepository;

    @Override
    public CustomPageResult&lt;HubDelivery&gt; searchByStartHubName(
        String startHubName,
        CustomPageRequest customPageRequest) {

        PageRequest pageable = PageRequest.of(customPageRequest.page(), customPageRequest.size());

        Page&lt;HubDelivery&gt; page;

        // 검색어 없으면 전체 조회, 있으면 조건 검색
        if (startHubName == null || startHubName.isBlank()) {
            page = jpaHubDeliveryRepository.findByDeletedAtIsNull(pageable);
        } else {
            page = jpaHubDeliveryRepository.findByStartHubNameAndDeletedAtIsNull(startHubName, pageable);
        }

        return CustomPageResult.of(
            page.getContent(),
            page.getNumber(),
            page.getSize(),
            page.getTotalElements()
        );
    }
}
</code></pre>
</br>

<h3 id="🔸jpahubdeliveryrepository">🔸JpaHubDeliveryRepository</h3>
<pre><code class="language-java">package chill_logistics.delivery_server.infrastructure.repository;

import chill_logistics.delivery_server.domain.entity.HubDelivery;
import java.util.UUID;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;

public interface JpaHubDeliveryRepository extends JpaRepository&lt;HubDelivery, UUID&gt; {

    Page&lt;HubDelivery&gt; findByDeletedAtIsNull(Pageable pageable);

    Page&lt;HubDelivery&gt; findByStartHubNameAndDeletedAtIsNull(String hubName, Pageable pageable);
}
</code></pre>
</br>

<h3 id="🔸deliveryqueryservice">🔸DeliveryQueryService</h3>
<pre><code class="language-java">package chill_logistics.delivery_server.application;

import chill_logistics.delivery_server.application.dto.query.HubDeliveryInfoResponseV1;
import chill_logistics.delivery_server.application.dto.query.HubDeliverySummaryResponseV1;
import chill_logistics.delivery_server.domain.entity.HubDelivery;
import chill_logistics.delivery_server.domain.repository.FirmDeliveryRepository;
import chill_logistics.delivery_server.domain.repository.HubDeliveryRepository;
import chill_logistics.delivery_server.presentation.ErrorCode;
import chill_logistics.delivery_server.presentation.dto.response.HubDeliveryPageResponseV1;
import java.util.List;
import java.util.UUID;
import lib.pagination.CustomPageRequest;
import lib.pagination.CustomPageResult;
import lib.web.error.BusinessException;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class DeliveryQueryService {

    private final HubDeliveryRepository hubDeliveryRepository;
    private final FirmDeliveryRepository firmDeliveryRepository;

    /* [허브배송 단건 조회]
     */
    public HubDeliveryInfoResponseV1 getHubDelivery(UUID hubDeliveryId) {

        HubDelivery hubDelivery = hubDeliveryRepository.findById(hubDeliveryId)
            .orElseThrow(() -&gt; new BusinessException(ErrorCode.HUB_DELIVERY_NOT_FOUND));

        if (!(hubDelivery.getDeletedAt() == null)) {
            throw new BusinessException(ErrorCode.DELIVERY_HAS_BEEN_DELETED);
        }

        return HubDeliveryInfoResponseV1.from(hubDelivery);
    }

    /* [허브배송 검색 조회]
     * 검색 기준: startHubName
     * 검색어 없으면 전체 목록 조회, 있으면 조건 검색 결과 반환
     */
    public HubDeliveryPageResponseV1 searchHubDeliveryByHubName(String hubName, int page, int size) {

        CustomPageRequest pageRequest = new CustomPageRequest(page, size);

        // 페이징 된 엔티티 조회
        CustomPageResult&lt;HubDelivery&gt; entityPage = hubDeliveryRepository.searchByStartHubName(hubName, pageRequest);

        // 엔티티 → DTO로 변환
        List&lt;HubDeliverySummaryResponseV1&gt; dataSummaryList = entityPage.getItemList().stream()
            .map(HubDeliverySummaryResponseV1::from)
            .toList();

        return HubDeliveryPageResponseV1.of(entityPage, dataSummaryList);
    }
}
</code></pre>
</br>

<h3 id="🔸deliverycontroller">🔸DeliveryController</h3>
<pre><code class="language-java">package chill_logistics.delivery_server.presentation;

import chill_logistics.delivery_server.application.DeliveryQueryService;
import chill_logistics.delivery_server.application.dto.query.HubDeliveryInfoResponseV1;
import chill_logistics.delivery_server.presentation.dto.response.HubDeliveryPageResponseV1;
import java.util.UUID;
import lib.entity.BaseStatus;
import lib.web.response.BaseResponse;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestController;

@Slf4j
@RestController
@RequiredArgsConstructor
@RequestMapping(&quot;/v1&quot;)
public class DeliveryController {

    private final DeliveryQueryService deliveryQueryService;

    /**
     * [허브배송 단건 조회]
     *
     * @param hubDeliveryId 조회하고자 하는 허브배송의 UUID
     * @return 허브배송 상세 정보
     */
    @GetMapping(&quot;/hub-deliveries/{hubDeliveryId}&quot;)
    @ResponseStatus(HttpStatus.OK)
    public BaseResponse&lt;HubDeliveryInfoResponseV1&gt; getHubDelivery(
        @PathVariable(&quot;hubDeliveryId&quot;) UUID hubDeliveryId) {

        HubDeliveryInfoResponseV1 response = deliveryQueryService.getHubDelivery(hubDeliveryId);

        return BaseResponse.ok(response, BaseStatus.OK);
    }

    /**
     * [허브배송 검색 조회]
     *
     * @param startHubName 허브배송에서 검색하고자 하는 허브명
     * @param page         조회할 페이지 번호 (0부터 시작)
     * @param size         페이지 당 조회할 데이터 개수
     * @return 허브배송 요약 정보 목록 + 페이징 정보
     */
    @GetMapping(&quot;/hub-deliveries&quot;)
    @ResponseStatus(HttpStatus.OK)
    public BaseResponse&lt;HubDeliveryPageResponseV1&gt; searchHubDeliveries(
        @RequestParam(required = false) String startHubName,
        @RequestParam(defaultValue = &quot;0&quot;) int page,
        @RequestParam(defaultValue = &quot;20&quot;) int size) {

        HubDeliveryPageResponseV1 response = deliveryQueryService.searchHubDeliveryByHubName(
            startHubName, page, size);

        return BaseResponse.ok(response, BaseStatus.OK);
    }
}
</code></pre>
]]></description>
        </item>
    </channel>
</rss>