<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
    <channel>
        <title>be_zion.log</title>
        <link>https://velog.io/</link>
        <description>be_zion</description>
        <lastBuildDate>Sat, 25 Apr 2026 10:25:59 GMT</lastBuildDate>
        <docs>https://validator.w3.org/feed/docs/rss2.html</docs>
        <generator>https://github.com/jpmonette/feed</generator>
        <image>
            <title>be_zion.log</title>
            <url>https://images.velog.io/images/be_zion/profile/b4417b8a-00cd-408e-8992-20db67e36741/social.png</url>
            <link>https://velog.io/</link>
        </image>
        <copyright>Copyright (C) 2019. be_zion.log. All rights reserved.</copyright>
        <atom:link href="https://v2.velog.io/rss/be_zion" rel="self" type="application/rss+xml"/>
        <item>
            <title><![CDATA[Claude API 토큰 효율 높이기]]></title>
            <link>https://velog.io/@be_zion/Claude-API-%ED%86%A0%ED%81%B0-%ED%9A%A8%EC%9C%A8-%EB%86%92%EC%9D%B4%EA%B8%B0</link>
            <guid>https://velog.io/@be_zion/Claude-API-%ED%86%A0%ED%81%B0-%ED%9A%A8%EC%9C%A8-%EB%86%92%EC%9D%B4%EA%B8%B0</guid>
            <pubDate>Sat, 25 Apr 2026 10:25:59 GMT</pubDate>
            <description><![CDATA[<blockquote>
<p>🤖 이 글은 <a href="https://claude.ai/code">Claude Code</a>을 활용하여 작성되었습니다.</p>
</blockquote>
<p>캐싱을 설정했다. 그런데 토큰이 줄지 않았다.</p>
<p>Anthropic 문서를 보고 <code>cache_control: { type: &#39;ephemeral&#39; }</code>을 추가했다. 코드도 정상 동작했다. 근데 캐시가 전혀 작동하지 않고 있었다는 사실을 한참 뒤에야 알았다. 이유는 단순했다. <strong>위치가 틀렸다.</strong></p>
<p>이 글은 그 버그를 발견하면서 정리한 Claude API 토큰 효율 개선 원칙들이다. Claude 채팅 기능을 붙이는 과정에서 직접 적용한 내용을 바탕으로 썼다.</p>
<hr>
<h2 id="tldr">TL;DR</h2>
<ul>
<li><code>body-level cache_control</code>은 Anthropic API가 에러 없이 무시하는 잘못된 위치다</li>
<li>캐싱은 <strong>system block</strong> 또는 <strong>messages content block</strong>에 붙여야 한다</li>
<li>캐시 브레이크포인트는 요청당 <strong>최대 4개</strong>로 제한된다</li>
<li>캐시 TTL은 <strong>5분</strong>이다 — &quot;영구 캐시&quot;는 없다</li>
<li>캐시 레이어 설계의 핵심은 &quot;얼마나 자주 바뀌는가&quot;를 기준으로 나누는 것</li>
<li>보내는 데이터 자체를 줄이는 것이 캐싱만큼 중요하다</li>
<li>모델을 작업 복잡도에 맞춰 분기하면 비용을 추가로 줄일 수 있다</li>
</ul>
<hr>
<h2 id="1️⃣-프롬프트-캐싱--위치가-전부다">1️⃣ 프롬프트 캐싱 — 위치가 전부다</h2>
<h3 id="동작-방식">동작 방식</h3>
<p>Anthropic의 프롬프트 캐싱은 요청 전체를 캐싱하는 게 아니다. <strong>특정 지점(breakpoint)까지의 컨텍스트</strong>를 서버에 저장해두고, 동일한 prefix가 오면 그 지점부터 이어서 처리한다.</p>
<p>캐시 브레이크포인트를 설정할 수 있는 위치는 두 곳이다. 단, <strong>요청당 최대 4개</strong>까지만 설정할 수 있다. 4개를 초과하면 API 에러가 발생한다.</p>
<pre><code>[system block]       ← cache_control 가능 ✅ (여러 블록 가능, 4개 한도 내에서)
[messages]
  user: ...
  assistant: ...     ← content block에 cache_control 가능 ✅
  user: 새 질문      ← 캐시 이후 (매번 새로 처리)</code></pre><p>캐시는 브레이크포인트를 설정한 지점까지의 prefix를 서버에 보관한다. 보관 시간은 <strong>5분(TTL)</strong>이다. 5분 안에 동일한 prefix로 요청이 오지 않으면 캐시가 만료되어 다음 요청에서 다시 쓰기 비용을 지불해야 한다. 요청 빈도가 낮은 기능에는 캐싱 효과가 제한적이다.</p>
<h3 id="잘못된-방식-vs-올바른-방식">잘못된 방식 vs 올바른 방식</h3>
<pre><code class="language-ts">// ❌ body-level — Anthropic이 에러 없이 무시함
const body = {
  model,
  messages,
  cache_control: { type: &#39;ephemeral&#39; },  // 무효
}

// ✅ system block에 직접 붙이기
system: [
  {
    type: &#39;text&#39;,
    text: &#39;정적인 지시사항...&#39;,
    cache_control: { type: &#39;ephemeral&#39; },
  }
]

// ✅ messages content block에 붙이기
messages: [
  {
    role: &#39;assistant&#39;,
    content: [
      {
        type: &#39;text&#39;,
        text: &#39;이전 응답...&#39;,
        cache_control: { type: &#39;ephemeral&#39; },
      }
    ]
  },
  { role: &#39;user&#39;, content: &#39;새 질문&#39; }
]</code></pre>
<p>API가 에러를 내지 않는다는 게 더 위험하다. 조용히 실패하기 때문에 한참 동안 캐싱이 작동한다고 착각할 수 있다.</p>
<h3 id="히스토리-캐싱">히스토리 캐싱</h3>
<p>채팅 히스토리를 캐싱하려면 <strong>마지막 assistant turn</strong>에 브레이크포인트를 설정한다. 매 요청마다 바뀌는 새 user 메시지 직전까지를 캐시하는 것이다. user 메시지에 걸면 질문이 바뀔 때마다 캐시가 무효화되므로 assistant turn이 적합하다.</p>
<pre><code class="language-ts">const messages = history.map(({ role, content }, i) =&gt; {
  const isLastAssistant = role === &#39;assistant&#39; &amp;&amp; i === history.length - 2
  if (isLastAssistant &amp;&amp; history.length &gt;= 2) {
    return {
      role,
      content: [{ type: &#39;text&#39;, text: content, cache_control: { type: &#39;ephemeral&#39; } }],
    }
  }
  return { role, content }
})</code></pre>
<p>대화가 4턴 이상 쌓이면 캐시 히트가 발생하기 시작한다. 대화가 길어질수록 절감 효과가 커진다.</p>
<hr>
<h2 id="2️⃣-캐시-레이어-설계--변경-빈도가-기준">2️⃣ 캐시 레이어 설계 — 변경 빈도가 기준</h2>
<p>캐싱을 제대로 설계하려면 프롬프트 구성요소를 <strong>변경 빈도</strong>에 따라 분리해야 한다.</p>
<blockquote>
<p>변경 빈도가 다른 데이터를 하나의 블록에 묶으면, 가장 빠르게 바뀌는 것이 전체 캐시를 무효화한다.</p>
</blockquote>
<p>채팅 시스템을 예로 들면 이렇다.</p>
<pre><code>자주 바뀜 (캐싱 의미 없음)
├── 사용자 메시지
└── 처리 결과 요약 (실행마다 업데이트)

가끔 바뀜 (5분 내 반복 요청 시 히트 기대 가능)
└── 사용 가능한 데이터 목록 (추가/삭제 시만)

거의 안 바뀜 (요청 빈도가 높으면 캐시 히트율 높음)
└── 역할 정의 + 액션 형식 지시사항</code></pre><p>이 분석을 바탕으로 system block을 3개로 분리했다. system block 2개 + messages 1개를 사용하므로 브레이크포인트 총 3개 — 4개 한도 내에 여유가 있다.</p>
<pre><code class="language-ts">system: [
  // block 1: 역할·지시사항 — 거의 안 바뀜 → 캐시 포인트 1
  { type: &#39;text&#39;, text: staticInstructions, cache_control: { type: &#39;ephemeral&#39; } },

  // block 2: 데이터 목록 — 추가/삭제 시만 무효화 → 캐시 포인트 2
  { type: &#39;text&#39;, text: dataList, cache_control: { type: &#39;ephemeral&#39; } },

  // block 3: 처리 결과 — 실행마다 바뀜 → 캐싱 안 함 (여기 걸면 쓰기 비용만 발생)
  { type: &#39;text&#39;, text: resultSummary },
]
// messages 내 마지막 assistant turn → 캐시 포인트 3 (총 3/4 사용)</code></pre>
<p>✅ 어차피 무효화될 데이터를 캐싱하면 캐시 쓰기 비용만 추가로 발생한다.</p>
<hr>
<h2 id="3️⃣-보내는-토큰-줄이기--컨텍스트-최소화">3️⃣ 보내는 토큰 줄이기 — 컨텍스트 최소화</h2>
<p>캐싱이 &quot;이미 보낸 토큰을 재활용&quot;하는 방식이라면, 이건 <strong>애초에 보내는 토큰을 줄이는</strong> 방식이다. 두 가지를 병행해야 효과가 크다.</p>
<h3 id="조건부-컨텍스트">조건부 컨텍스트</h3>
<p>모든 요청에 모든 정보를 넣을 필요가 없다. 필요한 맥락이 무엇인지 요청마다 판단해서 선택적으로 포함한다.</p>
<p>예를 들어, 아이템별 상세 위치 정보를 매 채팅마다 포함하고 있었다. 그런데 &quot;공간 활용률 높이려면?&quot; 같은 분석 질문에는 위치 정보가 전혀 필요하지 않다.</p>
<pre><code class="language-ts">// 마지막 user 메시지에 이동 관련 키워드가 있을 때만 위치 정보 포함
const MOVE_KEYWORDS = [&#39;이동&#39;, &#39;옮겨&#39;, &#39;위치&#39;, &#39;배치&#39;, &#39;모서리&#39;, &#39;move&#39;]

const needsPos = MOVE_KEYWORDS.some((k) =&gt; lastUserMsg.includes(k))
const summary = buildSummary(result, needsPos)</code></pre>
<p>아이템 10개 기준 요청당 약 <strong>150~200 토큰</strong> 절약.</p>
<h3 id="히스토리에서-처리된-데이터-제거">히스토리에서 처리된 데이터 제거</h3>
<p>Claude 응답에 시뮬레이션 실행을 위한 action JSON이 포함되는 경우가 있다.</p>
<pre><code>분석 결과입니다. 해당 아이템을 다른 위치로 이동하는 것을 추천합니다.
&lt;action&gt;{&quot;type&quot;:&quot;move_item&quot;,&quot;moves&quot;:[{&quot;productName&quot;:&quot;item-a&quot;,&quot;fromBoxIndex&quot;:1,&quot;toBoxIndex&quot;:0,&quot;anchor&quot;:&quot;bottom-center&quot;}]}&lt;/action&gt;</code></pre><p>이미 실행된 action은 다음 요청의 히스토리로 전송할 필요가 없다. assistant 메시지를 히스토리에 추가하기 전에 제거한다.</p>
<pre><code class="language-ts">const cleaned = role === &#39;assistant&#39;
  ? content.replace(/&lt;action&gt;[\s\S]*?&lt;\/action&gt;/g, &#39;&#39;).trimEnd()
  : content</code></pre>
<p>요청당 <strong>30~100 토큰</strong> 절약. action JSON이 복잡할수록 효과가 커진다.</p>
<h3 id="히스토리-크기-동적-조정">히스토리 크기 동적 조정</h3>
<p>모든 대화 유형에 동일한 히스토리 크기를 유지할 필요가 없다. 맥락이 많이 필요한 요청과 그렇지 않은 요청을 구분한다.</p>
<pre><code class="language-ts">// 맥락이 중요한 요청: 이전 대화 유지 → 8개
// 단순 분석 질문: 최근 내용만으로 충분 → 4개로 절반 축소
const historyLimit = needsContext ? 8 : 4
const trimmed = history.slice(-historyLimit)</code></pre>
<hr>
<h2 id="4️⃣-모델-분기--작업-복잡도에-맞는-모델-선택">4️⃣ 모델 분기 — 작업 복잡도에 맞는 모델 선택</h2>
<p>모든 요청이 같은 수준의 모델을 필요로 하지 않는다. 작업 복잡도에 따라 모델을 분리하면 비용을 크게 줄일 수 있다.</p>
<p><strong>작업 유형별 모델 매핑</strong></p>
<table>
<thead>
<tr>
<th>작업 유형</th>
<th>요구사항</th>
<th>적합한 모델</th>
</tr>
</thead>
<tbody><tr>
<td>구조화된 JSON 생성 (action 반환)</td>
<td>형식 정확도가 높아야 함</td>
<td>claude-sonnet-4-5</td>
</tr>
<tr>
<td>통계 분석 리포트 생성</td>
<td>긴 텍스트, 복잡한 추론</td>
<td>claude-sonnet-4-5</td>
</tr>
<tr>
<td>결과 간단 분석 (3~5줄 요약)</td>
<td>단순 텍스트 생성</td>
<td>claude-haiku-4-5</td>
</tr>
<tr>
<td>히스토리 패턴 분석</td>
<td>패턴 인식 + 추천</td>
<td>claude-sonnet-4-5</td>
</tr>
</tbody></table>
<blockquote>
<p>Haiku는 3.5 → 4.5 세대로 올수록 성능이 크게 올랐지만 가격도 상승했다. 단순 텍스트 요약이라면 구버전 Haiku도 충분하다. 모델 ID는 배포 시점에 따라 달라지므로 환경변수로 분리해두는 것이 좋다.</p>
</blockquote>
<p>예를 들어 즉석 분석, 통계 리포트, 패턴 분석 요청은 action JSON을 생성할 일이 없다. 순수 텍스트 분석이다. 채팅만 정확한 JSON 생성이 필요하므로 Sonnet을 유지한다.</p>
<pre><code class="language-ts">const modelMain = process.env.ANTHROPIC_MODEL        // Sonnet
const modelFast = process.env.ANTHROPIC_MODEL_FAST   // Haiku (미설정 시 Sonnet으로 fallback)
  ?? modelMain

// 채팅: action 정확도 필요 → main 모델
// 분석 전용: 순수 텍스트 → fast 모델
const model = type === &#39;chat&#39; ? modelMain : modelFast</code></pre>
<p><code>ANTHROPIC_MODEL_FAST</code>를 설정하지 않으면 기존처럼 동작하므로 <strong>안전하게 점진적으로 적용</strong>할 수 있다.</p>
<hr>
<h2 id="5️⃣-시스템-프롬프트-압축">5️⃣ 시스템 프롬프트 압축</h2>
<p>캐싱을 가장 효과적으로 만드는 방법 중 하나는 캐싱 대상 자체를 줄이는 것이다. 시스템 프롬프트가 짧을수록 캐시 히트 조건이 단순해지고, 신규 요청에서 처리해야 할 토큰도 줄어든다.</p>
<p><strong>압축 전</strong></p>
<pre><code>4. 아이템 이동 (컨테이너 변경 또는 위치 지정):
   &lt;action&gt;{&quot;type&quot;:&quot;move_item&quot;,&quot;moves&quot;:[{&quot;itemName&quot;:&quot;아이템명&quot;,&quot;fromIndex&quot;:0,&quot;toIndex&quot;:1,&quot;anchor&quot;:&quot;bottom-center&quot;,&quot;rotation&quot;:&quot;flat&quot;}]}&lt;/action&gt;
   anchor 값(선택): &quot;bottom-front-left&quot;,&quot;bottom-front-right&quot;,&quot;bottom-back-left&quot;,&quot;bottom-back-right&quot;,&quot;bottom-center&quot;,&quot;top-front-left&quot;,&quot;top-front-right&quot;,&quot;top-back-left&quot;,&quot;top-back-right&quot;,&quot;top-center&quot;
   anchor와 rotation은 생략 가능. 생략 시 알고리즘이 최적 위치/방향 선택.
   fromIndex/toIndex는 0-based. 같은 컨테이너 내 이동도 가능.
   복수 아이템 동시 이동 가능.</code></pre><p><strong>압축 후</strong></p>
<pre><code>4. 아이템 이동: &lt;action&gt;{&quot;type&quot;:&quot;move_item&quot;,&quot;moves&quot;:[{&quot;itemName&quot;:&quot;아이템명&quot;,&quot;fromIndex&quot;:0,&quot;toIndex&quot;:0,&quot;anchor&quot;:&quot;bottom-front-left&quot;,&quot;rotation&quot;:&quot;flat&quot;}]}&lt;/action&gt;
   anchor(선택): bottom/top + front/back + left/right/center 조합. 생략 시 알고리즘 자동 선택.
   fromIndex/toIndex는 0-based. 같은 컨테이너 내 이동 가능. 복수 이동 가능.</code></pre><p>중요한 건 압축이 <strong>의미를 훼손하지 않아야</strong> 한다는 점이다. 예시 값을 하나 남기고 패턴을 설명하는 방식으로 토큰을 줄였다.</p>
<p><strong>XML 태그 활용</strong></p>
<p>Claude는 XML 구조를 잘 이해한다. 긴 지시사항을 줄임표로 압축하는 것보다 <code>&lt;rules&gt;</code>, <code>&lt;format&gt;</code>, <code>&lt;examples&gt;</code> 같은 태그로 구조화하면 토큰 효율과 응답 정확도를 동시에 잡을 수 있다.</p>
<pre><code>// 비구조화 (압축해도 모델이 경계를 파악하기 어려움)
규칙: A를 하라. B는 하지 마라. 형식은 C다. 예시: ...

// XML 구조화 (구조가 명확해 모델 이해도 ↑, 불필요한 설명 제거 가능)
&lt;rules&gt;A를 하라. B는 하지 마라.&lt;/rules&gt;
&lt;format&gt;C 형식을 따른다.&lt;/format&gt;
&lt;examples&gt;...&lt;/examples&gt;</code></pre><hr>
<h2 id="적용-결과-요약">적용 결과 요약</h2>
<table>
<thead>
<tr>
<th>개선</th>
<th>분류</th>
<th>효과</th>
</tr>
</thead>
<tbody><tr>
<td>body-level → message content 캐싱</td>
<td>버그 수정</td>
<td>히스토리 캐시 실제 활성화</td>
</tr>
<tr>
<td>system block 3분리</td>
<td>캐시 설계</td>
<td>캐시 히트율 향상</td>
</tr>
<tr>
<td>instant/report 캐시 system block 적용</td>
<td>캐시 확장</td>
<td>반복 분석 요청 시 효과</td>
</tr>
<tr>
<td>위치 정보 조건부 전송</td>
<td>토큰 절약</td>
<td><del>150</del>200 토큰/요청</td>
</tr>
<tr>
<td>action 태그 히스토리 제거</td>
<td>토큰 절약</td>
<td><del>30</del>100 토큰/요청</td>
</tr>
<tr>
<td>히스토리 크기 동적 조정</td>
<td>토큰 절약</td>
<td>단순 질문 시 히스토리 절반</td>
</tr>
<tr>
<td>모델 분기 (Sonnet / Haiku)</td>
<td>비용 절감</td>
<td>분석 전용 요청 비용 대폭 감소</td>
</tr>
</tbody></table>
<hr>
<blockquote>
<p>⚠️ <strong>캐싱 제약사항 요약</strong></p>
<ul>
<li>브레이크포인트는 요청당 <strong>최대 4개</strong>. 초과 시 API 에러 발생</li>
<li>캐시 TTL은 <strong>5분</strong>. 5분 이상 간격으로 발생하는 드문 요청에는 효과 없음</li>
<li>캐싱이 적용되려면 프롬프트 최소 토큰 기준(약 1,024 토큰)을 충족해야 함</li>
</ul>
</blockquote>
<hr>
<p>토큰 비용은 기능이 추가될수록 선형적으로 증가한다. 채팅에 히스토리가 쌓이고, 컨텍스트가 풍부해질수록 요청당 토큰이 늘어난다. 미리 설계하지 않으면 나중에 고치기 어렵다.</p>
<p>이 과정에서 배운 핵심은 두 가지다.</p>
<p>첫째, <strong>캐싱은 &quot;어디에 붙이는가&quot;보다 &quot;무엇이 얼마나 자주 바뀌는가&quot;를 먼저 분석해야 한다.</strong> 변경 빈도가 다른 데이터를 하나의 블록에 묶으면, 가장 빠르게 바뀌는 것이 전체 캐시를 무효화한다.</p>
<p>둘째, <strong>&quot;보내지 않는 것&quot;이 &quot;캐시 히트&quot;보다 확실하다.</strong> 조건부 컨텍스트, 히스토리 정리, 히스토리 크기 조정은 캐싱과 독립적으로 항상 효과가 있다.</p>
<blockquote>
<p>토큰 절약은 부수 효과다. 캐시 히트가 발생하면 대형 컨텍스트의 응답 속도가 눈에 띄게 줄어든다. 진짜 목적은 사용자 경험이다.</p>
</blockquote>
]]></description>
        </item>
        <item>
            <title><![CDATA[Claude API를 서비스에 붙이면서 — 스트리밍, 시뮬레이션 제어, 토큰 절약]]></title>
            <link>https://velog.io/@be_zion/Claude-API%EB%A5%BC-%EC%84%9C%EB%B9%84%EC%8A%A4%EC%97%90-%EB%B6%99%EC%9D%B4%EB%A9%B4%EC%84%9C-%EC%8A%A4%ED%8A%B8%EB%A6%AC%EB%B0%8D-%EC%8B%9C%EB%AE%AC%EB%A0%88%EC%9D%B4%EC%85%98-%EC%A0%9C%EC%96%B4-%ED%86%A0%ED%81%B0-%EC%A0%88%EC%95%BD</link>
            <guid>https://velog.io/@be_zion/Claude-API%EB%A5%BC-%EC%84%9C%EB%B9%84%EC%8A%A4%EC%97%90-%EB%B6%99%EC%9D%B4%EB%A9%B4%EC%84%9C-%EC%8A%A4%ED%8A%B8%EB%A6%AC%EB%B0%8D-%EC%8B%9C%EB%AE%AC%EB%A0%88%EC%9D%B4%EC%85%98-%EC%A0%9C%EC%96%B4-%ED%86%A0%ED%81%B0-%EC%A0%88%EC%95%BD</guid>
            <pubDate>Sat, 11 Apr 2026 15:48:20 GMT</pubDate>
            <description><![CDATA[<h2 id="tldr">TL;DR</h2>
<p><a href="https://velog.io/@be_zion/Claude-API">이전 글</a>에서 Claude API의 구조를 정리했다. 모델이 토큰을 순차적으로 생성하기 때문에 SSE가 자연스러운 선택이라는 것, REST + SSE가 gRPC보다 단순한 이유, Transformer의 Attention이 입력 전체를 한 번에 참조해 어느 위치의 토큰이든 관계를 계산할 수 있다는 것.</p>
<p>이번엔 그 이해를 실제 서비스에 직접 붙이면서 생긴 일들이다. AI로 알고리즘을 제어하는 패턴, SSE 파싱 구현, 토큰 절약까지.</p>
<hr>
<h2 id="1️⃣-ai로-알고리즘을-제어하는-패턴">1️⃣ AI로 알고리즘을 제어하는 패턴</h2>
<p>이 서비스에 Claude API를 붙인 이유는 하나다. 사용자마다 요구사항이 다르기 때문이다.</p>
<p>&quot;이 항목은 우선순위를 높여주세요&quot;, &quot;특정 조건은 제외해주세요&quot;, &quot;균형을 맞춰주세요&quot; — 알고리즘은 이런 자연어 요청을 처리할 수 없다. 스트리밍 채팅 화면을 넣은 이유가 여기 있다. 사용자가 채팅으로 원하는 배치 방식을 전달하면, AI가 그 의도를 해석해 알고리즘에 전달하는 구조다.</p>
<p>전체 요청 흐름은 이렇다:</p>
<pre><code>[사용자 채팅 입력]
    ↓
[프론트엔드]  POST /api/analyze  { type: &quot;chat&quot;, messages, result }
    ↓
[서버]        system 블록 구성, 히스토리 트리밍
    ↓
[Anthropic]   POST /v1/messages  → SSE stream
    ↓
[서버]        청크 버퍼링 → &lt;action&gt; 태그 감지 전까지 실시간 포워딩 → 태그 추출
    ↓
[프론트엔드]  텍스트 실시간 렌더링 + 액션 버튼 노출
    ↓
[버튼 클릭]   제약 조건 → 알고리즘 재계산</code></pre><p>여기서 AI와 알고리즘의 역할을 명확히 나눌 필요가 있었다.</p>
<p><strong>초기 배치는 알고리즘이 담당한다.</strong> 여러 물리적 제약 조건을 동시에 만족해야 하는 최적화 문제는 NP-hard다. 처음부터 LLM에게 좌표를 계산하게 하면 모든 제약 조건을 동시에 만족하는 결과를 보장할 수 없다.</p>
<p><strong>수정 단계에서는 AI가 제약 조건을 생성하고, 알고리즘이 재계산한다.</strong> 사용자 요청을 AI가 해석해 구조화된 액션으로 변환하면, 알고리즘이 그 조건을 반영해 다시 배치를 계산한다. 다음 단계로는 알고리즘이 계산한 실제 좌표를 AI에게 전달해, AI가 특정 요소의 위치 조정을 직접 제안하는 방식도 고려하고 있다.</p>
<pre><code>[초기 배치]  알고리즘 → 유효한 좌표
[수정 단계]  사용자 요청 → LLM 해석 → 제약 조건 생성 → 알고리즘 재계산
[다음 단계]  기존 좌표 → LLM 분석 → 위치 조정 제안 → 적용</code></pre><p>LLM이 생성할 수 있는 액션의 범위를 정의한다:</p>
<pre><code class="language-typescript">type SimAction =
  | { type: &quot;filter_options&quot;; names: string[] }
  | { type: &quot;apply_constraints&quot;; constraints: ItemConstraint[] }
  | { type: &quot;combined&quot;; names?: string[]; constraints?: ItemConstraint[] };

interface ItemConstraint {
  itemName: string;
  mode: &quot;prioritize&quot; | &quot;restrict&quot; | &quot;default&quot;;
}</code></pre>
<p><strong>LLM이 선택할 수 있는 범위를 좁힐수록 엉뚱한 출력이 줄어든다.</strong> 선택지는 처리 방식의 핵심 파라미터뿐이다. 현재 좌표 계산은 알고리즘이 담당하고, AI는 제약 조건 생성에만 관여한다.</p>
<h3 id="action-태그로-텍스트와-액션을-동시에"><code>&lt;action&gt;</code> 태그로 텍스트와 액션을 동시에</h3>
<p>구조화된 출력 방식에는 선택지가 있다.</p>
<table>
<thead>
<tr>
<th>방식</th>
<th>장점</th>
<th>단점</th>
</tr>
</thead>
<tbody><tr>
<td>JSON mode</td>
<td>스키마 강제</td>
<td>스트리밍과 궁합 나쁨</td>
</tr>
<tr>
<td>Tool use</td>
<td>명확한 구조</td>
<td>텍스트+툴 블록 분리 처리 필요</td>
</tr>
<tr>
<td>커스텀 태그</td>
<td>스트리밍 유지, 텍스트+액션 동시</td>
<td>파싱 직접 구현</td>
</tr>
</tbody></table>
<p>커스텀 태그를 선택했다. <code>&lt;action&gt;</code> 태그가 응답 맨 끝에만 등장하기 때문에, 태그가 감지되기 전까지는 텍스트를 실시간으로 스트리밍하고 태그가 나타나는 순간부터만 누적한다. 스트리밍이 끝나면 파싱한 액션을 별도 이벤트로 전송한다. 텍스트 실시간 노출과 액션 버튼을 하나의 SSE 스트림에서 처리할 수 있다는 점이 이 방식의 핵심이다.</p>
<p>시스템 프롬프트에 형식을 명시한다:</p>
<pre><code>응답 맨 끝에 반드시 아래 형식을 추가하세요.

&lt;action&gt;{&quot;type&quot;:&quot;apply_constraints&quot;,&quot;constraints&quot;:[{&quot;itemName&quot;:&quot;항목A&quot;,&quot;mode&quot;:&quot;prioritize&quot;}]}&lt;/action&gt;

액션이 없으면: &lt;action&gt;null&lt;/action&gt;
항목 이름은 최적화 결과에 나온 이름 그대로 사용하세요.</code></pre><p>스트리밍이 완료된 후 전체 텍스트에서 태그를 추출한다:</p>
<pre><code class="language-typescript">function parseAction(fullText: string): { text: string; action: unknown } {
  const match = fullText.match(/&lt;action&gt;([\s\S]*?)&lt;\/action&gt;/);
  if (!match) return { text: fullText, action: null };

  const text = fullText.replace(/&lt;action&gt;[\s\S]*?&lt;\/action&gt;/, &quot;&quot;).trimEnd();
  try {
    return { text, action: JSON.parse(match[1].trim()) };
  } catch {
    return { text, action: null }; // 파싱 실패해도 텍스트는 정상 노출
  }
}</code></pre>
<p>버튼을 누르면 제약 조건을 알고리즘에 전달해 재계산한다. AI가 판단하고 알고리즘이 실행한다.</p>
<hr>
<h2 id="2️⃣-sse-파싱-구현">2️⃣ SSE 파싱 구현</h2>
<p>이전 글에서 SSE는 &quot;토큰이 생성되는 즉시 전달되는 방식&quot;이라고 정리했다. 이를 받으려면 요청에 <code>stream: true</code>를 포함해야 한다. Anthropic API 호출 코드다:</p>
<pre><code class="language-typescript">const aiRes = await fetch(&quot;https://api.anthropic.com/v1/messages&quot;, {
  method: &quot;POST&quot;,
  headers: {
    &quot;Content-Type&quot;: &quot;application/json&quot;,
    &quot;x-api-key&quot;: process.env.ANTHROPIC_API_KEY,
    &quot;anthropic-version&quot;: &quot;2023-06-01&quot;,
  },
  body: JSON.stringify({
    model: &quot;claude-opus-4-6&quot;,
    max_tokens: 512,
    stream: true,
    cache_control: { type: &quot;ephemeral&quot; },
    system: [...],
    messages: [...],
  }),
});</code></pre>
<p>API 키는 서버 환경변수로만 관리한다. 클라이언트에 노출되면 키가 탈취될 수 있기 때문에, 브라우저에서 Anthropic API를 직접 호출하는 구조는 피해야 한다.</p>
<p>응답은 SSE 스트림으로 온다. 실제로 파싱하면 이렇게 된다:</p>
<pre><code class="language-typescript">const reader = aiRes.body!.getReader();
const decoder = new TextDecoder();
let buffer = &quot;&quot;;

while (true) {
  const { done, value } = await reader.read();
  if (done) break;

  buffer += decoder.decode(value, { stream: true });
  const lines = buffer.split(&quot;\n&quot;);
  buffer = lines.pop() ?? &quot;&quot;; // 불완전한 마지막 줄은 다음 청크와 합침

  for (const line of lines) {
    if (!line.startsWith(&quot;data: &quot;)) continue;
    const raw = line.slice(6).trim();
    if (raw === &quot;[DONE]&quot;) continue;

    const event = JSON.parse(raw);
    if (
      event.type === &quot;content_block_delta&quot; &amp;&amp;
      event.delta?.type === &quot;text_delta&quot;
    ) {
      res.write(`data: ${JSON.stringify({ text: event.delta.text })}\n\n`);
    }
  }
}</code></pre>
<p><code>lines.pop()</code>으로 마지막 줄을 다시 buffer에 보존하는 것이 핵심이다. 네트워크 청크는 줄 경계와 무관하게 도착한다. 불완전한 마지막 줄을 버퍼에 남겨 다음 청크와 합쳐야 파싱 오류가 없다. 이전 글에서 SSE를 이론으로 이해했다면, 이 버퍼 처리가 그 이론의 실전 구현이다.</p>
<hr>
<h2 id="3️⃣-토큰-절약">3️⃣ 토큰 절약</h2>
<p>동작은 됐는데 비용이 문제였다. 채팅마다 시스템 프롬프트가 통째로 전송되고, 대화가 길어질수록 히스토리가 누적된다.</p>
<h3 id="프롬프트-캐싱">프롬프트 캐싱</h3>
<p>시스템 프롬프트란 AI의 역할과 출력 형식을 지정하는 고정된 지시문이다. 사용자 메시지와 달리 매 요청마다 동일하게 전송된다. 기존엔 이것을 첫 번째 <code>user</code> 메시지에 임베딩했다.</p>
<pre><code class="language-typescript">// 기존 — 캐싱 불가. 메시지 배열이 매 요청마다 달라짐
messages: [
  { role: &quot;user&quot;, content: `${systemPrompt}\n\n사용자: ${firstMessage}` },
  ...history,
];</code></pre>
<p>Anthropic Messages API의 <code>role</code>은 <code>user</code>(사람)와 <code>assistant</code>(모델) 두 가지뿐이다. <code>system</code>은 별도 파라미터로 분리되어 있어 messages 배열에 들어갈 수 없다. 그래서 시스템 프롬프트를 messages에 넣으려면 첫 번째 <code>user</code> 메시지에 임베딩하는 수밖에 없었다.</p>
<p>이 방식은 캐싱이 불가능하다. Anthropic의 프롬프트 캐싱은 prefix가 고정되어야 작동하는데, 메시지 배열이 매 요청마다 달라지기 때문이다.</p>
<p>시스템 프롬프트를 <code>system</code> 파라미터로 분리하고, 변경 빈도가 다른 내용은 블록을 나눠 각각 breakpoint를 건다:</p>
<pre><code class="language-typescript">body.system = [
  // breakpoint 1: 역할 정의 + 액션 형식 — 서비스 내내 고정
  { type: &quot;text&quot;, text: staticInstructions, cache_control: { type: &quot;ephemeral&quot; } },
  // breakpoint 2: 최적화 결과 컨텍스트 — 세션 내에서 고정
  { type: &quot;text&quot;, text: resultContext, cache_control: { type: &quot;ephemeral&quot; } },
];</code></pre>
<p>두 블록은 독립적으로 캐싱된다. 결과 컨텍스트가 바뀌어도 정적 지시문 캐시는 유지된다.</p>
<p>캐시 히트 시 해당 토큰 요금이 약 10%로 줄어든다. 모델마다 캐싱 가능한 최소 토큰이 다르고(Opus 4.6 기준 4096 토큰), 최솟값 미만이면 오류 없이 조용히 스킵된다. system 블록의 총 토큰이 기준 미만이면 캐싱 자체가 작동하지 않으므로, 적용 후 <code>cache_read_input_tokens</code> 값으로 실제 히트 여부를 확인해야 한다.</p>
<h3 id="대화-히스토리-자동-캐싱">대화 히스토리 자동 캐싱</h3>
<p>요청 최상위에 <code>cache_control</code>을 추가하면 대화가 길어질수록 히스토리도 자동으로 캐싱된다:</p>
<pre><code class="language-typescript">const body = {
  model, max_tokens: maxTokens, stream: true,
  cache_control: { type: &quot;ephemeral&quot; }, // 마지막 캐시 가능 블록에 자동 적용
  system: [...],
  messages: trimmedMessages,
};</code></pre>
<p>매 턴마다 breakpoint가 자동으로 앞으로 이동한다. 대화가 쌓일수록 캐시에서 읽히는 비율이 높아진다.</p>
<h3 id="히스토리-트리밍과-max_tokens-조정">히스토리 트리밍과 max_tokens 조정</h3>
<pre><code class="language-typescript">// 최근 4턴(8개 메시지)만 유지
const trimmed = messages.slice(-8);

// 응답 유형별 상한 분리
const maxTokens = type === &quot;report&quot; ? 1024 : 512;</code></pre>
<p>대화 맥락은 대부분 최근 몇 턴에 있다. context window는 유한하고, 누적될수록 토큰 비용도 선형으로 늘어난다. 히스토리 트리밍은 오래된 맥락을 버리는 대신 비용을 줄이는 가장 단순한 방법이다.</p>
<hr>
<h2 id="마무리">마무리</h2>
<p>이전 글에서 정리한 개념 — SSE가 토큰 생성 구조와 맞는 이유 — 이 실제 구현에서 그대로 나타났다. 이 글에서 새로 다룬 프롬프트 캐싱도 마찬가지다. prefix가 고정되어야 캐시가 작동한다는 원칙은, 구조를 이해하지 못하면 왜 안 되는지조차 알기 어렵다.</p>
<blockquote>
<p>구조를 이해하면 디버깅이 빨라진다. &quot;왜 이렇게 설계됐는가&quot;를 알면 &quot;왜 이렇게 동작하는가&quot;가 예측 가능해진다.</p>
</blockquote>
]]></description>
        </item>
        <item>
            <title><![CDATA[Claude API]]></title>
            <link>https://velog.io/@be_zion/Claude-API</link>
            <guid>https://velog.io/@be_zion/Claude-API</guid>
            <pubDate>Sat, 28 Mar 2026 08:59:20 GMT</pubDate>
            <description><![CDATA[<blockquote>
<p>🤖 이 글은 <a href="https://claude.ai/code">Claude Code</a>을 활용하여 작성되었습니다.</p>
</blockquote>
<hr>
<p>타이핑되듯 흘러나오는 응답. 그게 구현 편의가 아니라 모델의 작동 방식에서 비롯된 것이라면?</p>
<p>Claude API를 처음 쓸 때 대부분 스트리밍 여부를 옵션 정도로 생각한다. 하지만 <code>stream=True</code> 한 줄의 근거는 HTTP 프로토콜 선택을 넘어, 모델이 텍스트를 생성하는 구조 자체에 있다. 통신 계층부터 Transformer 추론까지, 하나의 요청이 완성되는 흐름을 분해한다.</p>
<p><strong>TL;DR</strong></p>
<ul>
<li>Claude API는 REST + JSON. gRPC 없음</li>
<li>응답은 일반(JSON 한 번에)과 스트리밍(SSE) 두 가지</li>
<li>스트리밍이 자연스러운 이유: 모델이 토큰을 하나씩 생성하기 때문</li>
<li>Transformer의 Attention → Autoregressive 생성 → SSE 전송이 하나의 흐름</li>
</ul>
<hr>
<h2 id="1️⃣-통신-구조--rest--sse">1️⃣ 통신 구조 — REST + SSE</h2>
<p>Claude API는 <strong>HTTPS REST</strong> 기반이다. 모든 요청은 단일 엔드포인트로 수렴한다.</p>
<pre><code class="language-http">POST https://api.anthropic.com/v1/messages
Content-Type: application/json
x-api-key: sk-ant-...
anthropic-version: 2023-06-01</code></pre>
<pre><code class="language-json">{
  &quot;model&quot;: &quot;claude-sonnet-4-6&quot;,
  &quot;max_tokens&quot;: 1024,
  &quot;messages&quot;: [
    {&quot;role&quot;: &quot;user&quot;, &quot;content&quot;: &quot;JWT와 세션 인증의 차이를 설명해줘&quot;}
  ]
}</code></pre>
<p>응답 방식은 두 가지다.</p>
<h3 id="일반-응답-vs-스트리밍-응답">일반 응답 vs 스트리밍 응답</h3>
<table>
<thead>
<tr>
<th>방식</th>
<th>동작</th>
<th>적합한 상황</th>
</tr>
</thead>
<tbody><tr>
<td>일반 (JSON)</td>
<td>생성 완료 후 전체를 한 번에 반환</td>
<td>짧은 응답, 배치 처리</td>
</tr>
<tr>
<td>스트리밍 (SSE)</td>
<td>생성 즉시 청크 단위로 전송</td>
<td>긴 응답, 실시간 UI</td>
</tr>
</tbody></table>
<p><strong>일반 응답</strong>은 완성된 JSON을 한 방에 받는다.</p>
<pre><code class="language-json">{
  &quot;id&quot;: &quot;msg_01XFDUDYJgAACzvnptvVoYEL&quot;,
  &quot;content&quot;: [{&quot;type&quot;: &quot;text&quot;, &quot;text&quot;: &quot;JWT는 stateless...&quot;}],
  &quot;usage&quot;: { &quot;input_tokens&quot;: 20, &quot;output_tokens&quot;: 340 }
}</code></pre>
<p><strong>스트리밍 응답</strong>은 Server-Sent Events로 토큰이 생성되는 즉시 흘러나온다.</p>
<pre><code>data: {&quot;type&quot;: &quot;content_block_delta&quot;, &quot;delta&quot;: {&quot;text&quot;: &quot;JWT&quot;}}
data: {&quot;type&quot;: &quot;content_block_delta&quot;, &quot;delta&quot;: {&quot;text&quot;: &quot;는&quot;}}
data: {&quot;type&quot;: &quot;content_block_delta&quot;, &quot;delta&quot;: {&quot;text&quot;: &quot; stateless&quot;}}
data: {&quot;type&quot;: &quot;message_stop&quot;}</code></pre><pre><code class="language-python">with client.messages.stream(
    model=&quot;claude-sonnet-4-6&quot;,
    max_tokens=1024,
    messages=[{&quot;role&quot;: &quot;user&quot;, &quot;content&quot;: &quot;JWT와 세션 인증의 차이를 설명해줘&quot;}]
) as stream:
    for text in stream.text_stream:
        print(text, end=&quot;&quot;, flush=True)</code></pre>
<hr>
<h3 id="grpc를-선택하지-않은-이유">gRPC를 선택하지 않은 이유</h3>
<blockquote>
<p>LLM API의 통신 패턴은 단방향 요청-응답에 서버 푸시 스트림이 전부다. gRPC의 장점이 발휘될 자리가 없다.</p>
</blockquote>
<table>
<thead>
<tr>
<th>비교 항목</th>
<th>REST + SSE</th>
<th>gRPC</th>
</tr>
</thead>
<tbody><tr>
<td>브라우저 직접 호출</td>
<td>✅</td>
<td>❌ (프록시 필요)</td>
</tr>
<tr>
<td>curl 디버깅</td>
<td>✅</td>
<td>번거로움</td>
</tr>
<tr>
<td>클라이언트 코드 생성</td>
<td>불필요</td>
<td>필요</td>
</tr>
<tr>
<td>스트리밍</td>
<td>SSE로 충분</td>
<td>양방향 가능하나 불필요</td>
</tr>
</tbody></table>
<p>WebSocket 등 다른 선택지도 있지만, SSE는 HTTP 위에서 동작해 별도 인프라 없이 어디서나 호출 가능하다는 점에서 가장 단순한 선택이 된다.</p>
<hr>
<h2 id="2️⃣-모델-내부-구조--토큰이-만들어지는-과정">2️⃣ 모델 내부 구조 — 토큰이 만들어지는 과정</h2>
<h3 id="토큰-모델이-보는-언어의-단위">토큰: 모델이 보는 언어의 단위</h3>
<p>Claude는 텍스트를 문자가 아닌 <strong>토큰</strong> 단위로 처리한다. 자주 등장하는 문자 조합을 하나의 토큰으로 묶는 방식으로, 사용하는 토크나이저에 따라 분리 방식이 달라진다.</p>
<pre><code>&quot;Hello, world!&quot; → [&quot;Hello&quot;, &quot;,&quot;, &quot; world&quot;, &quot;!&quot;]  (약 4토큰)
&quot;안녕하세요&quot;    → [&quot;안녕&quot;, &quot;하세&quot;, &quot;요&quot;]           (약 3토큰, 토크나이저마다 다름)</code></pre><p>영어는 평균 4글자당 1토큰, 한국어는 구조상 토큰 밀도가 더 높은 편이다. 입력과 출력 모두 토큰 단위로 비용이 계산된다. <code>max_tokens</code>가 출력 토큰 수를 제한하는 파라미터인 이유가 여기 있다.</p>
<hr>
<h3 id="transformer-맥락을-읽는-구조">Transformer: 맥락을 읽는 구조</h3>
<p>Claude는 <strong>Transformer</strong> 기반으로 알려진 대규모 언어 모델이다 (Anthropic이 내부 아키텍처를 공식 공개하지는 않았다). 핵심은 <strong>Attention</strong> 메커니즘으로, 입력된 모든 토큰이 서로 얼마나 관련 있는지를 동시에 계산한다.</p>
<pre><code>입력: &quot;주문이 완료되면 [?]을 발송한다&quot;
                         ↑
         Attention이 &quot;주문&quot;, &quot;완료&quot;, &quot;발송&quot;과의 관계에
         높은 가중치를 부여하고, 이를 바탕으로
         모델이 &quot;메일&quot;을 높은 확률로 예측</code></pre><p>Attention은 토큰 간 관계 가중치를 계산하는 레이어이며, 실제 다음 토큰 예측은 이후 FFN(Feed-Forward Network)과 Softmax 레이어가 담당한다. 이 계산이 컨텍스트 창 전체에 걸쳐 일어나기 때문에, 수만 줄의 코드베이스도 앞뒤 맥락을 고려한 답변이 가능하다.</p>
<hr>
<h3 id="자동-회귀-생성-스트리밍이-자연스러운-이유">자동 회귀 생성: 스트리밍이 자연스러운 이유</h3>
<p>Claude가 텍스트를 생성하는 방식은 <strong>다음 토큰을 하나씩 예측하는 반복</strong>이다.</p>
<pre><code>입력:   &quot;JWT는&quot;
1단계:  &quot;JWT는&quot; → 예측 → &quot;stateless&quot;
2단계:  &quot;JWT는 stateless&quot; → 예측 → &quot;방식으로&quot;
3단계:  &quot;JWT는 stateless 방식으로&quot; → 예측 → &quot;...&quot;</code></pre><blockquote>
<p>전체 응답을 미리 완성해두고 보내는 게 아니다. 토큰을 생성하는 즉시 전송하기 때문에 SSE가 이 구조와 정확히 맞물린다.</p>
</blockquote>
<hr>
<h3 id="temperature와-top_p-확률-분포를-조절하는-파라미터">temperature와 top_p: 확률 분포를 조절하는 파라미터</h3>
<p>각 단계에서 다음 토큰을 고를 때 확률 분포를 참조한다. <code>temperature</code>와 <code>top_p</code>는 이 분포를 조절하는 파라미터다.</p>
<table>
<thead>
<tr>
<th>파라미터</th>
<th>낮은 값</th>
<th>높은 값</th>
</tr>
</thead>
<tbody><tr>
<td><code>temperature</code></td>
<td>가장 높은 확률 토큰만 선택 (결정적)</td>
<td>확률 분포 그대로 샘플링 (다양함)</td>
</tr>
<tr>
<td><code>top_p</code></td>
<td>상위 확률 토큰 중에서만 선택 (보수적)</td>
<td>전체 후보 중 선택 (개방적)</td>
</tr>
</tbody></table>
<p>실제 사용에서는 용도에 따라 명확히 나뉜다.</p>
<pre><code class="language-python">import anthropic

client = anthropic.Anthropic()

# 코드 생성 — 정확성 우선, 낮은 temperature
code_response = client.messages.create(
    model=&quot;claude-sonnet-4-6&quot;,
    max_tokens=1024,
    temperature=0.1,  # 거의 결정적. 같은 입력 → 같은 출력
    messages=[{&quot;role&quot;: &quot;user&quot;, &quot;content&quot;: &quot;Python으로 이진 탐색 구현해줘&quot;}]
)

# 블로그 초안 작성 — 다양성 우선, 높은 temperature
creative_response = client.messages.create(
    model=&quot;claude-sonnet-4-6&quot;,
    max_tokens=1024,
    temperature=0.9,  # 매번 다른 표현. 창의적 글쓰기에 적합
    messages=[{&quot;role&quot;: &quot;user&quot;, &quot;content&quot;: &quot;Redis를 소개하는 블로그 도입부를 써줘&quot;}]
)</code></pre>
<p>두 파라미터를 동시에 설정할 때 적용 순서가 있다. temperature가 먼저 로짓(logits)의 날카로움을 조정하고, 그 결과로 만들어진 확률 분포에서 top_p가 후보군을 좁힌 뒤 최종 샘플링이 이루어진다. 일반적으로는 하나만 조정하는 것이 결과를 예측하기 쉽다.</p>
<hr>
<h3 id="컨텍스트-창-attention이-참조하는-범위">컨텍스트 창: Attention이 참조하는 범위</h3>
<p>Attention은 컨텍스트 창 안의 모든 토큰을 동시에 참조한다. Claude Sonnet 4.6과 Opus 4.6 모두 최대 <strong>1M 토큰</strong>을 지원한다 (2026년 3월 GA).</p>
<pre><code>[시스템 프롬프트] [이전 대화 히스토리] [현재 질문]
|←──────────────── 컨텍스트 창 (1M 토큰) ────────────────→|</code></pre><p>창이 가득 차면 오래된 내용부터 참조 불가 상태가 된다. Compaction API는 이 한계를 서버 사이드 요약으로 우회한다.</p>
<hr>
<h2 id="3️⃣-전체-흐름--요청에서-응답까지">3️⃣ 전체 흐름 — 요청에서 응답까지</h2>
<pre><code>[클라이언트]
    │  HTTPS POST /v1/messages (JSON)
    ▼
[api.anthropic.com]
    │  1. 인증 확인 (x-api-key)
    │  2. 입력 토크나이징
    │  3. Transformer 추론 시작
    │     ├─ 토큰 생성 → SSE 전송 → [클라이언트] 표시
    │     ├─ 다음 토큰 생성 → SSE 전송 → [클라이언트] 표시
    │     └─ (max_tokens 도달 또는 EOS 토큰까지 반복)
    │  4. message_stop 이벤트 전송
    ▼
[연결 종료]</code></pre><hr>
<h2 id="마치며">마치며</h2>
<p>REST냐 gRPC냐의 선택은 프로토콜 취향이 아니다. 모델이 토큰을 하나씩 생성한다는 구조적 사실이 SSE를 가장 단순한 선택으로 만들고, SSE가 HTTP 위에서 동작한다는 점이 REST와 자연스럽게 맞물린다.</p>
<p><code>max_tokens</code>를 어떻게 설정할지, 스트리밍을 쓸지 말지, <code>temperature</code>를 얼마로 줄지. 이 결정들은 API 옵션이 아니라 <strong>모델이 동작하는 방식에 대한 이해</strong>에서 나온다. 인터페이스를 외우는 것과 구조를 이해하는 것의 차이가 여기서 드러난다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[증권거래소- Matching Engine 구조 정리]]></title>
            <link>https://velog.io/@be_zion/%EC%A6%9D%EA%B6%8C%EA%B1%B0%EB%9E%98%EC%86%8C-Matching-Engine-%EA%B5%AC%EC%A1%B0-%EC%A0%95%EB%A6%AC</link>
            <guid>https://velog.io/@be_zion/%EC%A6%9D%EA%B6%8C%EA%B1%B0%EB%9E%98%EC%86%8C-Matching-Engine-%EA%B5%AC%EC%A1%B0-%EC%A0%95%EB%A6%AC</guid>
            <pubDate>Sun, 15 Mar 2026 08:46:38 GMT</pubDate>
            <description><![CDATA[<p>가상 면접 사례로 배우는 대규모 시스템 설계 기초 2에서는 마지막 장에서 거래소의 핵심 구조인 <strong>Matching Engine</strong>을 중심으로 시스템 아키텍처를 설명한다.</p>
<p>책을 읽고 거래소 시스템 구조를 정리하고, 핵심 개념인 <strong>Order Book과 매칭 로직을 Rust로 간단히 정리</strong>해 보았다.</p>
<hr>
<h1 id="거래소-시스템-전체-구조">거래소 시스템 전체 구조</h1>
<p>일반적인 증권거래소 시스템은 다음과 같은 구조를 가진다.</p>
<pre><code>Client
   ↓
Order Gateway
   ↓
Matching Engine
   ↓
Market Data
   ↓
Persistence</code></pre><p>각 구성 요소의 역할은 다음과 같다.</p>
<h3 id="order-gateway">Order Gateway</h3>
<p>외부 클라이언트의 진입점이다.</p>
<ul>
<li>주문 검증</li>
<li>인증</li>
<li>Rate limit</li>
<li>내부 서비스로 라우팅</li>
</ul>
<h3 id="matching-engine">Matching Engine</h3>
<p>거래소의 <strong>핵심 컴포넌트</strong>다.</p>
<ul>
<li>주문을 오더북에 저장</li>
<li>매수/매도 주문 매칭</li>
<li>거래 체결</li>
</ul>
<h3 id="market-data">Market Data</h3>
<p>체결 결과와 호가 정보를 외부에 전달한다.</p>
<p>예를 들어</p>
<ul>
<li>실시간 호가</li>
<li>체결 가격</li>
<li>거래량</li>
</ul>
<p>같은 데이터가 여기서 생성된다.</p>
<h3 id="persistence">Persistence</h3>
<p>거래 기록을 저장하는 계층이다.</p>
<ul>
<li>거래 로그</li>
<li>주문 상태</li>
<li>감사 로그</li>
</ul>
<p>실제 거래소에서는 이벤트 로그 기반 저장을 많이 사용한다.</p>
<p>대표적인 거래소 예로는</p>
<ul>
<li>NASDAQ</li>
<li>한국거래소</li>
</ul>
<p>같은 시스템이 있다.</p>
<hr>
<h1 id="거래소의-핵심--order-book">거래소의 핵심 — Order Book</h1>
<p>거래소의 핵심 데이터 구조는 <strong>Order Book</strong>이다.</p>
<p>오더북은 <strong>체결되지 않고 대기 중인 주문을 관리하는 자료구조</strong>다.</p>
<p>주문은 두 가지로 나뉜다.</p>
<ul>
<li><strong>Bid (매수 주문)</strong> — 높은 가격 우선</li>
<li><strong>Ask (매도 주문)</strong> — 낮은 가격 우선</li>
</ul>
<pre><code>매수(Bid) — 높은 가격 우선        매도(Ask) — 낮은 가격 우선
─────────────────────              ─────────────────────
100,500원  x 10주                  101,000원  x 5주
100,000원  x 20주                  101,500원  x 15주
 99,500원  x 5주                   102,000원  x 8주</code></pre><p>체결은 다음 조건이 만족되면 발생한다.</p>
<pre><code>매수 최고가 ≥ 매도 최저가</code></pre><hr>
<h1 id="price-time-priority">Price-Time Priority</h1>
<p>대부분의 거래소는 <strong>Price-Time Priority</strong> 규칙을 사용한다.</p>
<p>우선순위는 다음과 같다.</p>
<p>1️⃣ <strong>가격 우선</strong>
2️⃣ <strong>시간 우선</strong></p>
<p>예를 들어 같은 가격에 두 주문이 있다면</p>
<pre><code>10:01 주문
10:03 주문</code></pre><p>먼저 들어온 주문이 먼저 체결된다.</p>
<hr>
<h1 id="오더북-자료구조-설계">오더북 자료구조 설계</h1>
<p>오더북에서 가장 중요한 연산은 두 가지다.</p>
<p>1️⃣ <strong>최우선 호가 조회</strong>
2️⃣ <strong>같은 가격 내 FIFO 유지</strong></p>
<p>이 두 조건을 만족하기 위해 다음 자료구조를 사용했다.</p>
<pre><code class="language-rust">pub struct OrderBook {
    bids: BTreeMap&lt;Reverse&lt;u64&gt;, VecDeque&lt;Order&gt;&gt;,
    asks: BTreeMap&lt;u64, VecDeque&lt;Order&gt;&gt;,
}</code></pre>
<p>구조 선택 이유는 다음과 같다.</p>
<h3 id="btreemap">BTreeMap</h3>
<ul>
<li>키가 항상 <strong>정렬된 상태 유지</strong></li>
<li>최우선 호가 접근이 쉬움</li>
</ul>
<pre><code class="language-rust">keys().next()</code></pre>
<hr>
<h3 id="reverseu64">Reverse<u64></h3>
<p>Rust의 기본 정렬은 <strong>오름차순</strong>이다.</p>
<p>매수 주문은 <strong>높은 가격 우선</strong>이므로 Reverse로 뒤집는다.</p>
<hr>
<h3 id="vecdeque">VecDeque</h3>
<p>같은 가격의 주문은 <strong>FIFO</strong>를 유지해야 한다.</p>
<pre><code>push_back()
pop_front()</code></pre><p>이 구조로 <strong>Price-Time Priority</strong> 규칙을 자연스럽게 구현할 수 있다.</p>
<hr>
<h1 id="주문-매칭-로직">주문 매칭 로직</h1>
<p>매수 주문이 들어왔을 때 매칭 흐름은 다음과 같다.</p>
<p>1️⃣ 매도 측 최저가 확인
2️⃣ 가격 조건 확인
3️⃣ 체결 수량 계산
4️⃣ 주문 잔량 업데이트
5️⃣ 완전 체결 주문 제거</p>
<p>핵심 로직은 다음과 같다.</p>
<pre><code class="language-rust">fn match_buy(&amp;mut self, mut taker: Order) -&gt; Vec&lt;Trade&gt; {</code></pre>
<p>체결 가격은 항상 <strong>maker 주문 기준</strong>이다.</p>
<p>즉 <strong>먼저 들어온 주문의 가격으로 거래가 체결된다.</strong></p>
<p>실제 거래소도 동일한 규칙을 사용한다.</p>
<hr>
<h1 id="서비스-통신--grpc-선택">서비스 통신 — gRPC 선택</h1>
<p>서비스 간 통신 방식으로 REST 대신 <strong>gRPC</strong>를 사용했다.</p>
<table>
<thead>
<tr>
<th></th>
<th>REST</th>
<th>gRPC</th>
</tr>
</thead>
<tbody><tr>
<td>인터페이스</td>
<td>OpenAPI</td>
<td>proto</td>
</tr>
<tr>
<td>직렬화</td>
<td>JSON</td>
<td>Protobuf</td>
</tr>
<tr>
<td>성능</td>
<td>텍스트</td>
<td>바이너리</td>
</tr>
<tr>
<td>스트리밍</td>
<td>별도 구현</td>
<td>기본 지원</td>
</tr>
</tbody></table>
<p>매칭엔진처럼 <strong>대량의 주문을 빠르게 처리해야 하는 서비스</strong>에서는 JSON 직렬화 비용이 부담이 될 수 있다.</p>
<p>Protobuf는</p>
<ul>
<li>더 작은 메시지 크기</li>
<li>빠른 파싱</li>
</ul>
<p>이라는 장점이 있다.</p>
<hr>
<h1 id="proto--서비스-계약">proto — 서비스 계약</h1>
<p>gRPC에서는 proto 파일이 <strong>서비스 계약(contract)</strong> 역할을 한다.</p>
<pre><code class="language-proto">service MatchingEngine {
  rpc SubmitOrder(OrderRequest) returns (OrderResponse);
  rpc CancelOrder(CancelRequest) returns (CancelResponse);
  rpc GetOrderBook(Symbol) returns (OrderBookSnapshot);
}</code></pre>
<p>이 파일 하나로</p>
<ul>
<li>서버 코드</li>
<li>클라이언트 코드</li>
</ul>
<p>가 자동 생성된다.</p>
<p>Rust에서는 <strong>tonic</strong>을 사용했다.</p>
<pre><code class="language-rust">fn main() -&gt; Result&lt;(), Box&lt;dyn std::error::Error&gt;&gt; {
    tonic_build::compile_protos(&quot;../proto/matching.proto&quot;)?;
    Ok(())
}</code></pre>
<hr>
<h1 id="실제-거래소와의-차이">실제 거래소와의 차이</h1>
<p>이번 구현은 <strong>구조 이해를 위한 간단한 실험</strong>이다.</p>
<p>실제 거래소 시스템은 훨씬 복잡하다.</p>
<p>예를 들어</p>
<ul>
<li>lock-free 자료구조</li>
<li>멀티 스레드 매칭</li>
<li>shard된 order book</li>
<li>event sourcing 기반 로그</li>
<li>초저지연 네트워크</li>
</ul>
<p>같은 기술들이 사용된다.</p>
<hr>
<h1 id="마치며">마치며</h1>
<p>증권거래소 시스템에서 가장 중요한 컴포넌트는 <strong>Matching Engine</strong>이다.</p>
<p>핵심은 복잡한 알고리즘보다 <strong>자료구조 설계</strong>에 있다.</p>
<p>이번 글에서는</p>
<pre><code>BTreeMap + VecDeque</code></pre><p>조합을 사용해 Price-Time Priority 기반 오더북 구조와 매칭 로직을 Rust로 정리해 보았다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[MCP 구조 분석 - Orchestration Layer]]></title>
            <link>https://velog.io/@be_zion/MCP-%EA%B5%AC%EC%A1%B0-%EB%B6%84%EC%84%9D-Orchestration-Layer</link>
            <guid>https://velog.io/@be_zion/MCP-%EA%B5%AC%EC%A1%B0-%EB%B6%84%EC%84%9D-Orchestration-Layer</guid>
            <pubDate>Sat, 28 Feb 2026 09:52:35 GMT</pubDate>
            <description><![CDATA[<p>MCP 기반 시스템에서 <strong>실행 흐름(Control Flow)</strong> 과 <strong>대화 상태(Conversation State)</strong> 가 어디에서 관리되는지를 client 서비스를 구현하고, 구조적으로 정리한다.</p>
<blockquote>
<p>MCP 기반 구조에서 실행 흐름과 대화 상태는 어느 계층의 책임인가?</p>
</blockquote>
<hr>
<h1 id="1-전체-시스템-구조">1. 전체 시스템 구조</h1>
<p>MCP 기반 시스템은 단순 Client–Server 모델이라기보다 다음과 같은 계층 구조로 이해하는 편이 자연스럽다.</p>
<pre><code class="language-text">LLM (Inference)
        ↑↓
Orchestration Layer (Claude Desktop)
        ↑↓  JSON-RPC (MCP)
MCP Server (Tool Execution)
        ↑↓
External API</code></pre>
<p>각 계층의 책임은 다음과 같이 구분된다.</p>
<ul>
<li><p><strong>LLM</strong></p>
<ul>
<li>컨텍스트 기반 응답 생성</li>
<li>Tool 호출 여부 결정</li>
</ul>
</li>
<li><p><strong>Claude Desktop (Client)</strong></p>
<ul>
<li>대화 이력(messages) 관리</li>
<li>반복 호출 제어</li>
<li>MCP 통신 처리</li>
<li>세션 수명 관리</li>
</ul>
</li>
<li><p><strong>MCP Server</strong></p>
<ul>
<li>Tool 실행 및 결과 반환</li>
</ul>
</li>
<li><p><strong>External API</strong></p>
<ul>
<li>실제 데이터 조회 및 외부 연산 수행</li>
</ul>
</li>
</ul>
<p>따라서 Claude Desktop은 단순 UI가 아니라
<strong>실행 흐름을 조정하는 Orchestration Layer</strong>로 보는 것이 타당하다.</p>
<hr>
<h1 id="2-연결-과정-handshake">2. 연결 과정: Handshake</h1>
<pre><code class="language-ts">await mcpClient.connect(transport);</code></pre>
<p>이 호출은 내부적으로 JSON-RPC 기반 초기화 절차를 수행한다.</p>
<pre><code class="language-text">Client                MCP Server
  │ initialize (req)       │
  ├───────────────────────▶│
  │ initialize (res)       │
  │◀───────────────────────┤
  │ initialized (notify)   │
  ├───────────────────────▶│</code></pre>
<p>이 단계에서:</p>
<ul>
<li>프로토콜 버전 협상</li>
<li>Capability 교환</li>
<li>초기 상태 전이</li>
</ul>
<p>가 이루어진다.</p>
<p>즉, MCP 연결은 단순 소켓 연결이 아니라
<strong>명시적 초기화 단계를 포함하는 상태 기반 프로토콜 과정</strong>이다.</p>
<p><img src="https://velog.velcdn.com/images/be_zion/post/88492cd8-5ee7-4586-9ecc-e5c2ea883c3d/image.png" alt=""></p>
<hr>
<h1 id="3-mcp의-역할-범위">3. MCP의 역할 범위</h1>
<p>MCP는 JSON-RPC 기반 요청/응답 프로토콜이다.</p>
<ul>
<li>Request</li>
<li>Response</li>
<li>Notification</li>
</ul>
<p>중요한 점은 다음이다.</p>
<blockquote>
<p>MCP는 대화 맥락을 정의하지 않는다.</p>
</blockquote>
<p>초기화 상태는 존재하지만,
사용자 대화 이력이나 컨텍스트 전략은 프로토콜의 책임 범위를 벗어난다.</p>
<p>따라서 MCP는 다음에 가깝다.</p>
<ul>
<li>Tool 실행을 위한 RPC 계층</li>
<li>구조화된 요청/응답 전달 메커니즘</li>
</ul>
<p><img src="https://velog.velcdn.com/images/be_zion/post/a1bf485f-d353-49eb-bb44-657ca4fe2194/image.png" alt=""></p>
<hr>
<h1 id="4-세션의-이중-구조와-stateless-호출-모델">4. 세션의 이중 구조와 Stateless 호출 모델</h1>
<p>구현 과정에서 확인한 핵심은
“세션(Session)”이 단일 개념이 아니라는 점이다.</p>
<h2 id="①-transport-session">① Transport Session</h2>
<ul>
<li>MCP 서버 프로세스의 수명</li>
<li>JSON-RPC 연결 단위</li>
<li><code>initialize</code> 이후 유지되는 상태</li>
</ul>
<h2 id="②-conversation-session">② Conversation Session</h2>
<ul>
<li><code>messages</code> 배열의 수명</li>
<li>LLM 호출 시 전달되는 컨텍스트 범위</li>
<li><code>/reset</code>으로 초기화되는 영역</li>
</ul>
<p><img src="https://velog.velcdn.com/images/be_zion/post/353f8a7f-c580-42fd-b17d-80a17ab72be9/image.png" alt=""></p>
<p>여기서 중요한 기술적 배경이 있다.</p>
<p>Claude API 호출은 요청 단위로 상태를 유지하지 않는 구조에 가깝다.
매 호출 시 전체 대화 이력이 함께 전달된다.</p>
<pre><code class="language-ts">messages = [
  user,
  assistant(tool_use),
  tool(result),
  assistant,
  ...
]</code></pre>
<p>LLM은 내부적으로 대화 상태를 저장하지 않으며,
현재 턴에 필요한 모든 맥락은 <code>messages</code> 배열로 제공된다.</p>
<p>따라서 다음은 클라이언트의 책임이 된다.</p>
<ul>
<li>대화 이력 유지</li>
<li>메시지 절단(truncation)</li>
<li>요약 전략</li>
<li>컨텍스트 윈도우 관리</li>
</ul>
<p>정리하면:</p>
<blockquote>
<p>Transport는 연결의 수명이고,
Conversation은 맥락의 수명이다.</p>
</blockquote>
<p>이 둘을 구분하면 구조가 명확해진다.</p>
<hr>
<h1 id="5-agentic-loop의-제어-흐름">5. Agentic Loop의 제어 흐름</h1>
<p>Claude의 Tool 사용 흐름은 단일 요청-응답 모델이 아니라,
클라이언트가 구성하는 반복 제어 구조 위에서 동작한다.</p>
<p>이를 추상화하면 다음과 같다.</p>
<pre><code class="language-text">User Input
   ↓
LLM 호출
   ↓
stop_reason 확인
   ├─ tool_use → Tool 실행 → 결과를 messages에 추가
   └─ end_turn → 종료
   ↓
반복</code></pre>
<p>각 계층의 책임은 다음과 같이 분리된다.</p>
<ul>
<li><p><strong>LLM</strong></p>
<ul>
<li>Tool 사용 여부 결정</li>
<li>다음 단계 의도 생성</li>
</ul>
</li>
<li><p><strong>Client</strong></p>
<ul>
<li>stop_reason 해석</li>
<li>MCP 서버 호출</li>
<li>messages 업데이트</li>
<li>반복 여부 제어</li>
</ul>
</li>
<li><p><strong>MCP Server</strong></p>
<ul>
<li>순수 Tool 실행</li>
</ul>
</li>
</ul>
<p><img src="https://velog.velcdn.com/images/be_zion/post/ec0535ec-f198-49fe-8167-6e262551760b/image.png" alt=""></p>
<p>중요한 점은,
반복 로직은 LLM 내부가 아니라 <strong>Orchestration Layer에서 구성된다는 것</strong>이다.</p>
<hr>
<h1 id="정리">정리</h1>
<p>1편에서는 Execution Layer(MCP Server)를 분석했다.
이번 글에서는 Orchestration Layer의 구조적 책임을 살펴봤다.</p>
<p>핵심은 다음 세 가지다.</p>
<ol>
<li>MCP는 Tool 실행을 위한 RPC 계층이다.</li>
<li>실행 흐름은 Client가 구성하는 Agentic Loop 위에서 동작한다.</li>
<li>Conversation 상태는 LLM이 아니라 클라이언트 계층에서 관리된다.</li>
</ol>
]]></description>
        </item>
        <item>
            <title><![CDATA[MCP(Model Context Protocol) 구조 분석 – 로컬 MCP 서버 구현]]></title>
            <link>https://velog.io/@be_zion/MCPModel-Context-Protocol-%EA%B5%AC%EC%A1%B0-%EB%B6%84%EC%84%9D-%EB%A1%9C%EC%BB%AC-MCP-%EC%84%9C%EB%B2%84-%EA%B5%AC%ED%98%84</link>
            <guid>https://velog.io/@be_zion/MCPModel-Context-Protocol-%EA%B5%AC%EC%A1%B0-%EB%B6%84%EC%84%9D-%EB%A1%9C%EC%BB%AC-MCP-%EC%84%9C%EB%B2%84-%EA%B5%AC%ED%98%84</guid>
            <pubDate>Fri, 27 Feb 2026 18:06:44 GMT</pubDate>
            <description><![CDATA[<p><img src="https://velog.velcdn.com/images/be_zion/post/2963f491-c9d4-4f93-bf84-cc4f13f30746/image.png" alt=""></p>
<h2 id="1-llm은-왜-외부-시스템을-모를까">1. LLM은 왜 외부 시스템을 모를까?</h2>
<p>LLM은 학습 시점까지의 정적 데이터만을 기반으로 동작한다.
따라서 다음과 같은 정보에는 접근할 수 없다.</p>
<ul>
<li>현재 DB 상태</li>
<li>실시간 배포 현황</li>
<li>GitHub 이슈 목록</li>
<li>내부 비즈니스 데이터</li>
</ul>
<p>기존에는 이러한 문제를 해결하기 위해 <strong>서비스별 맞춤 연동 코드</strong>를 작성해야 했다.
그러나 이 방식은 다음과 같은 한계를 가진다.</p>
<ul>
<li>연동 방식이 서비스마다 상이함</li>
<li>재사용 불가능</li>
<li>클라이언트 종속적인 구현</li>
<li>표준 부재로 인한 확장성 부족</li>
</ul>
<p>이 문제를 해결하기 위해 등장한 것이 <strong>MCP(Model Context Protocol)</strong>이다.</p>
<hr>
<h2 id="2-mcp의-설계-목적">2. MCP의 설계 목적</h2>
<p>MCP는 AI와 외부 시스템 간의 상호작용을 표준화하기 위한 프로토콜이다.</p>
<p>핵심 철학은 다음과 같다.</p>
<blockquote>
<p><strong>AI의 판단 영역과 실행 영역을 명확히 분리한다.</strong></p>
</blockquote>
<ul>
<li>AI(Claude)는 어떤 도구를 사용할지 판단한다.</li>
<li>MCP 서버는 실제 실행만 담당한다.</li>
<li>프로토콜은 그 사이의 통신 규격을 정의한다.</li>
</ul>
<p>이 설계는 확장성과 재사용성을 동시에 확보한다.</p>
<hr>
<h2 id="3-프로토콜-구조-json-rpc-기반-설계">3. 프로토콜 구조: JSON-RPC 기반 설계</h2>
<p>MCP는 JSON-RPC 2.0 위에서 동작한다.</p>
<pre><code class="language-json">{ &quot;jsonrpc&quot;: &quot;2.0&quot;, &quot;id&quot;: 1, &quot;method&quot;: &quot;tools/call&quot;, &quot;params&quot;: { ... } }</code></pre>
<p>모든 통신은 method 호출 형태로 이루어진다.</p>
<h3 id="핵심-구성-요소">핵심 구성 요소</h3>
<p><img src="https://velog.velcdn.com/images/be_zion/post/f00fbce2-1828-43c3-865e-df494a28f5f6/image.png" alt=""></p>
<table>
<thead>
<tr>
<th>구성 요소</th>
<th>역할</th>
</tr>
</thead>
<tbody><tr>
<td>Resources</td>
<td>AI가 읽는 데이터 소스</td>
</tr>
<tr>
<td>Prompts</td>
<td>재사용 가능한 프롬프트 템플릿</td>
</tr>
<tr>
<td>Tools</td>
<td>AI가 실행하는 함수</td>
</tr>
</tbody></table>
<p>여기서 중요한 점은:</p>
<blockquote>
<p>Tool 선택 로직은 서버에 존재하지 않는다.</p>
</blockquote>
<p>Claude는 <code>tools/list</code>를 통해 메뉴를 받고,
각 Tool의 <code>description</code>과 <code>inputSchema</code>를 기반으로 호출을 결정한다.</p>
<p>즉, MCP 서버 설계에서 가장 중요한 요소는
<strong>명확한 description과 정확한 input schema 정의</strong>다.</p>
<hr>
<h2 id="4-wikipedia-mcp-서버-구현">4. Wikipedia MCP 서버 구현</h2>
<p>실제 프로토콜 흐름을 확인하기 위해 Wikipedia API 기반 MCP 서버를 구현했다.</p>
<h3 id="tool-등록">Tool 등록</h3>
<pre><code class="language-typescript">server.registerTool(
  &quot;search&quot;,
  {
    description: &quot;Wikipedia에서 키워드로 문서를 검색합니다&quot;,
    inputSchema: {
      keyword: z.string(),
      lang: z.enum([&quot;ko&quot;, &quot;en&quot;]).default(&quot;ko&quot;),
    },
  },
  async ({ keyword, lang }) =&gt; {
    const url = `https://${lang}.wikipedia.org/w/api.php?action=query&amp;list=search&amp;srsearch=${encodeURIComponent(keyword)}&amp;format=json&amp;origin=*&amp;srlimit=5`;
    const res = await fetch(url);
    const data = await res.json();
    const results = data.query.search.map((item, i) =&gt; `${i + 1}. ${item.title}`);
    return { content: [{ type: &quot;text&quot;, text: results.join(&quot;\n&quot;) }] };
  }
);</code></pre>
<p><img src="https://velog.velcdn.com/images/be_zion/post/d98a16e6-defa-4d97-9511-5ff078f96d9d/image.png" alt=""></p>
<p>Zod로 정의한 스키마는 JSON Schema로 변환되어 Claude에 전달된다.
Claude는 이를 기반으로 arguments를 자동 구성한다.</p>
<p>서버는 “어떤 상황에서 search를 써야 하는지”를 판단하지 않는다.
그 판단은 전적으로 Claude의 역할이다.</p>
<hr>
<h2 id="5-실제-프로토콜-흐름-분석">5. 실제 프로토콜 흐름 분석</h2>
<p><img src="https://velog.velcdn.com/images/be_zion/post/36674885-651c-447e-b628-2cb8b87589eb/image.png" alt=""></p>
<p>Inspector를 통해 확인한 호출 순서는 다음과 같다.</p>
<ol>
<li><code>initialize</code></li>
<li><code>tools/list</code></li>
<li><code>tools/call</code></li>
</ol>
<h3 id="toolslist--메뉴-제공">tools/list – 메뉴 제공</h3>
<pre><code class="language-json">{ &quot;method&quot;: &quot;tools/list&quot; }</code></pre>
<p>서버는 사용 가능한 Tool 목록을 반환한다.</p>
<h3 id="toolscall--실행-요청">tools/call – 실행 요청</h3>
<pre><code class="language-json">{
  &quot;method&quot;: &quot;tools/call&quot;,
  &quot;params&quot;: {
    &quot;name&quot;: &quot;search&quot;,
    &quot;arguments&quot;: { &quot;keyword&quot;: &quot;인공지능&quot; }
  }
}</code></pre>
<p>Claude는 <code>inputSchema</code>를 참고해 arguments를 자동 생성한다.</p>
<p>이 구조는 AI의 추론 능력을 활용하는 설계다.
서버는 로직을 최소화하고 실행 책임만 가진다.</p>
<hr>
<h2 id="6-sampling-양방향-상호작용">6. Sampling: 양방향 상호작용</h2>
<p>MCP의 흥미로운 기능 중 하나는 Sampling이다.</p>
<p>일반 흐름:</p>
<pre><code>Claude → MCP 서버</code></pre><p>Sampling 흐름:</p>
<pre><code>MCP 서버 → Claude</code></pre><p>서버가 데이터를 수집한 뒤,
Claude에게 다시 요약·분석을 요청할 수 있다.</p>
<p><img src="https://velog.velcdn.com/images/be_zion/post/5e8f4518-a5ae-4449-98ef-d71245960fb1/image.png" alt=""></p>
<p>이는 데이터 수집 계층과 추론 계층을 분리하는 구조를 가능하게 한다.</p>
<hr>
<h2 id="7-transport-계층-stdio-vs-sse">7. Transport 계층: Stdio vs SSE</h2>
<p>MCP 메시지 자체는 동일하지만, 전달 방식이 다르다.</p>
<table>
<thead>
<tr>
<th>구분</th>
<th>Stdio</th>
<th>SSE</th>
</tr>
</thead>
<tbody><tr>
<td>통신 방식</td>
<td>표준 입출력</td>
<td>HTTP + Server-Sent Events</td>
</tr>
<tr>
<td>Claude Desktop 지원</td>
<td>지원</td>
<td>미지원</td>
</tr>
<tr>
<td>서버 실행 방식</td>
<td>Claude가 직접 실행</td>
<td>독립 서버</td>
</tr>
</tbody></table>
<p>중요한 점은:</p>
<blockquote>
<p>Transport는 메시지 전달 방식일 뿐, 프로토콜 로직과는 무관하다.</p>
</blockquote>
<p>이 설계는 프로토콜 계층과 네트워크 계층을 명확히 분리한다.</p>
<hr>
<h2 id="8-설계적-인사이트">8. 설계적 인사이트</h2>
<p>Wikipedia 서버를 구현하며 얻은 핵심 인사이트는 다음과 같다.</p>
<ol>
<li>MCP는 단순 API 래퍼가 아니다.</li>
<li>AI 중심 설계를 전제로 한 프로토콜이다.</li>
<li>서버 로직을 최소화할수록 유연성이 높아진다.</li>
<li>description 설계가 곧 UX 설계다.</li>
</ol>
<p>즉, MCP 서버는 단순한 “백엔드 서비스”가 아니라
<strong>AI의 추론을 보조하는 실행 레이어</strong>에 가깝다.</p>
<hr>
<h2 id="결론">결론</h2>
<p>MCP는 AI와 외부 시스템 간의 상호작용을 표준화하는 프로토콜이다.
JSON-RPC 기반 구조, Tool 중심 설계, Transport 분리 구조는 확장성과 재사용성을 고려한 설계다.</p>
<p>Wikipedia 서버 구현을 통해 확인한 것은 단순 사용법이 아니라,
<strong>AI 중심 아키텍처가 어떻게 프로토콜 수준에서 설계되는지에 대한 이해</strong>였다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Contribution] Spring Data JPA: Jakarta Persistence 4.0 규격 반영 및 파서 최신화]]></title>
            <link>https://velog.io/@be_zion/Contribution-Spring-Data-JPA-Jakarta-Persistence-4.0-%EA%B7%9C%EA%B2%A9-%EB%B0%98%EC%98%81-%EB%B0%8F-%ED%8C%8C%EC%84%9C-%EC%B5%9C%EC%8B%A0%ED%99%94</link>
            <guid>https://velog.io/@be_zion/Contribution-Spring-Data-JPA-Jakarta-Persistence-4.0-%EA%B7%9C%EA%B2%A9-%EB%B0%98%EC%98%81-%EB%B0%8F-%ED%8C%8C%EC%84%9C-%EC%B5%9C%EC%8B%A0%ED%99%94</guid>
            <pubDate>Thu, 15 Jan 2026 14:00:04 GMT</pubDate>
            <description><![CDATA[<p>최근 Java 생태계는 <strong>Jakarta EE 11</strong> 및 <strong>Jakarta Persistence(JPA) 4.0</strong>으로의 전환기에 있습니다. 저는 첫 오픈소스 기여로 Spring Data JPA 프로젝트에 참여하여 차세대 표준 사양을 선제적으로 반영하는 유의미한 경험을 했습니다.</p>
<hr>
<h2 id="1-생태계의-변화와-이슈-분석">1. 생태계의 변화와 이슈 분석</h2>
<p>JPA 4.0 규격의 핵심은 관행적 코드를 표준 API로 대체하여 생산성과 성능을 개선하는 것입니다. Spring Data JPA는 이에 대응하기 위해 관련 이슈들을 생성중이었습니다.</p>
<h3 id="🔍-분석한-주요-이슈-리스트">🔍 분석한 주요 이슈 리스트</h3>
<ul>
<li><strong>#4141:</strong> 신규 정적 쿼리 어노테이션(<code>@StaticQuery</code>) 인프라 확장</li>
<li><strong>#4143:</strong> 신규 표준 API(<code>getResultCount()</code>)를 활용한 페이징 최적화</li>
<li><strong>#4144:</strong> DTO 프로젝션 리라이팅 로직 개선 및 유연한 매핑 지원</li>
<li><strong>#4150:</strong> JPA 4.0 문법 변경에 따른 쿼리 파서 업데이트 <strong>(My Contribution)</strong></li>
</ul>
<p>첫 오픈소스 기여다보니, 먼저 가장 부담이 적은 #4150 부터 진행해보기로 했습니다.</p>
<hr>
<h2 id="2-4150-해결-과정-case-문법-최신화">2. #4150 해결 과정: CASE 문법 최신화</h2>
<h3 id="21-문제-정의-background">2.1 문제 정의 (Background)</h3>
<p>이전 JPQL 사양에서 <code>CASE</code> 식은 반드시 <code>ELSE</code> 절을 포함해야 했으나, <strong>JPA 4.0</strong>부터는 <code>ELSE</code> 절이 <strong>선택 사항(Optional)</strong>으로 변경되었습니다. 생략 시 암묵적으로 <code>NULL</code>을 반환하도록 규격이 완화된 것입니다.</p>
<h3 id="22-antlr4-문법-수정">2.2 ANTLR4 문법 수정</h3>
<p>Spring Data JPA는 쿼리 해석을 위해 <strong>ANTLR4</strong> 파서 생성기를 활용합니다. 사용자가 작성한 쿼리가 신규 사양을 준수하는지 판단하도록 <code>Jpql.g4</code> 및 <code>Eql.g4</code> 정의 파일을 수정했습니다.</p>
<p><strong>[Grammar Refactoring]</strong></p>
<pre><code class="language-antlr">// 수정 후: (ELSE scalar_expression)? 구문을 통해 ELSE 절을 Optional하게 변경
general_case_expression
    : CASE when_clause (when_clause)* (ELSE scalar_expression)? END ;

simple_case_expression
    : CASE case_operand simple_when_clause (simple_when_clause)* (ELSE scalar_expression)? END ;
</code></pre>
<p><code>ELSE</code> 구문을 <strong><code>()?</code></strong> 기호로 그룹화하여 처리함으로써, 파싱 단계에서의 <code>Syntax Error</code>를 제거하고 표준 규격을 수용했습니다.</p>
<hr>
<h2 id="3-merged-polished-and-backported">3. Merged, Polished, and Backported</h2>
<p>해당 Pull Request는 리뷰를 거쳐 메인 저장소에 병합(Merge)되었습니다.</p>
<p><img src="https://velog.velcdn.com/images/be_zion/post/096792ae-54f9-40ce-8f13-64090cb79715/image.png" alt=""></p>
<p>그리고 메인테이너에 의해 <strong>백포트(Backport)</strong> 되었습니다.</p>
<p><img src="https://velog.velcdn.com/images/be_zion/post/224cfc58-6697-4b63-b6b9-c32500dfeb65/image.png" alt=""></p>
<blockquote>
<p>*&quot;Jakarta Persistence 4.0은 아직 정식 릴리즈 전인데, 이전 버전(Backport)에 들어가도 정말 괜찮은 걸까?&quot;*</p>
</blockquote>
<hr>
<h2 id="4-표준-구현체-그리고-추상화의-관계">4. 표준, 구현체, 그리고 추상화의 관계</h2>
<p>자바 데이터 접근 계층은 표준, 구현체, 추상화 라이브러리의 상호작용을 통해 발전합니다.</p>
<table>
<thead>
<tr>
<th>구분</th>
<th>프로젝트</th>
<th>역할</th>
</tr>
</thead>
<tbody><tr>
<td><strong>표준 (Spec)</strong></td>
<td>Jakarta Persistence 4.0</td>
<td>공통 명세 및 문법 규칙 정의</td>
</tr>
<tr>
<td><strong>구현 (Impl)</strong></td>
<td>Hibernate 6.x / 7.0</td>
<td>명세에 따른 실제 쿼리 실행 엔진</td>
</tr>
<tr>
<td><strong>추상화 (Lib)</strong></td>
<td>Spring Data JPA</td>
<td>고수준 인터페이스 및 가이드 제공</td>
</tr>
</tbody></table>
<blockquote>
<p><strong>구현체 선행 (Running Code First):</strong> 표준 문서 확정 전이라도 <strong>Hibernate</strong>이 기능을 지원한다면, 프레임워크는 이를 수용해 생태계 정합성을 맞춥니다.</p>
</blockquote>
<h3 id="41-구현체hibernate의-선행-지원-확인">4.1 구현체(Hibernate)의 선행 지원 확인</h3>
<p>분석 결과, 구현체인 <strong>Hibernate</strong>는 이미 2021년 <strong>6.0(Alpha)</strong> 설계 당시부터 SQL 표준 준수를 위해 <code>CASE</code> 문의 <code>ELSE</code>를 선택 사항으로 정의해 두었습니다.</p>
<p><strong>[Hibernate HqlParser.g4 소스코드]</strong></p>
<pre><code class="language-antlr">// Hibernate 6.0+ 에서는 이미 ELSE 절에 &#39;?&#39;(Optional)가 적용되어 있음
simpleCaseList
    : CASE expressionOrPredicate simpleCaseWhen+ caseOtherwise? END ;
</code></pre>
<h3 id="42-기여의-가치-기술적-단절mismatch-해소">4.2 기여의 가치: 기술적 단절(Mismatch) 해소</h3>
<p>이번 기여의 핵심은 라이브러리 간의 규격 동기화에 있습니다.</p>
<ul>
<li><strong>현상:</strong> 엔진(Hibernate)은 이미 <code>ELSE</code>가 없는 쿼리를 실행할 수 있었으나, 상위 계층인 Spring Data JPA 파서의 구식 규격이 이를 가로막고 있었습니다.</li>
<li><strong>해결:</strong> Spring Data JPA의 파서를 최신 표준에 맞춰 업데이트함으로써, 하부 엔진의 기능을 상위 프레임워크 수준에서 정상적으로 사용할 수 있도록 수정했습니다.</li>
</ul>
<hr>
<h2 id="5-정리를-마치며-공급자의-시각을-얻다">5. 정리를 마치며: 공급자의 시각을 얻다</h2>
<p>이번 기여를 통해 얻은 핵심 수확은 라이브러리를 <strong>&#39;공급자적 시각&#39;</strong>에서 바라보게 된 점입니다.</p>
<ul>
<li><strong>선순환 구조의 이해:</strong> 우리가 당연시했던 기능들이 표준과 구현체의 긴밀한 상호작용 결과물임을 확인했습니다. 특히 백포트를 통해 생태계의 속도를 맞추는 오픈소스의 운영 방식을 체감했습니다.</li>
<li><strong>기술적 공백의 보완:</strong> 익숙한 도구의 내부 구조를 파헤치며 이론적 공백을 채웠습니다. 낮은 난이도의 이슈로 시작한 점은 아쉽지만, 도구의 본질을 분석하고 기여로 연결한 경험 자체가 큰 성장이었습니다.</li>
</ul>
<p>이번 경험을 발판 삼아, 앞으로는 프레임워크의 설계 철학까지 깊이 파고드는 기여자로 꾸준히 성장하겠습니다.</p>
<blockquote>
<p>🔗 <strong>관련 Pull Request</strong>
<a href="https://github.com/spring-projects/spring-data-jpa/pull/4150">Support optional ELSE in CASE expressions (JPA 4.0) #4150</a></p>
</blockquote>
]]></description>
        </item>
        <item>
            <title><![CDATA[랭킹은 로그가 아니다: Redis로 안정적인 상품 랭킹 만들기]]></title>
            <link>https://velog.io/@be_zion/%EB%9E%AD%ED%82%B9%EC%9D%80-%EB%A1%9C%EA%B7%B8%EA%B0%80-%EC%95%84%EB%8B%88%EB%8B%A4-Redis%EB%A1%9C-%EC%95%88%EC%A0%95%EC%A0%81%EC%9D%B8-%EC%83%81%ED%92%88-%EB%9E%AD%ED%82%B9-%EB%A7%8C%EB%93%A4%EA%B8%B0</link>
            <guid>https://velog.io/@be_zion/%EB%9E%AD%ED%82%B9%EC%9D%80-%EB%A1%9C%EA%B7%B8%EA%B0%80-%EC%95%84%EB%8B%88%EB%8B%A4-Redis%EB%A1%9C-%EC%95%88%EC%A0%95%EC%A0%81%EC%9D%B8-%EC%83%81%ED%92%88-%EB%9E%AD%ED%82%B9-%EB%A7%8C%EB%93%A4%EA%B8%B0</guid>
            <pubDate>Fri, 26 Dec 2025 08:13:36 GMT</pubDate>
            <description><![CDATA[<h2 id="tldr">TL;DR</h2>
<ul>
<li>Redis ZSET을 활용해 <strong>상품 일간 랭킹 시스템</strong>을 구현했다</li>
<li>조회수·좋아요·판매량을 <strong>10분 단위 time-bucket</strong>으로 집계해 노이즈를 줄이고 실시간성을 확보했다</li>
<li>캘린더 기준(00:00 시작) 일간 랭킹의 <strong>콜드 스타트 문제</strong>를 Carry Over 전략으로 완화했다</li>
<li>랭킹은 정확한 로그가 아니라, <strong>상품 노출을 제어하는 시스템</strong>이라는 관점으로 설계했다</li>
</ul>
<blockquote>
<p>Redis의 <strong>ZSET 자료 구조</strong>를 활용해 실시간 상품 <strong>일간 랭킹 시스템</strong>을 구현하며 고민했던 설계 포인트를 정리합니다.
랭킹은 단순한 정렬 문제가 아니라, <strong>시간 기준·집계 단위·안정성</strong>에 대한 선택의 연속이었습니다.</p>
</blockquote>
<hr>
<h2 id="1️⃣-일간-랭킹의-시간-기준">1️⃣ 일간 랭킹의 시간 기준</h2>
<h3 id="캘린더-기준-0000--2359-kst">캘린더 기준 (00:00 ~ 23:59, KST)</h3>
<h3 id="vs-sliding-window-기준-최근-24시간">vs Sliding Window 기준 (최근 24시간)</h3>
<p>일간 랭킹의 기준은 크게 두 가지 방식으로 나눌 수 있습니다.</p>
<ul>
<li><p><strong>캘린더 기준</strong></p>
<ul>
<li>날짜 단위로 데이터 관리가 명확</li>
<li>일자별 랭킹 비교 및 이력 관리에 유리</li>
<li>단점: 00시 시점에 랭킹 데이터가 거의 없는 <strong>콜드 스타트 문제</strong> 발생</li>
</ul>
</li>
<li><p><strong>Sliding Window 기준 (최근 24시간)</strong></p>
<ul>
<li>항상 충분한 데이터가 존재</li>
<li>시간 경계에서의 데이터 왜곡이 적음</li>
<li>단점: “오늘의 랭킹”이라는 개념이 모호해짐</li>
</ul>
</li>
</ul>
<p>처음에는 데이터 왜곡이 적고,  <strong>최근 24시간 기준</strong>이 더 합리적으로 보였습니다.
하지만 콜드 스타트 문제를 실제로 고민하고 일간 랭킹의 범위를 명확히 구분하기 위해,
이번 구현에서는 <strong>캘린더 기준(00:00 시작)의 일간 랭킹</strong>을 선택했습니다.</p>
<hr>
<h2 id="2️⃣-시간의-양자화와-이벤트-집계-단위">2️⃣ 시간의 양자화와 이벤트 집계 단위</h2>
<p>랭킹 시스템에서 중요한 질문 중 하나는 다음과 같습니다.</p>
<blockquote>
<p>연속적으로 발생하는 이벤트를 <strong>어떤 시간 단위로 끊어 집계할 것인가?</strong></p>
</blockquote>
<ul>
<li><p><strong>1분 단위 집계</strong></p>
<ul>
<li>세밀한 계산 가능</li>
<li>하지만 작은 변동이나 노이즈 이벤트까지 모두 반영되어 랭킹이 불안정해질 수 있음</li>
</ul>
</li>
<li><p><strong>너무 큰 단위 집계</strong></p>
<ul>
<li>랭킹은 안정적</li>
<li>하지만 실제 의미 있는 급등·급락이 늦게 반영됨</li>
</ul>
</li>
</ul>
<p>이 두 극단 사이에서,
<strong>노이즈는 완만하게 흡수하면서도 의미 있는 변화는 반영할 수 있는 단위</strong>로
<strong>10분 단위(time-bucket)</strong> 집계를 선택했습니다.</p>
<p>추후 10분 단위 데이터를 기반으로 시간 감쇠(Time Decay) 모델을 적용해
급상승 상품·브랜드 랭킹으로 확장할 수도 있습니다.</p>
<pre><code class="language-text">product_views:{productId}::{yyyyMMddHHmm}
product_likes:{productId}::{yyyyMMddHHmm}
product_sales:{productId}::{yyyyMMddHHmm}</code></pre>
<ul>
<li>TTL: <strong>2일</strong></li>
<li>이벤트는 <strong>commerce-api에서 Kafka로 발행된 시각(event time)</strong> 기준으로 bucket에 귀속</li>
<li>실시간 계산에 필요한 최소한의 데이터만 Redis에 유지</li>
</ul>
<hr>
<h2 id="3️⃣-redis-→-db-영속화-전략">3️⃣ Redis → DB 영속화 전략</h2>
<p>이벤트는 eventTime 기준으로 처리되기 때문에,</p>
<blockquote>
<p>“12:00에 배치가 실행되었다고 해서,
12:00까지 발생한 모든 이벤트가 이미 집계되었다고 보장할 수는 없다.”</p>
</blockquote>
<p>이 문제를 해결하기 위해 <strong>변화량(delta) 기반 영속화 방식</strong>을 선택했습니다.</p>
<h3 id="처리-흐름">처리 흐름</h3>
<ol>
<li><p>이벤트 발생 시</p>
<ul>
<li>조회수 / 좋아요 / 판매량을 Redis bucket에 증가</li>
</ul>
</li>
<li><p>배치 실행 시</p>
<ul>
<li><p><code>&quot;product_likes:*&quot;</code> 와 같은 패턴으로 Redis SCAN 조회</p>
</li>
<li><p><code>redisTemplate.getAndDelete(key)</code> 사용</p>
<ul>
<li>읽기와 삭제를 동시에 처리</li>
<li>중복 집계 방지</li>
</ul>
</li>
</ul>
</li>
<li><p>DB에는 <strong>delta update 방식</strong>으로 누적 저장</p>
</li>
</ol>
<p>이 방식으로,</p>
<ul>
<li>집계 시점과 이벤트 발생 시점의 불일치 문제를 완화하고</li>
<li>절대적인 시각 일치보다는 <strong>일관된 증가량 반영</strong>에 집중했습니다.</li>
</ul>
<hr>
<h2 id="4️⃣-실시간-상품별-일간-랭킹-redis-집계">4️⃣ 실시간 상품별 일간 랭킹 Redis 집계</h2>
<p>집계된 지표를 기반으로, 상품별 인기도 점수를 계산해
일자 단위 ZSET에 저장합니다.</p>
<pre><code class="language-text">ranking:all:{yyyyMMdd}</code></pre>
<pre><code class="language-java">VIEW_WEIGHT  = 0.1
LIKE_WEIGHT  = 0.2
ORDER_WEIGHT = 0.6
TTL_DAYS     = 2</code></pre>
<p>점수는 가중치 기반으로 계산되며,
ZSET의 score를 증가시키는 방식으로 반영합니다.</p>
<pre><code class="language-java">redisTemplate.opsForZSet()
    .incrementScore(rankingKey, productId.toString(), score);</code></pre>
<ul>
<li>ZSET 특성을 활용한 자동 정렬</li>
<li>날짜별 키 분리로 랭킹 범위 명확화</li>
<li>TTL을 통한 자연스러운 데이터 정리</li>
</ul>
<hr>
<h2 id="5️⃣-ranking-api-구성">5️⃣ Ranking API 구성</h2>
<ul>
<li><strong>랭킹 목록 조회</strong></li>
</ul>
<pre><code class="language-java">reverseRange(rankingKey, start, end)</code></pre>
<ul>
<li><strong>상품별 랭크 조회</strong></li>
</ul>
<pre><code class="language-java">reverseRank(rankingKey, productId)</code></pre>
<ul>
<li><strong>상품별 점수 조회</strong></li>
</ul>
<pre><code class="language-java">score(rankingKey, productId)</code></pre>
<ul>
<li><strong>랭킹 멤버 수 조회</strong></li>
</ul>
<pre><code class="language-java">zCard(rankingKey)</code></pre>
<hr>
<h2 id="6️⃣-콜드-스타트-완화를-위한-carry-over-전략">6️⃣ 콜드 스타트 완화를 위한 Carry Over 전략</h2>
<p>캘린더 기준 일간 랭킹의 가장 큰 문제는
<strong>00시 직후 랭킹 데이터가 거의 없다는 점</strong>입니다.</p>
<p>이를 완화하기 위해,
매일 <strong>23:50</strong>, 오늘의 랭킹 점수를 <strong>가중치 0.2</strong>로 감쇠하여
다음 날 랭킹 키에 미리 반영합니다.</p>
<ul>
<li>전날 인기 상품의 자연스러운 노출 유지</li>
<li>00시 직후 랭킹 공백 완화</li>
<li>일간 랭킹의 연속성 확보</li>
</ul>
<hr>
<h2 id="7️⃣-정리하며">7️⃣ 정리하며</h2>
<p>랭킹 시스템을 구현하다 보면,
로그처럼 <strong>모든 데이터를 정확히 기록해야 할 것 같은 유혹</strong>을 받게 됩니다.</p>
<p>하지만 랭킹은 로그 시스템이 아니라,</p>
<blockquote>
<p><strong>“인기 측정기”가 아닌
“노출을 제어하는 시스템”</strong></p>
</blockquote>
<p>입니다.</p>
<ul>
<li>일부 누락은 허용될 수 있고</li>
<li>절대적인 정확성보다</li>
<li><strong>일관성, 안정성, 예측 가능성</strong>이 더 중요합니다.</li>
</ul>
<p>추가로,
10분 단위 집계 데이터를 <strong>시간 감쇠(Time Decay) 모델</strong>에 적용해
<strong>‘지금 뜨는 상품(Trending)’ 랭킹</strong>을 계산하는 방식으로 확장하여 롱테일 문제를 해결해 볼 수 있을 것입니다.</p>
<p>이번 설계는
“완벽한 집계”보다는
<strong>사용자가 신뢰할 수 있는 랭킹 경험</strong>을 목표로 한 선택들의 결과였습니다.</p>
<p>참고
<a href="https://dan.naver.com/25/sessions/681">https://dan.naver.com/25/sessions/681</a></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[Kafka에서 중복 이벤트를 전제로 설계해야 하는 이유와 처리 전략]]></title>
            <link>https://velog.io/@be_zion/Kafka%EC%97%90%EC%84%9C-%EC%A4%91%EB%B3%B5-%EC%9D%B4%EB%B2%A4%ED%8A%B8%EB%A5%BC-%EC%A0%84%EC%A0%9C%EB%A1%9C-%EC%84%A4%EA%B3%84%ED%95%B4%EC%95%BC-%ED%95%98%EB%8A%94-%EC%9D%B4%EC%9C%A0%EC%99%80-%EC%B2%98%EB%A6%AC-%EC%A0%84%EB%9E%B5</link>
            <guid>https://velog.io/@be_zion/Kafka%EC%97%90%EC%84%9C-%EC%A4%91%EB%B3%B5-%EC%9D%B4%EB%B2%A4%ED%8A%B8%EB%A5%BC-%EC%A0%84%EC%A0%9C%EB%A1%9C-%EC%84%A4%EA%B3%84%ED%95%B4%EC%95%BC-%ED%95%98%EB%8A%94-%EC%9D%B4%EC%9C%A0%EC%99%80-%EC%B2%98%EB%A6%AC-%EC%A0%84%EB%9E%B5</guid>
            <pubDate>Thu, 18 Dec 2025 18:06:31 GMT</pubDate>
            <description><![CDATA[<h2 id="1-왜-kafka에서는-중복-이벤트를-고려해야-하는가">1. 왜 Kafka에서는 중복 이벤트를 고려해야 하는가</h2>
<p>Kafka를 도입한다는 것은 <strong>비즈니스 로직과 후속 처리 로직을 분리하겠다는 설계 선택</strong>이다.</p>
<p>이 선택은 다음과 같은 변화를 만든다.</p>
<ul>
<li>하나의 트랜잭션 경계가 여러 컴포넌트로 분리된다</li>
<li>처리 시점이 비동기적으로 분리된다</li>
<li>실행 순서와 재시도 타이밍을 애플리케이션이 직접 통제할 수 없게 된다</li>
</ul>
<p>Kafka는 기본적으로 <strong>at-least-once 전달 방식</strong>을 사용한다.
이 말은 곧,</p>
<blockquote>
<p><strong>메시지 유실 가능성을 줄이는 대신
중복 처리 책임을 Consumer에게 위임한다</strong></p>
</blockquote>
<p>는 의미다.</p>
<p>따라서 Kafka 기반 시스템에서는 다음과 같은 상황이 <em>정상 동작 범위</em>로 발생할 수 있다.</p>
<ul>
<li>producer retry로 인한 중복 전송</li>
<li>consumer 장애 후 재시작</li>
<li>offset commit 이전 성공 처리</li>
<li>rebalancing으로 인한 재처리</li>
</ul>
<p>즉, Kafka를 사용하는 순간부터 시스템은
<strong>중복 이벤트를 예외가 아닌 전제 조건으로 두고 설계해야 한다.</strong></p>
<hr>
<h2 id="2-kafka에서-발생하는-두-가지-중복">2. Kafka에서 발생하는 두 가지 중복</h2>
<p>Kafka 중복 이벤트는 <strong>성격이 다른 두 종류의 중복</strong>이 존재한다.</p>
<hr>
<h3 id="2-1-이벤트-자체의-중복-eventid-기준">2-1. 이벤트 자체의 중복 (eventId 기준)</h3>
<pre><code class="language-text">같은 eventId를 가진 이벤트가 여러 번 소비됨</code></pre>
<p><strong>발생 원인</strong></p>
<ul>
<li>producer retry</li>
<li>consumer rebalancing</li>
<li>offset rollback</li>
<li>장애 후 재처리</li>
</ul>
<p>이 경우는 <strong>기술적인 중복</strong>이다.
같은 이벤트가 여러 번 전달되었을 뿐,
<strong>비즈니스 의미는 동일하다.</strong></p>
<hr>
<h3 id="2-2-비즈니스-의미의-중복-aggregate-기준">2-2. 비즈니스 의미의 중복 (aggregate 기준)</h3>
<pre><code class="language-text">같은 주문 / 같은 좋아요 / 같은 재고 차감이 두 번 반영됨</code></pre>
<p><strong>특징</strong></p>
<ul>
<li>eventId는 서로 다를 수 있다</li>
<li>하지만 <strong>같은 비즈니스 행위가 두 번 처리된 결과</strong>를 만든다</li>
</ul>
<p>이 중복은 단순 전달 문제가 아니라,
<strong>도메인 규칙이 깨지는 상황</strong>이다.</p>
<hr>
<h2 id="3-핵심-원칙-consumer는-결과적으로-멱등해야-한다">3. 핵심 원칙: Consumer는 “결과적으로” 멱등해야 한다</h2>
<p>흔히 다음과 같은 원칙을 이야기한다.</p>
<blockquote>
<p><strong>Consumer는 항상 멱등해야 한다</strong></p>
</blockquote>
<p>이 문장은 어떤 의미를 가지고 있을까</p>
<h3 id="❓-좋아요-1-재고--1-같은-로직은-멱등하지-않은데">❓ 좋아요 +1, 재고 -1 같은 로직은 멱등하지 않은데?</h3>
<p>이 로직 자체는 <strong>본질적으로 비멱등</strong>이다.</p>
<p>따라서 이 원칙의 정확한 의미는 다음에 가깝다.</p>
<blockquote>
<p><strong>같은 이벤트가 여러 번 전달되더라도
최종 비즈니스 상태는 한 번만 반영되어야 한다</strong></p>
</blockquote>
<p>즉,</p>
<ul>
<li>로직 자체가 멱등일 필요는 없고</li>
<li><strong>중복 이벤트가 결과를 깨뜨리지 않도록 보호 장치가 있어야 한다</strong></li>
</ul>
<hr>
<h2 id="전략-1-eventid-기반-중복-제거-inbox--dedup-table">전략 1: eventId 기반 중복 제거 (Inbox / Dedup Table)</h2>
<h3 id="eventid는-어디서-생성해야-할까">eventId는 어디서 생성해야 할까?</h3>
<p>이벤트 발행 시점에 <strong>producer가 eventId를 생성</strong>한다.</p>
<p>초기에는 Auto Increment ID를 고려했지만,
Outbox 패턴을 사용하면서 문제가 발생했다.</p>
<ul>
<li>Outbox insert 시점에는 eventId가 없음</li>
<li>이후 update 로직이 필요해짐</li>
<li>트랜잭션 흐름이 불필요하게 복잡해짐</li>
</ul>
<p>그래서 <strong>producer에서 ID를 먼저 생성</strong>하는 방식으로 전환했다.</p>
<hr>
<h3 id="uuid-vs-ulid">UUID vs ULID</h3>
<table>
<thead>
<tr>
<th>항목</th>
<th>UUID v4</th>
<th>ULID</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>DB 인덱스 친화성</td>
<td>❌</td>
<td>✅</td>
</tr>
<tr>
<td>이벤트 흐름 추적</td>
<td>어려움</td>
<td>쉬움</td>
</tr>
<tr>
<td>표준화</td>
<td>매우 높음</td>
<td>상대적으로 낮음</td>
</tr>
</tbody></table>
<p>Kafka + Outbox 환경에서는 다음 이유로 <strong>ULID가 특히 유리했다</strong>.</p>
<ul>
<li>Outbox 테이블에서 최신 이벤트 스캔 비용 감소</li>
<li>DB 인덱스 locality 개선</li>
<li>이벤트 발생 흐름을 시간 기준으로 추적 가능</li>
</ul>
<p>이벤트는 단순 식별자가 아니라,
<strong>흐름과 순서를 해석하기 위한 단서</strong>이기 때문이다.</p>
<hr>
<h3 id="처리-흐름">처리 흐름</h3>
<ol>
<li>producer에서 eventId 생성</li>
<li>비즈니스 트랜잭션과 함께 Outbox에 이벤트 저장</li>
<li>Kafka로 이벤트 publish</li>
<li>Consumer Inbox에 eventId 저장</li>
<li>eventId 기준 중복 여부 판단</li>
<li>최초 이벤트만 비즈니스 로직 실행</li>
</ol>
<p>이 방식은 다음과 같은 <strong>정합성 중심 도메인</strong>에 적합하다.</p>
<ul>
<li>주문</li>
<li>결제</li>
<li>쿠폰</li>
<li>재고</li>
</ul>
<hr>
<h2 id="전략-2-aggregateid-기반-멱등성">전략 2: aggregateId 기반 멱등성</h2>
<p>모든 중복 처리를 eventId 기준으로 할 필요는 없다.</p>
<p>경우에 따라 <strong>비즈니스 식별자 기준</strong>이 더 효율적일 수 있다.</p>
<h3 id="언제-적합한가">언제 적합한가?</h3>
<ul>
<li>좋아요</li>
<li>팔로우</li>
<li>찜하기</li>
</ul>
<p>예를 들어:</p>
<ul>
<li>같은 <code>userId + productId</code> 조합의 좋아요 이벤트</li>
<li>이미 좋아요 상태라면 추가 집계를 하지 않는다</li>
</ul>
<hr>
<h3 id="전략-선택-기준">전략 선택 기준</h3>
<table>
<thead>
<tr>
<th>기준</th>
<th>eventId dedup</th>
<th>aggregateId 멱등</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>
</tbody></table>
<hr>
<h2 id="4-delta-이벤트-설계">4. Delta 이벤트 설계</h2>
<p>추가로 좋아요, 조회수처럼 <strong>발생 빈도가 매우 높은 이벤트</strong>는 누적량만 업데이트 하는 이벤트를 설계할수 있다.</p>
<pre><code class="language-json">{ &quot;delta&quot;: +1 }</code></pre>
<p>Consumer는 단순 누적만 수행한다.</p>
<pre><code class="language-sql">UPDATE product
SET like_count = like_count + :delta;</code></pre>
<h3 id="장점">장점</h3>
<ul>
<li>Kafka 메시지 수 감소</li>
<li>DB 부하 감소</li>
</ul>
<h3 id="한계">한계</h3>
<ul>
<li>중복 시 정확도 보장 어려움</li>
<li>보정(batch, snapshot) 전략 필요</li>
</ul>
<p>현재 시스템에서는:</p>
<ul>
<li>정확성이 더 중요하고</li>
<li>트래픽이 임계점에 도달하지 않았다고 판단하여</li>
</ul>
<p>👉 <strong>단건 이벤트 방식을 유지</strong>했다.</p>
<hr>
<h2 id="5-outbox-패턴과-중복-처리의-관계">5. Outbox 패턴과 중복 처리의 관계</h2>
<p>Outbox 패턴은 종종 <strong>중복 처리까지 해결해준다고 오해</strong>된다. 실제로 초기 Producer 에서 중복을 제거하거나 변화량을 계산해 보내준다면, Customer 에서 더 빠른 처리가 가능하지 않을까 라는 생각아래, Outbox 에서도 중복을 제거를 시도하기도 했었다.</p>
<p>하지만 Producer에서 Outbox의 역할은 명확하다.</p>
<ul>
<li>이벤트 유실 방지</li>
<li>트랜잭션 경계 보장</li>
</ul>
<p>중복 방지는 아니다.</p>
<p>때문에 Outbox는 원자성 보장이 중요하다. 하나의 트랜잭션을 사용하여 주문 내역은 저장되었는데 이벤트가 누락되거나, 반대로 주문은 실패했는데 이벤트만 발행되는 모순이 발생하지 않도록 해야한다.</p>
<pre><code class="language-java">@Transactional
  public OrderInfo createOrder(CreateOrderCommand command) {
    // 비즈니스 로직
    Order order = Order.create(command.userId(), createOrderItems(command.orderItemRequests(), products), command.couponIssueId());
    Order savedOrder = orderService.save(order);

    // 한 트랜잭션으로 Outbox 저장
    OrderCreatedEvent orderCreatedEvent = new OrderCreatedEvent(
        savedOrder.getId(),
        command.userId(),
        command.couponIssueId(),
        command.cardType(),
        command.cardNo()
    );

    OutboxEvent savedOutboxEvent = outboxService.saveEvent(
        &quot;Order&quot;,
        savedOrder.getId().toString(),
        &quot;OrderCreated&quot;,
        orderCreatedEvent
    );

    return OrderInfo.from(savedOrder);
  }</code></pre>
<blockquote>
<p><strong>Outbox + Consumer 멱등성</strong></p>
</blockquote>
<hr>
<h2 id="정리">정리</h2>
<ul>
<li>Kafka에서 중복은 <strong>장애가 아니라 설계 전제</strong>다</li>
<li>중복을 “막으려는 설계”보다</li>
<li><strong>중복이 와도 안전한 설계가 중요하다</strong></li>
</ul>
<blockquote>
<p><strong>좋은 Kafka 설계란
중복 이벤트가 와도 시스템이 흔들리지 않는 설계다</strong></p>
</blockquote>
<p>Kafka 중복 처리는 기술 문제가 아니라,
<strong>도메인 성격에 맞는 멱등성 전략을 선택하는 문제다.</strong></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[🧩 Spring Application Event로 주문 생성 트랜잭션 분리하기]]></title>
            <link>https://velog.io/@be_zion/Spring-Application-Event%EB%A1%9C-%EC%A3%BC%EB%AC%B8-%EC%83%9D%EC%84%B1-%ED%8A%B8%EB%9E%9C%EC%9E%AD%EC%85%98-%EB%B6%84%EB%A6%AC%ED%95%98%EA%B8%B0</link>
            <guid>https://velog.io/@be_zion/Spring-Application-Event%EB%A1%9C-%EC%A3%BC%EB%AC%B8-%EC%83%9D%EC%84%B1-%ED%8A%B8%EB%9E%9C%EC%9E%AD%EC%85%98-%EB%B6%84%EB%A6%AC%ED%95%98%EA%B8%B0</guid>
            <pubDate>Thu, 11 Dec 2025 16:25:50 GMT</pubDate>
            <description><![CDATA[<h1 id="tldr"><strong>TL;DR</strong></h1>
<p>이 글에서는 기존에 하나의 트랜잭션 안에서 동작하던 주문 생성 로직을 Spring Application Event 기반으로 분리하며, 트랜잭션 안정성과 코드 구조 개선을 동시에 달성한 과정을 정리합니다.</p>
<hr>
<h2 id="⚙️-spring-application-event란-무엇인가">⚙️ Spring Application Event란 무엇인가</h2>
<p>Spring Application Event는 <strong>JVM 내부 메모리에서 동작하는 in-memory 이벤트 시스템</strong>입니다.
서비스 간 강한 결합을 줄이고 후속 로직을 별도의 이벤트 핸들러로 분리하는 데 효과적입니다.
단일 서버 환경에서는 구현이 간단하고 성능도 좋아 널리 사용하는 방식입니다.</p>
<p>다만 구조적으로 JVM 단일 인스턴스를 기반으로 하기 때문에 몇 가지 명확한 한계가 존재합니다.</p>
<hr>
<h2 id="🚫-spring-application-event의-구조적-한계">🚫 Spring Application Event의 구조적 한계</h2>
<p>Spring Event는 <strong>JVM 안에서만 처리되는 메모리 기반 이벤트</strong>이기 때문에 아래와 같은 제약을 갖습니다.</p>
<h3 id="🔁-멀티-인스턴스-환경에서-이벤트가-여러-번-실행되는-문제">🔁 멀티 인스턴스 환경에서 이벤트가 여러 번 실행되는 문제</h3>
<p>Spring Event는 인스턴스마다 독립적으로 존재하기 때문에
동일한 이벤트가 여러 인스턴스에서 각각 실행될 수 있습니다.
“후속 처리가 단 한 번만 실행돼야 하는 로직”에는 적합하지 않습니다.</p>
<h3 id="💥-장애-시-이벤트-유실">💥 장애 시 이벤트 유실</h3>
<p>이벤트가 저장되지 않기 때문에
처리되기 전에 서버가 종료되면 해당 이벤트는 복구할 수 없습니다.
결제·정산·포인트 등 <strong>신뢰성이 중요한 영역에서 사용하기 어렵습니다.</strong>
이번 프로젝트에서는 우선 트랜잭션 분리를 목적에 두고 Spring Event를 사용했습니다.</p>
<hr>
<h2 id="🛠️-주문-생성-트랜잭션-분리-과정">🛠️ 주문 생성 트랜잭션 분리 과정</h2>
<p>기존 주문 생성 로직은 다음과 같은 문제가 있었습니다.</p>
<ul>
<li>주문 생성, 재고 차감, 포인트 차감, 쿠폰 처리, PG 요청이 모두 하나의 트랜잭션에 묶여 있음</li>
<li>후속 로직 오류(PG 요청 등)가 발생하면 주문 전체가 롤백됨</li>
<li>트랜잭션 시간이 불필요하게 길어짐</li>
<li>기능 확장 시 복잡도 증가</li>
</ul>
<p>이를 해결하기 위해 주문 생성과 후속 처리(쿠폰, PG 요청)를 이벤트 기반으로 분리했습니다.</p>
<hr>
<h2 id="🧭-주문-처리-전략-선택-과정">🧭 주문 처리 전략 선택 과정</h2>
<h3 id="모델-1--검증·점유-우선-정합성-우선">모델 1 — “검증·점유 우선” (정합성 우선)</h3>
<p>항공권/예약 시스템 등 <strong>정확성이 중요한 환경</strong>에서 사용하는 방식입니다.</p>
<h3 id="모델-2--주문-먼저-정합성-검증은-나중-매출-우선">모델 2 — “주문 먼저, 정합성 검증은 나중” (매출 우선)</h3>
<p>트래픽이 높은 커머스에서 자주 사용하는 방식입니다.</p>
<hr>
<h2 id="🎯-선택한-방향-모델-1-기반--후속-처리-이벤트-분리">🎯 선택한 방향: 모델 1 기반 + 후속 처리 이벤트 분리</h2>
<p>서비스 특성상 아래 기준을 세웠습니다.</p>
<ul>
<li>재고는 동시성 요구가 매우 강해 트랜잭션 내부에서 처리해야 합니다</li>
<li>쿠폰, 포인트, PG 요청은 후순위여도 무방합니다</li>
<li>주문 생성 자체는 PG 성공 여부와 관계없이 기록되어야 합니다</li>
</ul>
<hr>
<h1 id="🏗️-최종-구조">🏗️ 최종 구조</h1>
<h2 id="주문-생성은-본-트랜잭션에서-처리">주문 생성은 본 트랜잭션에서 처리</h2>
<pre><code class="language-java">@Transactional
public OrderInfo createOrder(CreateOrderCommand command) {
    List&lt;Product&gt; products = productService.getExistingProducts(
        command.orderItemRequests().stream()
            .map(CreateOrderCommand.OrderItemRequest::productId)
            .toList()
    );

    deductStock(command.orderItemRequests());

    Order order = Order.create(command.userId(), createOrderItems(command.orderItemRequests(), products));
    Order savedOrder = orderService.save(order);

    eventPublisher.publishEvent(new OrderCreatedEvent(
        savedOrder.getId(),
        command.userId(),
        command.couponId(),
        command.cardType(),
        command.cardNo()
    ));

    return OrderInfo.from(savedOrder);
}</code></pre>
<p><strong>핵심 의도</strong></p>
<ul>
<li>주문 생성과 재고 차감은 즉시 DB에 반영되어야 합니다</li>
<li>쿠폰/포인트/PG 요청은 주문 생성 이후에도 충분히 처리 가능합니다</li>
<li>후속 로직은 이벤트로 분리해 트랜잭션 영향도를 최소화했습니다</li>
</ul>
<hr>
<h2 id="후속-로직은-별도-트랜잭션에서-처리">후속 로직은 별도 트랜잭션에서 처리</h2>
<pre><code class="language-java">@TransactionalEventListener(phase = AFTER_COMMIT)
@Async
public void handleOrderCreated(OrderCreatedEvent event) {
    Order order = orderService.getOrder(event.orderId());

    Money originalPrice = order.getTotalPrice();
    Money finalPrice = couponService.useCouponById(
        event.couponId(),
        event.userId(),
        originalPrice
    );

    paymentService.requestPayment(event.orderId(), event.cardType(), event.cardNo(), finalPrice);

    eventPublisher.publishEvent(new OrderDataTransferEvent(
        event.orderId(),
        event.userId(),
        order.getStatus(),
        order.getTotalPrice().getAmount(),
        LocalDateTime.now(),
        &quot;ORDER_CREATED&quot;
    ));
}</code></pre>
<h3 id="왜-after_commit인가">왜 AFTER_COMMIT인가?</h3>
<ul>
<li>주문 생성 트랜잭션이 커밋된 이후 실행됩니다</li>
<li>후속 로직 실패가 주문 생성에 영향을 주지 않습니다</li>
<li>후속 작업만 자체적으로 롤백될 수 있습니다</li>
</ul>
<hr>
<h2 id="❤️-좋아요-집계-구조-개선--고빈도-이벤트-처리-최적화">❤️ 좋아요 집계 구조 개선 — 고빈도 이벤트 처리 최적화</h2>
<p>좋아요 이벤트는 발생 빈도가 높아
각 요청마다 즉시 집계 테이블을 갱신하면 성능 부하가 커집니다.</p>
<p>이를 해결하기 위해 <strong>이벤트 기반 집계 + 디바운스 방식</strong>을 적용했습니다.</p>
<pre><code class="language-java">Long lastTime = lastProcessedTime.get(event.productId());
long currentTime = System.currentTimeMillis();

if (lastTime != null &amp;&amp; (currentTime - lastTime) &lt; debounceInterval) {
    return;
}

long actualLikeCount = likeService.getLikeCount(event.productId());
productListViewService.syncLikeCount(event.productId(), actualLikeCount);

lastProcessedTime.put(event.productId(), currentTime);</code></pre>
<hr>
<h2 id="📌-정리">📌 정리</h2>
<p>구조 개선을 통해 다음과 같은 효과를 얻었습니다.</p>
<ul>
<li>주문 생성 트랜잭션이 짧아졌습니다</li>
<li>후속 작업 오류가 주문 생성에 영향을 주지 않습니다</li>
<li>좋아요와 같은 고빈도 이벤트의 처리 효율이 크게 향상되었습니다</li>
</ul>
<p>다만 Spring Event는 근본적인 이벤트 플랫폼이 아니기 때문에
이벤트 유실·중복 처리 등 구조적 한계가 존재합니다.
이번 개편에서는 <strong>트랜잭션 분리와 코드 구조 개선</strong>을 주 목적에 두었으며,
추후 Kafka/RabbitMQ 등 MQ 기반 구조로 확장할 예정입니다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[PG Simulator 환경 기반 장애 전파 차단 전략 정리 (Timeout / Retry / CircuitBreaker)]]></title>
            <link>https://velog.io/@be_zion/PG-Simulator-%ED%99%98%EA%B2%BD-%EA%B8%B0%EB%B0%98-%EC%9E%A5%EC%95%A0-%EC%A0%84%ED%8C%8C-%EC%B0%A8%EB%8B%A8-%EC%A0%84%EB%9E%B5-%EC%A0%95%EB%A6%AC-Timeout-Retry-CircuitBreaker</link>
            <guid>https://velog.io/@be_zion/PG-Simulator-%ED%99%98%EA%B2%BD-%EA%B8%B0%EB%B0%98-%EC%9E%A5%EC%95%A0-%EC%A0%84%ED%8C%8C-%EC%B0%A8%EB%8B%A8-%EC%A0%84%EB%9E%B5-%EC%A0%95%EB%A6%AC-Timeout-Retry-CircuitBreaker</guid>
            <pubDate>Thu, 04 Dec 2025 16:05:11 GMT</pubDate>
            <description><![CDATA[<blockquote>
<p>PG 시뮬레이터를 로컬 PC(M1 Air)에서 실행하며, 실제 운영 환경에서 적용할 <strong>Failure Ready System</strong>을 어떻게 설계할지 수치 기반으로 정리했습니다.</p>
</blockquote>
<hr>
<h2 id="pg-simulator-사양-요약">PG Simulator 사양 요약</h2>
<table>
<thead>
<tr>
<th>구분</th>
<th>값</th>
</tr>
</thead>
<tbody><tr>
<td>요청 성공 확률</td>
<td><strong>60%</strong></td>
</tr>
<tr>
<td>요청 지연</td>
<td><strong>100ms ~ 500ms</strong></td>
</tr>
<tr>
<td>처리 지연</td>
<td><strong>1s ~ 5s</strong></td>
</tr>
<tr>
<td>처리 결과 성공률</td>
<td>성공 70% / 한도초과 20% / 카드오류 10%</td>
</tr>
</tbody></table>
<hr>
<h2 id="1-timeout-설정">1. Timeout 설정</h2>
<h3 id="✔-feign-connectread-timeout">✔ Feign Connect/Read Timeout</h3>
<h4 id="connect-timeout"><strong>connect-timeout</strong></h4>
<ul>
<li>서버와 TCP 연결을 맺는 데 걸리는 시간</li>
<li>PG 요청 지연 max = <strong>500ms</strong>
→ <strong>600ms</strong>로 설정</li>
</ul>
<h4 id="read-timeout"><strong>read-timeout</strong></h4>
<ul>
<li>서버가 응답을 보내기까지 기다리는 시간</li>
<li>PG 처리 지연 max = <strong>5s</strong>
→ <strong>6000ms</strong> 설정</li>
</ul>
<hr>
<h3 id="✔-resilience4j-timelimiter">✔ Resilience4j TimeLimiter</h3>
<p>Feign timeout 외에 <strong>TimeLimiter</strong>는 전체 실행 시간을 제한한다.</p>
<p>특징:</p>
<ul>
<li>호출 전체가 일정 시간을 넘기면 강제 취소</li>
<li>fallback 가능</li>
<li>CircuitBreaker 실패로 카운트됨</li>
</ul>
<p>PG 처리 지연 max = <strong>5s</strong>
→ <strong>timeout-duration = 5.5s (~6s)</strong> 설정</p>
<hr>
<h2 id="2-retry-설정">2. Retry 설정</h2>
<h3 id="✔-요청-단계-성공률-기반-계산">✔ 요청 단계 성공률 기반 계산</h3>
<p>PG 요청 성공률 = <strong>60%</strong>
→ 즉, Retries가 너무 많으면 PG 자체 부하만 키움</p>
<h3 id="retry-성공률-증가-효과">Retry 성공률 증가 효과</h3>
<table>
<thead>
<tr>
<th>max-attempts</th>
<th>성공률</th>
<th>부하 증가율</th>
</tr>
</thead>
<tbody><tr>
<td>1</td>
<td>60%</td>
<td>0.6</td>
</tr>
<tr>
<td>2</td>
<td><strong>84%</strong></td>
<td>0.6 + 0.6 * 0.4</td>
</tr>
<tr>
<td>3</td>
<td><strong>93.6%</strong></td>
<td>0.6 + 0.24 + 0.096</td>
</tr>
</tbody></table>
<p>→ 2회 → +24%
→ 3회 → +9.6% (효용 급감)</p>
<hr>
<h3 id="✔-retry-부하-증가율">✔ Retry 부하 증가율</h3>
<p>재시도는 성공률 증가보다 <strong>부하 폭증</strong>이 더 크다.</p>
<table>
<thead>
<tr>
<th>Retry 횟수</th>
<th>최대 시도</th>
<th>기대 시도</th>
<th>부하 증가율</th>
</tr>
</thead>
<tbody><tr>
<td>0</td>
<td>1</td>
<td>1.0</td>
<td>1.0x</td>
</tr>
<tr>
<td>1</td>
<td>2</td>
<td>1.4</td>
<td><strong>1.4x</strong></td>
</tr>
<tr>
<td>2</td>
<td>3</td>
<td>1.56</td>
<td>1.56x</td>
</tr>
<tr>
<td>3</td>
<td>4</td>
<td>1.624</td>
<td>1.62x</td>
</tr>
<tr>
<td>5</td>
<td>6</td>
<td>1.781</td>
<td>1.78x</td>
</tr>
</tbody></table>
<p>➡ 재시도 1회만 해도 <strong>부하가 40% 증가</strong>
→ 실제로는 retry 를 하지 않는 것이 가장 현실적인 선택</p>
<hr>
<h2 id="3-circuitbreaker-설정">3. CircuitBreaker 설정</h2>
<p>PG 자체의 “정상적인 실패율”을 먼저 계산한다.</p>
<h4 id="pg-성공률-">PG 성공률 =</h4>
<pre><code>요청 성공률 60% × 처리 성공률 70% = 42%</code></pre><p>즉, 실패율:</p>
<pre><code>100% - 42% = 58%</code></pre><p>따라서 CircuitBreaker의 <code>failure-rate-threshold</code> 는
→ 최소 <strong>65% 이상</strong>이 되어야 정상 실패를 차단하지 않음.</p>
<hr>
<h2 id="4-로컬-pg-simulator-성능-측정-wrk">4. 로컬 PG Simulator 성능 측정 (wrk)</h2>
<pre><code>brew install wrk
wrk -t2 -c200 -d30s -s post.lua http://localhost:8082/api/v1/payments</code></pre><h4 id="postlua">post.lua</h4>
<pre><code class="language-lua">wrk.method = &quot;POST&quot;
wrk.body   = [[
{
  &quot;orderId&quot;: &quot;1351039135&quot;,
  &quot;cardType&quot;: &quot;SAMSUNG&quot;,
  &quot;cardNo&quot;: &quot;1234-5678-9814-1451&quot;,
  &quot;amount&quot;: &quot;5000&quot;,
  &quot;callbackUrl&quot;: &quot;http://localhost:8080/api/v1/payments/callback&quot;
}
]]
wrk.headers[&quot;Content-Type&quot;] = &quot;application/json&quot;
wrk.headers[&quot;X-USER-ID&quot;]    = &quot;135135&quot;</code></pre>
<hr>
<h3 id="✔-wrk-결과-해석-pg-단독">✔ wrk 결과 해석 (PG 단독)</h3>
<p><img src="https://velog.velcdn.com/images/be_zion/post/1c8c41c3-cd1a-4ca4-a430-8e1d36bf4e97/image.png" alt=""></p>
<ul>
<li><strong>TPS ≈ 300</strong></li>
<li>평균 지연 ≈ <strong>625ms</strong></li>
<li>Max latency = <strong>2000ms</strong></li>
<li>Timeout = 202건</li>
</ul>
<p>2초 정도의 지연은 사용자가 느끼기에 긴 시간으로 느껴지지 않아, Slow-call 기준을 다음처럼 설정:</p>
<pre><code>slow-call-duration-threshold: 2000ms</code></pre><p>PG 자체가 감당 가능한 처리량 = <strong>약 300 TPS</strong>
→ 이 이상의 호출이 오면 PG가 먼저 병목됨</p>
<hr>
<h2 id="5-결제-요청-api--retry-적용-전후-성능-비교">5. 결제 요청 API : Retry 적용 전/후 성능 비교</h2>
<p>Retry 여부를 검증하기 위해 동일 조건에서 테스트함.</p>
<pre><code>wrk -t2 -c200 -d30s -s post.lua http://localhost:8080/api/v1/payments</code></pre><hr>
<h3 id="✔-retry-1회-적용-시-비교">✔ Retry 1회 적용 시 비교</h3>
<table>
<thead>
<tr>
<th>지표</th>
<th>Retry O</th>
<th>Retry X</th>
<th>변화</th>
</tr>
</thead>
<tbody><tr>
<td>요청 수</td>
<td>2854</td>
<td>2754</td>
<td>+100</td>
</tr>
<tr>
<td>성공 TPS</td>
<td><strong>94.96</strong></td>
<td>91.65</td>
<td>+3.3%</td>
</tr>
<tr>
<td>Latency</td>
<td>1.26s</td>
<td>1.24s</td>
<td>거의 동일</td>
</tr>
<tr>
<td>Non-2xx</td>
<td><strong>109</strong></td>
<td>189</td>
<td><strong>-42% ↓</strong></td>
</tr>
<tr>
<td>Timeout</td>
<td>1236</td>
<td>1223</td>
<td>비슷</td>
</tr>
</tbody></table>
<h4 id="결과">결과</h4>
<ul>
<li>timeout·latency는 거의 변화 없음</li>
<li><strong>Non-2xx는 42% 감소 (큰 개선)</strong></li>
<li>부하 증가는 아주 조금</li>
</ul>
<p>➡ 따라서 <strong>retry 1회(max-attempts:2)</strong> 가 최적</p>
<hr>
<h2 id="최종-설정값">최종 설정값</h2>
<pre><code class="language-yaml">feign:
  client:
    config:
      pgClient:
        connect-timeout: 600 #PG 요청 지연 max = 500ms
        read-timeout: 6000 #PG 처리 지연 max = 5s
resilience4j:
  timelimiter:
    instances:
      pgTimeLimiter:
        timeout-duration: 5s #PG 처리 지연 max = 5s, read-timeout 보다 작은값
        cancel-running-future: true
  retry:
    instances:
      pgRetry:
        max-attempts: 2
        wait-duration: 1s
        retry-exceptions:
          - feign.RetryableException
        fail-after-max-attempts: true
  circuitbreaker:
    instances:
      pgCircuit:
        failure-rate-threshold: 65 # 요청 성공률 60% × 처리 성공률 70% = 42%, 실패율  58%
        slow-call-rate-threshold: 50
        slow-call-duration-threshold: 2s # Max latency = 2000ms
        permitted-number-of-calls-in-half-open-state: 30 # TPS 300 의 10~20%
        minimum-number-of-calls: 30 # 타임아웃 2s, TPS 300
        sliding-window-type: TIME_BASED # 트래픽 차이가 클경우, TIME_BASED
        sliding-window-size: 60 # 장애탐지가 중요하므로 작게
        wait-duration-in-open-state: 5s #회복하는데 걸리는 시간 기본 5s 적용
        automatic-transition-from-open-to-half-open-enabled: true
        record-exceptions:
          - feign.FeignException
          - org.springframework.web.client.HttpServerErrorException
          - java.io.IOException
          - java.util.concurrent.TimeoutException      
</code></pre>
<hr>
<h2 id="🔚-마무리">🔚 마무리</h2>
<p>이번 글은 <strong>PG Simulator의 수치를 기반</strong>으로
Timeout, Retry, CircuitBreaker 값을 합리적으로 결정한 과정을 정리했습니다.</p>
<p>실제 운영 환경의 성능, 네트워크 품질에 따라 수치는 달라질 수 있어, 차후 실제 환경 부하 테스트를 통해 더 세밀한 수정이 필요합니다. 특히 위 방식이 로컬을 기준으로 측정된 값 + 제공된 PG 사양이 혼합되어 있지만, 수치를 기반으로 근거있는 결정을 해볼수 있었습니다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[상품 목록/상세 조회 캐시 설계]]></title>
            <link>https://velog.io/@be_zion/%EC%83%81%ED%92%88-%EB%AA%A9%EB%A1%9D%EC%83%81%EC%84%B8-%EC%A1%B0%ED%9A%8C-%EC%BA%90%EC%8B%9C-%EC%84%A4%EA%B3%84</link>
            <guid>https://velog.io/@be_zion/%EC%83%81%ED%92%88-%EB%AA%A9%EB%A1%9D%EC%83%81%EC%84%B8-%EC%A1%B0%ED%9A%8C-%EC%BA%90%EC%8B%9C-%EC%84%A4%EA%B3%84</guid>
            <pubDate>Fri, 28 Nov 2025 02:33:25 GMT</pubDate>
            <description><![CDATA[<p>상품 목록과 상세 조회를 최적화하기 위해 캐시 구조를 설계하면서 겪은 고민과 시행착오를 정리해보았습니다. <strong>왜 이렇게 설계했는지, 어떤 문제를 만났는지를</strong> 공유합니다.</p>
<hr>
<h2 id="1-조회-목록-기능에서-고려해야-할-조건">1. 조회 목록 기능에서 고려해야 할 조건</h2>
<ul>
<li>브랜드별 상품 목록 조회  </li>
<li>정렬 기준: 최신순, 가격순, 좋아요수  </li>
<li>페이징: 페이지 단위로 결과 제공  </li>
</ul>
<p>목록 조회는 자주 발생하지만, 일부 조건(최신순, 좋아요순)에 따라 <strong>데이터 변경 빈도가 달라지므로 캐시 전략을 달리</strong>할 필요가 있었습니다.</p>
<hr>
<h2 id="2-목록상세-캐시-설계">2. 목록/상세 캐시 설계</h2>
<h3 id="2-1-비정규화-테이블-생성">2-1. 비정규화 테이블 생성</h3>
<p>처음에는 상품 목록 조회 시, <strong>상품 상세(Product) + 재고(Stock) + 좋아요수(Like)</strong>를 조회 시점에 조합하는 방식으로 개발했습니다.<br>조회/수정이 빈번한 좋아요 수로 인한 상품 조회/수정시 성능 이슈가 걱정 되어서 였습니다. 대신 캐시 설계시 비정규화 테이블을 생성하기로 결정했습니다.</p>
<ul>
<li>문제: 매번 조회 시 조합해야 하므로 DB 부하가 발생  </li>
<li>해결: <strong>목록 조회에 최적화된 비정규화 테이블 <code>ProductListView</code></strong> 생성  <ul>
<li>좋아요수 포함  </li>
<li>목록 조회 시 바로 사용할 수 있음  </li>
<li>상세 캐시는 별도로 관리(<code>ProductStock</code>)  </li>
</ul>
</li>
</ul>
<blockquote>
<p>요약: 목록 조회는 비정규화 테이블, 상세 조회는 Product + Stock 조합</p>
</blockquote>
<hr>
<h3 id="2-2-목록상세-캐시-분리-및-id-기반-조합">2-2. 목록/상세 캐시 분리 및 ID 기반 조합</h3>
<p>처음 구현에서는 <strong>목록 캐시와 상세 캐시를 각각 생성</strong>되어있고, 총 좋아요 수 가 각 캐시에 저징되어 있어, 좋아요 수정시 목록/상세 캐시가 모두 업데이트 되어야만 하는방식이었습니다. 때문에 상품과 좋아요 캐시를 우선 분리하고, 목록 캐시에서 상세 캐시를 조합하는 방식으로 진행했습니다.</p>
<pre><code class="language-text">// 초기 캐시 구조
목록 캐시: product:list:{brandId}:{sort}:{page}:{size}
상세 캐시: product:detail:{productId}</code></pre>
<ul>
<li>문제: 좋아요수가 자주 변경될 경우, 모든 목록/상세 캐시를 삭제해야 함 → 성능 저하</li>
<li>해결: <strong>목록 캐시는 ID 리스트만 캐싱하고, 상세 캐시는 ID 기준으로 조합</strong>하는 방식 진행</li>
</ul>
<pre><code class="language-java">// 목록 조회 시 상세 캐시 조합 예시
List&lt;ProductWithLikeCount&gt; list = productIds.stream()
    .map(id -&gt; {
        ProductStock stock = cacheRepository.get(id);
        if (stock == null) stock = getProductStock(id);
        LikeInfo like = likeCacheRepository.getLikeInfo(userId, id);
        return new ProductWithLikeCount(
            id,
            stock.product().getName(),
            stock.product().getPrice().getAmount(),
            like.likeCount()
        );
    }).toList();</code></pre>
<pre><code class="language-text">

* 장점:

  1. 상세 캐시 미스가 있어도 전체 목록을 DB에서 재조회하지 않음
  2. 좋아요수 변경 시 페이지별 캐시를 일일이 삭제할 필요 없음
  3. 캐시 효율과 데이터 최신성을 모두 고려 가능</code></pre>
<hr>
<h2 id="3-상세-캐시-미스-처리">3. 상세 캐시 미스 처리</h2>
<ul>
<li>목록 ID 리스트 캐시는 존재하지만, 일부 상세 캐시(ProductStock)가 없는 경우</li>
<li>각 ID 마다 상세 조회가 일어남</li>
<li>해결 방법: <strong>해당 페이지의 상세 캐시가 없는 ID만 DB 조회 및 캐싱</strong></li>
</ul>
<pre><code class="language-java">List&lt;Long&gt; missIds = productIds.stream()
    .filter(id -&gt; cacheRepository.get(id) == null)
    .toList();

if (!missIds.isEmpty()) {
    List&lt;ProductStock&gt; stocks = missIds.stream()
        .map(id -&gt; getProductStock(id))
        .toList();
    stocks.forEach(stock -&gt; cacheRepository.save(stock, DETAIL_TTL));
}</code></pre>
<hr>
<h2 id="4-페이지-단위-ttl-전략">4. 페이지 단위 TTL 전략</h2>
<p>각 목록별 특징을 고려해 페이지 마다 다른 TTL 전략을 고려했습니다.</p>
<ul>
<li><p>최신순(latest):</p>
<ul>
<li>신규 데이터 추가 시 1페이지는 즉시 변경됨 → 1분</li>
<li>뒤로 갈수록 변화 영향이 매우 적음 → TTL 증가 가능</li>
</ul>
</li>
<li><p>좋아요순(likes_desc):</p>
<ul>
<li>좋아요는 실시간으로 변하지만 1페이지만 영향 큼</li>
<li>뒤쪽 페이지는 순위 변동률이 낮음 → 2분 유지</li>
</ul>
</li>
<li><p>가격순, 기본</p>
<ul>
<li>데이터 변경 자체가 거의 없음 → TTL 길게(10분 이상)</li>
</ul>
</li>
</ul>
<pre><code class="language-java">private Duration getListTtl(String sort, int page) {
    if (&quot;latest&quot;.equals(sort)) {
        if (page == 0) return Duration.ofMinutes(1);
        if (page &lt;= 4) return Duration.ofMinutes(2);
        return Duration.ofMinutes(5);
    } else if (&quot;likes_desc&quot;.equals(sort)) {
        if (page == 0) return Duration.ofMinutes(1);
        return Duration.ofMinutes(2);
    }
    return Duration.ofMinutes(10);
}</code></pre>
<hr>
<h2 id="5-좋아요수-캐시-전략">5. 좋아요수 캐시 전략</h2>
<ul>
<li>좋아요수는 수정이 빈번한 데이터로 언제 DB에 저장하더라고 해당 데이터가 가장 정확한 데이터라고 보장 받을수 없습니다.</li>
<li><strong>캐시에서 먼저 증가/감소 후, DB는 추후 배치로 저장 예정</strong></li>
<li>배치 개발 전에는 순서를 맞추기 위해 <strong>캐시 → DB 저장</strong> 구조로 개발</li>
<li>키 구성:</li>
</ul>
<pre><code class="language-text">사용자별 좋아요 여부: product:liked:{userId}:{productId}
상품별 좋아요 수: product:likeCount:{productId}</code></pre>
<ul>
<li>장점: 높은 읽기 트래픽에서 캐시 성능 보장, DB 부담 최소화</li>
</ul>
<hr>
<h2 id="6-현-구조의-문제점-및-추가-개발-사항">6. 현 구조의 문제점 및 추가 개발 사항</h2>
<ul>
<li>배치를 통한 DB 저장, 동기화 로직 추가 필요. </li>
<li>추가 캐시 TTL 전략 고려, 인기 상품 TTL 연장, 저빈도 브랜드 캐시 주기적 삭제 등 고려 가능</li>
<li>캐시 미스시 id 리스트 전체 조화로 수정</li>
</ul>
<hr>
<h2 id="7-정리">7. 정리</h2>
<ol>
<li>비정규화 테이블로 목록 조회 최적화</li>
<li>목록/상세 캐시 분리 → ID 리스트 기반 조합</li>
<li>상세 캐시 미스는 해당 ID만 조회, 전체 재조회 방지</li>
<li>페이지 단위 TTL 전략 적용</li>
<li>좋아요수는 캐시 우선 → 배치 DB 저장</li>
</ol>
<blockquote>
<p>이번 설계를 통해, <strong>읽기 최적화 + 캐시 효율 + 변경 데이터 처리 문제</strong>를 모두 고려해볼수 있었습니다.</p>
</blockquote>
]]></description>
        </item>
        <item>
            <title><![CDATA[주문 시스템에서 JPA 1차 캐시와 @OneToOne 관계가 비관적 락을 방해했던 경험]]></title>
            <link>https://velog.io/@be_zion/%EC%A3%BC%EB%AC%B8-%EC%83%9D%EC%84%B1-%EB%8F%99%EC%8B%9C%EC%84%B1-%ED%85%8C%EC%8A%A4%ED%8A%B8-%EC%A4%91-%ED%8F%AC%EC%9D%B8%ED%8A%B8-%EC%A1%B0%ED%9A%8C-%EC%98%A4%EB%A5%98-%ED%95%B4%EA%B2%B0%EA%B8%B0</link>
            <guid>https://velog.io/@be_zion/%EC%A3%BC%EB%AC%B8-%EC%83%9D%EC%84%B1-%EB%8F%99%EC%8B%9C%EC%84%B1-%ED%85%8C%EC%8A%A4%ED%8A%B8-%EC%A4%91-%ED%8F%AC%EC%9D%B8%ED%8A%B8-%EC%A1%B0%ED%9A%8C-%EC%98%A4%EB%A5%98-%ED%95%B4%EA%B2%B0%EA%B8%B0</guid>
            <pubDate>Thu, 20 Nov 2025 16:05:05 GMT</pubDate>
            <description><![CDATA[<blockquote>
<p>TL;DR 
이커머스 시스템에서 주문이 발생하면 재고 차감과 포인트 차감이 동시에 일어납니다. 여러 사용자가 동시에 주문을 요청할 때 데이터 정합성을 보장하기 위해 비관적 락을 적용했지만, 예상치 못한 문제가 발생했습니다.
이 문제를 분석하고 해결한 과정을 정리했습니다.</p>
</blockquote>
<h2 id="1-배경-동시성-제어가-필요한-이유">1. 배경: 동시성 제어가 필요한 이유</h2>
<h3 id="동시성-문제의-예시">동시성 문제의 예시</h3>
<ul>
<li>재고가 1개 남았는데 10명이 동시에 주문한다면?</li>
<li>포인트가 100원 남았는데 여러 주문에서 동시에 차감한다면?</li>
</ul>
<p>동시성 제어 없이는 <strong>재고를 초과한 주문</strong>이나 <strong>마이너스 포인트</strong>가 발생할 수 있습니다.</p>
<h3 id="비관적-락을-선택한-이유">비관적 락을 선택한 이유</h3>
<p>주문 시스템에서 사용할 수 있는 동시성 제어 방식은 크게 두 가지입니다.</p>
<p><strong>낙관적 락(Optimistic Lock)</strong></p>
<ul>
<li>충돌이 드물 것이라 가정하고 자유롭게 작업</li>
<li>커밋 시점에 충돌 검사 후 문제가 있으면 롤백</li>
<li>충돌 시 재시도 비용 발생</li>
</ul>
<p><strong>비관적 락(Pessimistic Lock)</strong></p>
<ul>
<li>충돌이 발생할 것이라 가정하고 미리 락을 획득</li>
<li>다른 트랜잭션의 접근을 원천 차단</li>
<li>성능은 낮지만 데이터 정합성 보장</li>
</ul>
<p>주문 시스템에서는 <strong>비관적 락</strong>을 선택했습니다.</p>
<p><strong>선택 이유:</strong></p>
<ol>
<li>재고와 포인트는 데이터 정합성이 매우 중요한 데이터</li>
<li>잘못된 데이터로 인한 비즈니스 손해가 심각할 수 있음</li>
<li>성능 문제는 재고와 포인트를 상품/유저와 분리하여 완화</li>
</ol>
<h2 id="2-문제-발견-동시성-테스트-실패">2. 문제 발견: 동시성 테스트 실패</h2>
<h3 id="테스트-시나리오">테스트 시나리오</h3>
<p>10포인트를 가진 사용자가 4원짜리 상품(재고 10개)을 10번 동시에 주문합니다.</p>
<p><strong>예상 결과</strong></p>
<ul>
<li>2건 성공 / 8건 실패 (포인트 부족)</li>
<li>총 8원 차감</li>
<li>최종 상태: 재고 8개, 남은 포인트 2원</li>
</ul>
<h3 id="실제-결과">실제 결과</h3>
<p><img src="https://velog.velcdn.com/images/be_zion/post/fdb0af9d-c928-4062-bc0f-37447160a531/image.png" alt=""></p>
<pre><code>실패 건수: 0
남은 재고: 0개
남은 포인트: 2원</code></pre><p><strong>모든 주문이 성공</strong>했습니다. 재고는 정상적으로 차감되었지만, 포인트는 10건 모두 정상 실행되었는데도 오류가 발생하지 않았습니다.</p>
<p>로그를 확인한 결과:</p>
<ul>
<li>포인트 조회 시 <code>10 → 6</code> 업데이트 후에도 계속 <strong>10포인트로 조회</strong>됨</li>
<li>이후 어느 순간부터 <code>6 → 2</code>로만 조회되어 오류 없이 완료</li>
</ul>
<p><strong>쿼리문에 비관적 락은 제대로 동작하고 있지만 조회 포인트가 업데이트 되지 않고 있었습니다.</strong></p>
<hr>
<h2 id="원인-분석-1-mysql의-동시성-제어">원인 분석 1: MySQL의 동시성 제어</h2>
<p>문제를 이해하기 위해 먼저 사용하고 있는 MySQL의 동작 방식을 알아봤습니다.</p>
<h3 id="repeatable-read와-mvcc">Repeatable Read와 MVCC</h3>
<p>MySQL InnoDB의 기본 격리 수준은 <strong>Repeatable Read</strong>이며, <strong>MVCC(Multi-Version Concurrency Control)</strong>로 동시성을 제어합니다.</p>
<p><strong>MVCC의 핵심 개념:</strong></p>
<ul>
<li>각 레코드를 여러 버전으로 관리 (undo log 활용)</li>
<li>트랜잭션마다 읽는 데이터의 &quot;스냅샷 버전&quot;이 다를 수 있음</li>
<li>동시 접근 시에도 블로킹 없이 각자의 버전을 읽음</li>
</ul>
<h3 id="select-for-update의-특별한-동작">SELECT FOR UPDATE의 특별한 동작</h3>
<p>여기서 중요한 포인트가 있습니다.</p>
<p><strong><code>SELECT FOR UPDATE</code>는 MVCC 스냅샷을 무시하고 항상 최신 커밋된 데이터에 락을 겁니다.</strong></p>
<p>즉, Repeatable Read 격리 수준이더라도 <code>SELECT FOR UPDATE</code>, <code>UPDATE</code>, <code>DELETE</code> 같은 쓰기 락 작업은 마치 <strong>Read Committed</strong>처럼 동작합니다.</p>
<p>그렇다면 <code>SELECT FOR UPDATE</code>도 최신 데이터를 조회해야 정상인데, 왜 오래된 데이터가 조회되었을까요?</p>
<hr>
<h2 id="원인-분석-2-user-findbyid가-문제였다">원인 분석 2: User findById가 문제였다</h2>
<h3 id="의심스러운-코드-발견">의심스러운 코드 발견</h3>
<pre><code class="language-java">Optional&lt;User&gt; user = userRepository.findById(command.userId());</code></pre>
<p>포인트를 조회하는 곳에서 <code>User.getPoint()</code>를 직접 호출하는 곳은 없었습니다. 유일하게 Point와 연관되는 곳은 주문 생성 전 해당 객체들의 존재 여부를 확인하기 위해 조회되던 <code>userRepository.findById</code> 밖에 없었습니다.</p>
<p>User 객체가 실제로 필요한 곳이 없어, 해당 구문을 주석 처리하고 테스트를 실행했습니다.</p>
<p><img src="https://velog.velcdn.com/images/be_zion/post/1e09c6bf-7efc-46cb-9ef9-f839082b24f7/image.png" alt=""></p>
<p><strong>테스트가 성공했습니다!</strong></p>
<p><code>userRepository.findById</code>로 조회된 user를 사용하지 않고 조회 여부만으로 테스트의 결과가 달라지고 있었습니다. 조회만으로 1차 캐시가 생성되고, 이후 Point 조회 시 <code>SELECT FOR UPDATE</code> 값보다 1차 캐시의 값이 우선적으로 리턴되고 있는 상황이었습니다.</p>
<h2 id="원인-분석-3-jpa-1차-캐시의-함정">원인 분석 3: JPA 1차 캐시의 함정</h2>
<h3 id="jpa-1차-캐시와-select-for-update의-충돌">JPA 1차 캐시와 SELECT FOR UPDATE의 충돌</h3>
<p>JPA의 영속성 컨텍스트는 엔티티를 조회하면 1차 캐시에 저장합니다. 그리고 동일 트랜잭션 내에서 같은 엔티티를 다시 조회하면 <strong>DB를 거치지 않고</strong> 1차 캐시의 값을 반환합니다.</p>
<p>여기서 핵심은 &quot;DB를 거치지 않는다&quot;는 점입니다.</p>
<h3 id="mysql과-jpa는-서로-다른-레벨에서-동작한다">MySQL과 JPA는 서로 다른 레벨에서 동작한다</h3>
<p>앞서 설명한 MySQL의 MVCC와 <code>SELECT FOR UPDATE</code>는 <strong>데이터베이스 레벨</strong>의 이야기입니다. 반면 JPA 1차 캐시는 <strong>애플리케이션 레벨</strong>의 이야기입니다.</p>
<p><strong>문제의 핵심:</strong></p>
<ol>
<li>🔄 <code>userRepository.findById()</code> 호출 → User 엔티티가 1차 캐시에 저장됨</li>
<li>🚫 이후 <code>SELECT FOR UPDATE</code>로 Point를 조회하려 해도 → <strong>DB 쿼리가 아예 실행되지 않음</strong></li>
<li>⚠️ DB에서 최신 버전을 읽을 기회 자체가 없어짐</li>
<li>❌ 결과: 1차 캐시에 있는 &quot;오래된 값&quot;이 그대로 반환됨</li>
</ol>
<p>즉, MySQL의 <code>SELECT FOR UPDATE</code>가 아무리 최신 데이터를 가져오려 해도, <strong>JPA가 DB 조회 자체를 차단</strong>하기 때문에 무용지물이 되는 상황입니다.</p>
<h3 id="주석-처리한-코드가-왜-문제를-해결했을까">주석 처리한 코드가 왜 문제를 해결했을까?</h3>
<pre><code class="language-java">// Optional&lt;User&gt; user = userRepository.findById(command.userId()); // 주석 처리</code></pre>
<p>이 한 줄을 주석 처리했더니 테스트가 통과했습니다. 그 이유는:</p>
<ul>
<li><strong>주석 처리 전</strong>: <code>findById()</code>로 User를 조회 → Point도 함께 1차 캐시에 저장 → 이후 <code>SELECT FOR UPDATE</code>가 DB에 가지 않음</li>
<li><strong>주석 처리 후</strong>: User 조회를 하지 않음 → Point가 1차 캐시에 없음 → <code>SELECT FOR UPDATE</code>가 실제로 DB에서 최신 데이터를 가져옴</li>
</ul>
<p>결국 불필요한 User 조회가 1차 캐시를 오염시켜, 비관적 락의 동작을 무력화시킨 것입니다.</p>
<h2 id="원인-분석-4-왜-point까지-1차-캐시에-들어갔을까">원인 분석 4: 왜 Point까지 1차 캐시에 들어갔을까?</h2>
<h3 id="새로운-의문점">새로운 의문점</h3>
<p>여기서 의문이 생깁니다. <code>userRepository.findById()</code>로 User만 조회했는데, 왜 Point까지 1차 캐시에 저장되었을까요?</p>
<p>User와 Point는 <code>@OneToOne</code> 관계로 연결되어 있고, <strong>fetch=LAZY</strong>로 설정되어 있습니다. LAZY 로딩이라면 Point는 실제로 사용되는 시점에만 조회되어야 합니다. 즉, <code>user.getPoint()</code>를 명시적으로 호출하지 않는 한 Point는 1차 캐시에 들어가지 않아야 정상입니다.</p>
<p>그런데 실제로는 Point도 함께 조회되어 1차 캐시에 저장되고 있었습니다. 왜 그럴까요?</p>
<h3 id="lazy로-설정했는데-eager로-동작하는-이유-확인">LAZY로 설정했는데 EAGER로 동작하는 이유 확인</h3>
<p>의심이 든 부분을 검증하기 위해 테스트 코드를 작성했습니다. <code>findById(User)</code> 시, Point 객체가 LAZY/EAGER 중 어떤 방식으로 조회되었는지 확인하는 테스트입니다.</p>
<h3 id="테스트-결과-lazy-설정이-무시되고-있었다">테스트 결과: LAZY 설정이 무시되고 있었다</h3>
<ul>
<li>SQL 로그를 확인하니 User 조회 시 Point조회도 함께 실행됨</li>
<li>assert 검증으로 <strong>EAGER 조회</strong>가 일어남을 확인</li>
<li>디버그로 실행 시, User 객체 안의 Point가 Proxy가 아닌 <strong>실제 데이터를 가진 객체</strong>로 조회</li>
</ul>
<p><img src="https://velog.velcdn.com/images/be_zion/post/5b8c5ac4-3a11-458e-a454-a38d5162bb82/image.png" alt=""></p>
<p>분명 <code>fetch=LAZY</code>로 설정했는데, Hibernate가 이를 무시하고 EAGER로 동작하고 있었습니다.</p>
<h3 id="onetoone-양방향-관계의-숨겨진-동작">@OneToOne 양방향 관계의 숨겨진 동작</h3>
<p><code>@OneToOne</code> 관계에서 LAZY 로딩을 명시하더라도, <strong>Hibernate는 EAGER 로딩을 강제하는 경우가 많습니다.</strong></p>
<h4 id="왜-이런-일이-발생할까">왜 이런 일이 발생할까?</h4>
<p><strong>프록시 생성의 난제</strong>: <code>@OneToOne</code> 관계에서 LAZY 로딩을 구현하려면 프록시(Proxy) 객체를 사용해야 합니다. 하지만 <code>@OneToOne</code>은 특별한 문제가 있습니다.</p>
<p>예를 들어, User를 조회할 때 Hibernate는 &quot;이 User에게 연관된 Point가 있는가?&quot;를 알아야 프록시를 만들지 null을 반환할지 결정할 수 있습니다. 하지만 이를 알려면 어차피 Point 테이블을 조회해야 합니다.</p>
<p>결과적으로:</p>
<ul>
<li>Point가 있는지 확인하려면 → Point 테이블을 조회해야 함</li>
<li>어차피 조회할 거면 → 그냥 데이터까지 가져오자</li>
<li>Hibernate의 선택 → <strong>LAZY를 무시하고 EAGER로 동작</strong></li>
</ul>
<p>특히 <strong>양방향 관계</strong>에서, 그리고 <strong>연관 필드가 Not-Null</strong>인 경우 이런 현상이 자주 발생합니다.</p>
<h3 id="문제의-연쇄-작용-전체-그림">문제의 연쇄 작용 전체 그림</h3>
<p>이제 모든 퍼즐이 맞춰졌습니다:</p>
<ol>
<li>📝 <code>userRepository.findById()</code> 호출</li>
<li>🔄 <code>@OneToOne</code> 양방향 관계 + Hibernate의 내부 최적화 → Point도 함께 EAGER 로딩</li>
<li>💾 User와 Point 모두 1차 캐시에 저장됨</li>
<li>🔒 이후 <code>SELECT FOR UPDATE</code>로 Point를 조회하려 시도</li>
<li>🚫 JPA가 1차 캐시를 먼저 확인 → DB 쿼리가 실행되지 않음</li>
<li>⏰ 1차 캐시의 &quot;오래된 Point 값&quot;이 반환됨</li>
<li>❌ 비관적 락이 무력화되어 동시성 제어 실패</li>
</ol>
<h2 id="해결-방법-시도와-최종-결정">해결 방법 시도와 최종 결정</h2>
<h3 id="시도한-해결-방안들">시도한 해결 방안들</h3>
<ol>
<li><code>@OneToOne(mappedBy = &quot;user&quot;, cascade = CascadeType.ALL, fetch = FetchType.LAZY, optional = true)</code>에서 <code>optional</code> 제거</li>
<li>Point에서 User를 참조하는 쪽도 <code>FetchType.LAZY</code>를 명시적으로 지정 (방어적 코딩)</li>
</ol>
<p><strong>결과: 두 방법 모두 실패</strong> ❌</p>
<h3 id="최종-해결-양방향-관계-제거">최종 해결: 양방향 관계 제거</h3>
<p>현재 상태에서 가장 수정이 적은 방법들로도 해결되지 않아, <strong>양방향 관계를 단방향으로 변경</strong>하기로 결정했습니다.</p>
<p>포인트를 유저 안에 넣을지, 유저와 포인트를 분리할지 설계 시점에서 정확하게 결정하지 못한 것이 근본 원인이었습니다. 중간에 방향을 바꾸려다 보니 불완전한 구조가 되었고, 이것이 예상치 못한 버그로 이어졌습니다.</p>
<p><strong>최종 결정: User와 Point를 명확하게 분리하는 단방향 관계로 재설계</strong></p>
<h2 id="회고와-배운-점">회고와 배운 점</h2>
<ol>
<li><strong>JPA 1차 캐시의 우선순위</strong>: <code>SELECT FOR UPDATE</code>보다 1차 캐시가 먼저 동작한다는 것을 경험으로 배웠습니다</li>
<li><strong>@OneToOne 양방향 관계의 함정</strong>: LAZY로 설정해도 Hibernate가 EAGER로 동작할 수 있다는 것을 알게 되었습니다</li>
<li><strong>트랜잭션 격리 수준</strong>: MySQL의 Repeatable Read와 MVCC에 대한 이해도가 높아졌습니다</li>
<li><strong>관계 설정의 중요성</strong>: 양방향 관계가 정말 필요한지, 단방향으로도 충분하지 않은지 신중하게 고려해야 합니다</li>
</ol>
<p>발생한 문제를 해결하면서, Repeatable Read에 대해 알아보고, 각 DB별 기본 트랜잭션 격리에 대해서 학습하니 각 특성에 대해 더 잘 이해할 수 있었습니다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[🚀 DDD 기반 주문 생성 프로세스 설계: Facade와 도메인 서비스 분리 여정]]></title>
            <link>https://velog.io/@be_zion/DDD-%EA%B8%B0%EB%B0%98-%EC%A3%BC%EB%AC%B8-%EC%83%9D%EC%84%B1-%ED%94%84%EB%A1%9C%EC%84%B8%EC%8A%A4-%EC%84%A4%EA%B3%84-Facade%EC%99%80-%EB%8F%84%EB%A9%94%EC%9D%B8-%EC%84%9C%EB%B9%84%EC%8A%A4-%EB%B6%84%EB%A6%AC-%EC%97%AC%EC%A0%95</link>
            <guid>https://velog.io/@be_zion/DDD-%EA%B8%B0%EB%B0%98-%EC%A3%BC%EB%AC%B8-%EC%83%9D%EC%84%B1-%ED%94%84%EB%A1%9C%EC%84%B8%EC%8A%A4-%EC%84%A4%EA%B3%84-Facade%EC%99%80-%EB%8F%84%EB%A9%94%EC%9D%B8-%EC%84%9C%EB%B9%84%EC%8A%A4-%EB%B6%84%EB%A6%AC-%EC%97%AC%EC%A0%95</guid>
            <pubDate>Thu, 13 Nov 2025 17:25:20 GMT</pubDate>
            <description><![CDATA[<blockquote>
<p>주문 생성 트랜잭션을 깔끔하고 유지보수하기 쉬운 코드로 만들기 위해, 기능과 책임을 쪼개고 합치는 여정의 기록입니다. 이 과정에서 <strong>응용 서비스(Facade)</strong>와 도메인 서비스의 경계를 명확히 했습니다.</p>
</blockquote>
<h2 id="1-🔍-초기-분석-주문-트랜잭션의-4단계">1. 🔍 초기 분석: 주문 트랜잭션의 4단계</h2>
<blockquote>
<p>구현해야하는 기능을 문장으로, 
&quot;주문자가 / 재고가 있는 상품을 / 충분한 포인트로 결재하여 / 주문을 생성한다.&quot;</p>
</blockquote>
<ol>
<li><p>문장에서 필요한 도메인(대상)은?
1) 사용자
2) 상품(주문에 필요한 자원)
3) 포인트(주문에 필요한 자원)
4) 주문</p>
</li>
<li><p>어떤 순서로 진행되어야 하는가?
1) 주문에 필요한 도메인이 진짜 있는가?
2) 주문에 필요한 자원은 확보하였는가?
3) 자원을 사용한다.
4) 주문을 생성한다.</p>
</li>
</ol>
<h2 id="📋-주문-생성-프로세스-단계-ddd-관점">📋 주문 생성 프로세스 단계 (DDD 관점)</h2>
<table>
<thead>
<tr>
<th align="left">순서</th>
<th align="left">명칭</th>
<th align="left">핵심 행위</th>
<th align="left">관련 도메인 엔티티</th>
</tr>
</thead>
<tbody><tr>
<td align="left"><strong>1</strong></td>
<td align="left"><strong>확인 (Active)</strong></td>
<td align="left">사용자, 상품, 포인트가 <strong>존재</strong>하는가?</td>
<td align="left"><code>User</code>, <code>Product</code>, <code>Point</code></td>
</tr>
<tr>
<td align="left"><strong>2</strong></td>
<td align="left"><strong>검증 (Validate)</strong></td>
<td align="left">재고/잔액 <strong>조건</strong>이 충분한가?</td>
<td align="left"><code>Product</code>, <code>Point</code></td>
</tr>
<tr>
<td align="left"><strong>3</strong></td>
<td align="left"><strong>실행 (Execute)</strong></td>
<td align="left">재고 차감, 포인트 차감 <strong>(상태 변경)</strong></td>
<td align="left"><code>Product</code>, <code>Point</code></td>
</tr>
<tr>
<td align="left"><strong>4</strong></td>
<td align="left"><strong>생성 (Create)</strong></td>
<td align="left">최종 주문 객체 <strong>생성 및 저장</strong></td>
<td align="left"><code>Order</code></td>
</tr>
</tbody></table>
<h3 id="11-확인-도메인-정보-조회-read-only">1.1. 확인: 도메인 정보 조회 (Read-Only)</h3>
<p>주문 생성이 가능한 활성 상태인지 확인합니다.</p>
<ul>
<li>userService.getActiveUser(Long userId)</li>
<li>productService.getExistProducts(List&lt;Long&gt; productIds)</li>
<li>pointService.getAvailablePoints(Long userId) (포인트는 경우에 따라 필수X)</li>
</ul>
<h3 id="12-검증--실행-필요한-데이터-정의">1.2. 검증 &amp; 실행: 필요한 데이터 정의</h3>
<p>가장 복잡한 재고/포인트의 검증 및 차감은 다음 데이터를 필요로 합니다.</p>
<table>
<thead>
<tr>
<th align="left">자원</th>
<th align="left">현재 상태 (도메인)</th>
<th align="left">필요 수량/금액 (요청)</th>
</tr>
</thead>
<tbody><tr>
<td align="left">재고</td>
<td align="left">List&lt;Product&gt; (현재 재고)</td>
<td align="left">Map&lt;Long, Long&gt; (상품 ID → 수량)</td>
</tr>
<tr>
<td align="left">포인트</td>
<td align="left">Point 또는 User</td>
<td align="left">SUM(상품 가격 x 수량)</td>
</tr>
</tbody></table>
<h3 id="13-실행">1.3. 실행</h3>
<p>전 단계의 이름과 특징을 적어보자</p>
<p>1) 확인</p>
<ul>
<li>유저 정보 (DB 조회)</li>
<li>상품 정보 (DB 조회)</li>
<li>포인트 정보 (DB 조회)</li>
</ul>
<pre><code>userService.getActiveUser(Long `사용자ID`)
productService.getExistProducts(List &lt;Long\&gt; `상품IDs`)
pointService.getAvailablePoints(Long `사용자ID`)
</code></pre><p>2) 재고차감</p>
<ul>
<li>필요한 재고 검증</li>
<li>재고차감(트랜잭션)<pre><code>verifyProductStock(List &lt;Product\&gt; `현재재고`, List&lt;Long 상품ID ,Long 수량\&gt; `필요한 재고`)
deductProductStock(List&lt;Product\&gt; `현재재고`, List&lt;Long 상품ID,Long 수량\&gt; `필요한 재고`)
</code></pre></li>
</ul>
<pre><code>3) 포인트차감
- 충분한 포인트 검증
- 포인트 차감(트랜잭션)</code></pre><p>verifyPointBalance(Point <code>현재 포인트</code>, <code>? 필요한 포인트</code>)
useUserPoint(Point <code>현재 포인트</code>, <code>? 필요한 포인트</code>)</p>
<pre><code>4) 주문서 생성(트랜잭션)</code></pre><p>createOrder(Order)</p>
<pre><code>
## 2. 🧱 설계 시도와 핵심 결정

### 결정 1: DB 조회와 트랜잭션이 일어나는 기능은 Application service, 그 외는 Domain service


### 결정 2: 검증과 차감은 한번에
verify(검증) 후 deduct(차감) 을 분리하는 대신, 하나의 도메인 서비스 내에서 **검증과 상태 변경(차감)을 하나의 행위로 통합**했습니다. 입력값이 동일하며, 리스트 확인 및 데이터 수정으로 중복 조회가 일어나기 때문에 이를 방지하기 위해서 입니다.

### 결정 3: Map&lt;Long, Long&gt;으로 데이터 표준화

```java
public class OrderCreateV1Dto {
  public record OrderItemRequest(long productId, long quantity) {
  }
  public record OrderRequest(List&lt;OrderItemRequest&gt; items) {
  }
}</code></pre><p>재고데이터 List&lt;Product&gt; 와 요청받은 List&lt;OrderItemRequest&gt; 주문내역을 비교하기 위해서, List&lt;OrderItemRequest&gt; 를 중복을 제거한 Map&lt;Long,Long&gt; 형식으로 변경하여 productId를 키값으로 수량검색이 용이하도록 했습니다.</p>
<pre><code class="language-java">public record CreateOrderCommand(Long userId, Map&lt;Long, Long&gt; orderItemInfo) {
  public record ItemCommand(Long productId, Map&lt;Long, Long&gt; quantity) {
  }
}</code></pre>
<p>지금까지의 결정을 반환 타입까지 포함해서 적어보면,</p>
<pre><code>UserService.getActiveUser(Long userId)-&gt;User
ProductService.getExistProducts(List&lt;Long&gt;)-&gt;List&lt;Product&gt;
PointService.getAvailablePoints(Long userId)-&gt;Point

verifyProductStock(List&lt;Product&gt; 현재재고, Map&lt;Long 상품ID ,Long 수량&gt; 필요한재고)-&gt; `재고를 차감한 List&lt;Product&gt;`
deductProductStock(List&lt;Product&gt; 현재재고, Map&lt;Long 상품ID,Long 수량&gt; 필요한 재고)

verifyPointBalance(Point 현재 포인트, List&lt;Product&gt; 가격,Map&lt;Long,Long&gt; 수량) -&gt; `사용 포인트를 차감한 Point`
saveUserPoint(Point 현재 포인트, `필요한 포인트`)

createOrder(Order)-&gt; Order</code></pre><h3 id="결정-4-verifypointbalance에서-차감된-포인트가-아닌-총-가격를-반환하자">결정 4: verifyPointBalance에서 차감된 포인트가 아닌 <code>총 가격</code>를 반환하자.</h3>
<h4 id="시도-1-verifypointbalance-에서-포인트를-차감하고-잔액-point를-반환하자">시도 1: verifyPointBalance 에서 포인트를 차감하고 잔액 Point를 반환하자?</h4>
<p>verifyPointBalance으로( Point, List&lt;Product&gt;, Map&lt;Long,Long&gt;)을 보내고, Point 에서 총가격을 뺀 남은 Point 를 리턴하고 그대로 Point 를 저장하자.</p>
<h4 id="포인트-차감후-주문-생성에-필요한-데이터는">포인트 차감후, 주문 생성에 필요한 데이터는?</h4>
<ul>
<li><p>Order: 유저ID, <code>총 가격</code></p>
</li>
<li><p>OrderItem: 상품ID, 수량, 단가</p>
<p>총가격을 비교하는 verifyPointBalance에서 <strong>차감된 잔액 포인트</strong>가 리턴된다. 따라서 어디서도 총가격을 저장하고 있지 않아, 검증 이후 총 가격을 다시 구해야 한다. verifyPointBalance 에서 차감된 포인트가 아닌 <strong>총가격을 반환</strong>하자.</p>
</li>
</ul>
<p>Order Facade에서 정리하면,</p>
<pre><code class="language-java">OrderFacade {
  public OrderInfo createOrder(CreateOrderCommand command) {
  User user = UserService.getActiveUser(Long userId);
  List&lt;Product&gt; products = ProductService.getExistProducts(List&lt;Long&gt; productIds);
  Point point = PointService.getAvailablePoints(Long userId);

  List&lt;Product&gt; deductedProducts = OrderPreparer.verifyProductStock(List&lt;Product&gt; products, List&lt;Long 상품ID ,Long 수량&gt; command.getOrderItemCommand);
  deductProductStock(deductedProducts)

  BigDecimal requestedPoints = OrderPreparer.verifyPointBalance(Point 현재 포인트, List&lt;Product&gt; 가격,Map&lt;Long,Long&gt; 수량);
  saveUserPoint(Point 현재 포인트 - requestedPoints)

  Order order = createOrder(User,List&lt;Product&gt;,Point)
  return OrderInfo.from(savedOrder);
  }</code></pre>
<h3 id="결정-5-주문-생성시-총가격-대신--maplong-상품id-long-수량">결정 5: 주문 생성시, <code>총가격</code> 대신  Map&lt;Long 상품ID ,Long 수량&gt;</h3>
<p>  주문 상세 데이터 생성을 위해 상품의 단가,수량을 알려면 Map&lt;Long 상품ID ,Long 수량&gt; 를 넣어야 하고, List<OrderItem> 만들면서 총가격을 계산할수 있기 때문에, <code>총가격</code> 대신  Map&lt;Long 상품ID ,Long 수량&gt; 을 넣기로 했습니다.</p>
<h3 id="결정-6-포인트-저장-대신-포인트-차감">결정 6: 포인트 저장 대신 포인트 차감</h3>
<p>  차감한 포인트를 저장하려다가, &quot;포인트를 저장한다&quot; 대신 &quot;포인트를 차감한다&quot; 의 의미가 <strong>행동의 의미</strong>를 잘 담고 있는것 같아서, verifyPointBalance 대신, <strong>총가격을 조회하는 함수</strong>로 바꾸기로 했습니다.</p>
<pre><code class="language-java">@Transactional
  public OrderInfo createOrder(CreateOrderCommand command) {
    Map&lt;Long, Long&gt; quantityMap = command.orderItemInfo();
    User user = userService.getActiveUser(command.userId());
    List&lt;Product&gt; productList = productService.getExistingProducts(quantityMap.keySet());
    Point point = pointService.getAvailablePoints(user.getId());

    List&lt;Product&gt; deductedProducts = OrderPreparer.verifyProductStock(productList, quantityMap);
    productService.save(deductedProducts);

    BigDecimal totalAmt = OrderPreparer.getTotalAmt(point, productList, quantityMap);
    pointService.use(user, totalAmt);

    Order savedOrder = createOrderService.save(user, productList, quantityMap);

    return OrderInfo.from(savedOrder);
  }</code></pre>
<h3 id="결정-7verify검증-후-deduct실행을-합치자">결정 7:verify(검증) 후 **deduct(실행)을 합치자</h3>
<p>같은 파라미터를 쓰고 있는 재고검증 및 차감 과 포인트검증 및 차감을 productStockService,userPointService로 합쳤습니다.</p>
<pre><code class="language-java">@Transactional
  public OrderInfo createOrder(CreateOrderCommand command) {
    Map&lt;Long, Long&gt; quantityMap = command.orderItemInfo();
    User user = userService.getActiveUser(command.userId());
    List&lt;Product&gt; productList = productService.getExistingProducts(quantityMap.keySet());

    productStockService.deduct(productList, quantityMap);
    userPointService.use(user, productList, quantityMap);

    Order savedOrder = createOrderService.save(user, productList, quantityMap);
    return OrderInfo.from(savedOrder);
  }</code></pre>
<h2 id="💡-정리-및-결론">💡 정리 및 결론</h2>
<p>처음 복잡하게 느껴졌던 주문 생성 로직이, <strong>책임(재고, 포인트)을 기준으로 도메인 서비스(Manager)</strong>를 분석하고, 최종적으로 간결하고 이해하기 쉬운 형태의 OrderFacade를 만들어내었습니다. 추가로,초기 모델의 존재여부를 확인하는 부분도 한번 합쳐보면 더 보기좋은 코드가 될것 같은데 이렇게 계속 합쳐도 되는지, 의문이 들기도 했습니다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[Mermaid로 완성하는 도메인 설계: 클래스 다이어그램 점검]]></title>
            <link>https://velog.io/@be_zion/Mermaid%EB%A1%9C-%EC%99%84%EC%84%B1%ED%95%98%EB%8A%94-%EB%8F%84%EB%A9%94%EC%9D%B8-%EC%84%A4%EA%B3%84-%ED%81%B4%EB%9E%98%EC%8A%A4-%EB%8B%A4%EC%9D%B4%EC%96%B4%EA%B7%B8%EB%9E%A8-%EC%A0%90%EA%B2%80</link>
            <guid>https://velog.io/@be_zion/Mermaid%EB%A1%9C-%EC%99%84%EC%84%B1%ED%95%98%EB%8A%94-%EB%8F%84%EB%A9%94%EC%9D%B8-%EC%84%A4%EA%B3%84-%ED%81%B4%EB%9E%98%EC%8A%A4-%EB%8B%A4%EC%9D%B4%EC%96%B4%EA%B7%B8%EB%9E%A8-%EC%A0%90%EA%B2%80</guid>
            <pubDate>Thu, 06 Nov 2025 17:01:16 GMT</pubDate>
            <description><![CDATA[<blockquote>
<p>간단하게 그려진 초기 클래스 다이어그램을 점검하고, 핵심 요소를 반영하며 최종 문서를 완성하는 과정을 정리합니다.</p>
</blockquote>
<p><img src="https://velog.velcdn.com/images/be_zion/post/29a6cde9-c3e8-4b16-ad02-3d00295fa36d/image.png" alt=""></p>
<h2 id="1-🔑-접근-범위visibility-및-메서드-추가">1. 🔑 접근 범위(Visibility) 및 메서드 추가</h2>
<p>Mermaid 문법을 활용하여 클래스 멤버(필드, 메서드)의 접근 범위를 명확히 표현했습니다.</p>
<table>
<thead>
<tr>
<th>기호</th>
<th>의미</th>
<th>설명</th>
</tr>
</thead>
<tbody><tr>
<td><code>+</code></td>
<td>Public</td>
<td>공개 멤버, 어디서든 접근 가능</td>
</tr>
<tr>
<td><code>-</code></td>
<td>Private</td>
<td>비공개 멤버, 클래스 내부에서만 접근 가능</td>
</tr>
<tr>
<td><code>#</code></td>
<td>Protected</td>
<td>상속 관계 내에서 접근 가능</td>
</tr>
<tr>
<td><code>~</code></td>
<td>Package / Internal</td>
<td>같은 패키지 내에서 접근 가능</td>
</tr>
</tbody></table>
<h5 id="수정된-클래스-다이어그램1">수정된 클래스 다이어그램1</h5>
<p><img src="https://velog.velcdn.com/images/be_zion/post/fb7010b7-b44a-4702-bc1b-781307702369/image.png" alt=""></p>
<h2 id="2-🧱-vovalue-object와-enum-추가">2. 🧱 VO(Value Object)와 Enum 추가</h2>
<p>도메인 설계의 완성도를 높이기 위해 식별자가 없는 VO와 상태를 정의하는 Enum을 추가했습니다.</p>
<h3 id="1-point-vo-value-object">1) Point VO (Value Object)</h3>
<p>지난주 User 엔티티와 별도 테이블로 관리하던 Point 데이터를 VO로 전환하고 User 클래스에 포함시켰습니다. 
평소 유저 테이블은 ERP에서 전송받는 데이터로 수정이 일어나지 않는 테이블이다보니 수정이 반복되는 Point가 포함되는 것이 어색해보여서 분리하게 되었었습니다. 하지만 현재 Point 테이블에 userId, pointAmount 외에 추가로 들어갈 정보들이나 요구사항이 없기 때문에 테이블로 분리하는 것은 나중 일이라는 것을 이해하게 되었습니다.</p>
<h3 id="2-orderstatus-enum">2) OrderStatus Enum</h3>
<p>도메인 내에서 통일된 언어(유비쿼터스 언어)로 정의한 주문 상태를 Enum으로 추가했습니다.</p>
<h5 id="수정된-클래스-다이어그램2">수정된 클래스 다이어그램2</h5>
<p><img src="https://velog.velcdn.com/images/be_zion/post/5edce14d-2710-478a-ac90-e53f35a67959/image.png" alt=""></p>
<h2 id="3-➡️-관계-및-방향-명확화-mermaid-화살표-사용">3. ➡️ 관계 및 방향 명확화 (Mermaid 화살표 사용)</h2>
<p>Mermaid가 지원하는 UML 관계 표현을 사용하여 도메인 간의 결합도와 생명주기를 명확하게 정의했습니다.</p>
<p><img src="https://velog.velcdn.com/images/be_zion/post/d5d122ce-5489-4f71-bbfb-f6fc87aebc51/image.png" alt=""></p>
<p>Mermaid에서 표현할수 있는 화살표중 아래 <strong>상속,연관,집합,합성</strong> 4가지의 관계를 알아보겠습니다.</p>
<h3 id="1-inheritance상속">1) Inheritance(상속)</h3>
<p><strong>자식 --|&gt; 부모</strong>
하나의 클래스가 다른 클래스의 특성과 동작을 <strong>물려받는</strong> 관계입니다. 
예) 사과는 과일이다.</p>
<h3 id="2-association연관">2) Association(연관)</h3>
<p><strong>참조하는 --&gt; 참조되는</strong>
독립적인 객체 간의 느슨한 관계로, &quot;has-a&quot; 관계입니다. 객체들은 <strong>독자적인 라이프사이클</strong>을 가지며 서로 소유하지 않고 사용할 수 있습니다. 
예) 선생님과 학생 관계</p>
<h3 id="3-aggregation집합">3) Aggregation(집합)</h3>
<p><strong>전체 o-- 부분</strong>
특별한 형태의 Association으로, 소유권을 가진 <strong>약한 결합</strong> 관계입니다. 포함된 객체는 독립적으로 존재할 수 있고, 부모 객체가 사라져도 자식 객체는 남습니다. 
예) 부서와 직원 관계</p>
<h3 id="4-composition합성">4) Composition(합성)</h3>
<p><strong>전체 *-- 부분</strong>
Aggregation보다 <strong>강한 결합</strong> 관계로, 부모 객체가 소멸되면 자식 객체도 함께 소멸합니다. 
예) 집과 방 관계</p>
<h5 id="처음-정의한-관계코드">처음 정의한 관계코드</h5>
<pre><code>Order --&gt; User  
Like --&gt; Product  
Like --&gt; User

Order --&gt; OrderItem  
Product --&gt; Brand
OrderItem --&gt; Product  
User *--Point : has 
OrderStatus &lt;-- Order : has</code></pre><p>1) 강한 결합으로 Order가 사라지면 OrderItem은 독립적인 의미가 소멸하게 때문에 OrderItem 과 Order 를 <strong>&quot;합성&quot; 관계로 수정</strong>했습니다.
2) VO와 Class 의 관계는 &quot;<strong>contains</strong>&quot;, Enum과 Class 의 관계는 &quot;<strong>uses</strong>&quot;, Order Class 와 OrderItem의 합성 관계는 &quot;<strong>owns</strong>&quot; 로 눈에 띄도록 수정했습니다.</p>
<h5 id="수정된-클래스-다이어그램3">수정된 클래스 다이어그램3</h5>
<p><img src="https://velog.velcdn.com/images/be_zion/post/e338ef7f-479d-44a9-8d06-b1098bd8af01/image.png" alt=""></p>
<h2 id="4-✅-클래스-구조가-도메인-설계를-잘-표현하고-있는가">4. ✅ 클래스 구조가 도메인 설계를 잘 표현하고 있는가?</h2>
<p>체크리스트 중 있었던 &quot;도메인 설계를 잘 표현하는 클래스 구조&quot;란 무엇일까?
perplexity 검색에 검색하니 핵심 요소들 5가지를 알려주었습니다.이를 보고 현재 반영하면 좋을 부분들을 찾다가 생략되었던 <strong>엔티티의 고유 식별자와 생성자</strong>를 추가하기로 했습니다.</p>
<ol>
<li>엔티티(Entity)</li>
</ol>
<ul>
<li>고유 식별자(ID)를 갖고 생명주기 관리</li>
<li>도메인 규칙과 상태를 가진 핵심 객체</li>
</ul>
<ol start="2">
<li>밸류 오브젝트(Value Object, VO)</li>
</ol>
<ul>
<li>식별자가 없으며 불변의 특성을 가짐</li>
<li>엔티티의 속성으로 포함되어 의미 부여</li>
</ul>
<ol start="3">
<li>애그리게이트(Aggregate)</li>
</ol>
<ul>
<li>하나 이상의 엔티티와 밸류 오브젝트 묶음</li>
<li>일관성 경계(Boundary)를 정의, 외부에서 일괄 접근</li>
<li>루트 엔티티가 존재하여 외부 참조 포인트 역할</li>
</ul>
<h5 id="최종-클래스-다이어그램를-완성했습니다">최종 클래스 다이어그램를 완성했습니다.</h5>
<p><img src="https://velog.velcdn.com/images/be_zion/post/b714813e-45aa-4099-907d-9bbe1e65f247/image.png" alt=""></p>
<h2 id="5🌟-클래스-다이어그램-학습-후기-정리">5.🌟 클래스 다이어그램 학습 후기 정리</h2>
<p>단계별 수정 과정을 통해 클래스 다이어그램의 설계를 구체화하고 객체 간 관계 표현에 대한 이해도를 심화할 수 있었습니다. 특히 클래스 다이어그램 작성 중 가장 어려움을 느꼈던 <strong>연관/집합/합성 관계간 구분과 화살표의 방향</strong>을 확인하면서 스스로 명확히 정리할수 있었습니다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[📚 TDD(테스트 주도 개발) 학습 및 `@SpringBootTest` 빌드 속도 저하 트러블 슈팅]]></title>
            <link>https://velog.io/@be_zion/TDD</link>
            <guid>https://velog.io/@be_zion/TDD</guid>
            <pubDate>Fri, 31 Oct 2025 02:50:19 GMT</pubDate>
            <description><![CDATA[<p>테스트 주도 개발(Test Driven Development, TDD)은 기능 구현에 앞서 테스트 코드를 먼저 작성하는 소프트웨어 개발 방법론입니다. 테스트 케이스를 먼저 작성하고 이를 통과시킬 최소한의 코드를 작성하는 과정을 반복하며 개발을 진행합니다.</p>
<hr>
<h2 id="🙋♀️-tdd에-대한-경험과-의문점">🙋‍♀️ TDD에 대한 경험과 의문점</h2>
<p>3년 전 Spring 공부 중 테스트 코드를 접했고, 이후 사이드 프로젝트에서 PR 템플릿에 맞추어 단순 값을 대충 바꿔 넣는 정도로만 사용했습니다. 리뷰 없이 &#39;내 맘대로&#39; 작성했기에, TDD 방식은 알았지만 테스트 코드 작성이 <strong>단순 반복적이고 재미없는 일</strong>로 느껴졌습니다. 특히 <strong>&#39;테스트 코드부터 개발&#39;</strong>한다는 방식이 현실적으로 상상되지 않았습니다.</p>
<h3 id="tdd-의문점과-해결">TDD 의문점과 해결</h3>
<h4 id="q1-테스트-코드를-짜는데-시간이-걸린다">Q1. 테스트 코드를 짜는데 시간이 걸린다.</h4>
<ul>
<li>테스트 코드에 들이는 시간이 아예 없을수는 없다. 하지만 TDD 방식으로 검증과 개발을 동시에 진행한다면, 개발만 하다가 계획을 변경하고, 사이드 이펙트를 경험하는 것보다 빠를수밖에 없다.</li>
</ul>
<h4 id="q2-테스트-코드부터-개발하는-상황이-상상이-되지-않는다">Q2. 테스트 코드부터 개발하는 상황이 상상이 되지 않는다.</h4>
<ul>
<li>테이블 설계를 먼저 하는 것에 익숙하다 보니, 전혀 그려지지 않는 일이었다. 하지만 필요한 API요청들을 E2E로 먼저 작성하고, 세세한 로직들을 만들어 가는 과정, 객체의 특성을 녹이는 과정이 이제는 이해되기 시작했다.</li>
</ul>
<h4 id="q3-단위통합e2e-각각-어떤-검증을-해야하나요">Q3. 단위/통합/E2E 각각 어떤 검증을 해야하나요.</h4>
<h4 id="단위통합-테스트-주로-비즈니스-로직-및-객체-상태-검증">단위/통합 테스트 (주로 비즈니스 로직 및 객체 상태 검증)</h4>
<pre><code class="language-java">//DB 저장 메서드가 호출되는지 확인
verify(repository).save(any())
//반환된 객체가 null이 아닌지
assertNotNull(result)
//반환 객체의 핵심 필드가 정확한지 
assertEquals(expectedValue, result.getField())
//특정 필드 값이 비즈니스 로직에 맞는지
assertTrue(result.isValid())
//삭제 메서드가 정확히 1번 호출되었는지 확인
verify(repository, times(1)).deleteById(id)
//삭제 후 조회 시 예외가 발생하는지 확인
assertThrows(NotFoundException.class, () -&gt; service.read(id))</code></pre>
<h4 id="e2e-테스트-주로-시스템-응답-및-영속성-검증">E2E 테스트 (주로 시스템 응답 및 영속성 검증)</h4>
<pre><code class="language-java">//201 응답 확인
assertEquals(HttpStatus.CREATED, response.getStatusCode())
//DB에서 직접 조회하여 객체가 존재하는지 확인.
assertNotNull(repository.findById(id).orElse(null))
//응답 JSON 필드 값 검증
assertEquals(expectedValue, response.getBody().getFieldName())
//DB 조회 결과 Optional이 비어있는지 확인
assertTrue(repository.findById(id).isEmpty())</code></pre>
<h4 id="q4-운영db에서-테스트-코드가-실행될수도-있다던데">Q4. 운영DB에서 테스트 코드가 실행될수도 있다던데..</h4>
<ul>
<li>이 위험도 때문에 테스트 코드 작성이 주저되기도 했다. 사실 내가 설정을 잘 해놓으면 되는 일인데, 혹여나 하는 걱정이 있었다.</li>
</ul>
<hr>
<h2 id="🐢-springboottest-빌드-속도-저하-트러블-슈팅">🐢 @SpringBootTest 빌드 속도 저하 트러블 슈팅</h2>
<p><img src="https://velog.velcdn.com/images/be_zion/post/7a657fb3-3fce-4b39-ab26-dc95cf5a76a2/image.png" alt=""></p>
<p>E2E 및 통합 테스트 개발 중 빌드 시간이 5분 이상 소요되는 현상이 발생했습니다. 원인을 찾기 시작했습니다.</p>
<h3 id="1-spy와-spybean-차이점-검토-원인-아님">1) @Spy와 @SpyBean 차이점 검토 (원인 아님)</h3>
<table>
<thead>
<tr>
<th align="left">특징</th>
<th align="left"><code>@Spy</code> (Mockito)</th>
<th align="left"><code>@SpyBean</code> (Spring Boot Test)</th>
</tr>
</thead>
<tbody><tr>
<td align="left"><strong>프레임워크</strong></td>
<td align="left"><strong>Mockito</strong></td>
<td align="left"><strong>Spring Boot Test</strong></td>
</tr>
<tr>
<td align="left"><strong>사용 환경</strong></td>
<td align="left"><strong>단위 테스트 (Unit Test)</strong></td>
<td align="left"><strong>통합 테스트 (Integration Test)</strong></td>
</tr>
<tr>
<td align="left"><strong>객체 대상</strong></td>
<td align="left">개발자가 <code>new</code>로 생성한 <strong>일반 인스턴스</strong></td>
<td align="left">Spring 컨텍스트 내의 <strong>기존 Bean</strong></td>
</tr>
<tr>
<td align="left"><strong>주입 방식</strong></td>
<td align="left"><code>@InjectMocks</code>를 통한 <strong>수동 주입</strong></td>
<td align="left"><code>@Autowired</code>를 통한 Spring의 <strong>자동 주입</strong></td>
</tr>
<tr>
<td align="left"><strong>영향 범위</strong></td>
<td align="left">테스트 메서드/클래스 내부</td>
<td align="left"><strong>Spring Application Context 전체</strong></td>
</tr>
</tbody></table>
<p>@SpyBean 사용 시점과 속도 저하 시점이 비슷하여 의심했지만, 이는 근본 원인이 아님을 확인했습니다.</p>
<h3 id="2-testcontainers-환경-문제-발견-주요-원인">2) Testcontainers 환경 문제 발견 (주요 원인)</h3>
<p>Docker와 Testcontainers가 띄워지는 로그를 통해 Docker 관련 문제임을 추정했습니다.</p>
<p>✅ 해결 과정</p>
<p>1) Docker Desktop 업데이트
2) Testcontainers 아이콘을 발견하고 실행 중인 컨테이너를 모두 삭제했습니다.
3) Containers running locally &gt; Embedded Runtime을 desktop-linux로 변경했습니다.
4) 설정 변경 후 테스트 실행 속도가 현저히 빨라졌습니다.</p>
<h3 id="🔎-embedded-runtime-vs-desktop-linux-docker-데몬">🔎 Embedded Runtime vs. Desktop Linux Docker 데몬</h3>
<p>성능 및 오버헤드 (M1/Mac 환경의 경우 더 중요)
<strong>Embedded Runtime (리눅스 네이티브)</strong>: Testcontainers는 도커 API를 사용하여 컨테이너를 직접 실행합니다. 파일 시스템 접근이 호스트 리눅스 커널 수준에서 이루어지기 때문에 오버헤드가 거의 없어 빌드 및 테스트 속도가 가장 빠릅니다.</p>
<p><strong>Docker Desktop (Mac/Windows)</strong>: Mac이나 Windows에서 Docker Desktop을 사용하면, 도커 데몬은 항상 내부 VM 내에서 실행됩니다. 특히 M1/M2/M3 Mac의 경우, 성능 개선이 이루어졌지만 여전히 네이티브 리눅스에 비하면 약간의 파일 I/O 오버헤드가 발생할 수 있습니다.</p>
<p>제 환경(M1)에서는 예상과 달리 Embedded Runtime에서 더 오래 걸리는 현상을 경험했습니다. 이부분은 좀더 공부해봐야 직접적인 문제를 알수 있을것 같습니다.
<img src="https://velog.velcdn.com/images/be_zion/post/835d5bf6-3c51-4b98-8ea7-e407ce5c389d/image.png" alt=""></p>
<h2 id="💡-tdd-관련-학습-내용-정리">💡 TDD 관련 학습 내용 정리</h2>
<p>이번 경험을 통해 알게 된 주요 개념과 도구를 정리했습니다. 추후 적합한 도구를 활용해 다양한 테스트 코드를 경험해 보려고 합니다.</p>
<blockquote>
<p>🟢 <strong>테스트 더블 (Test Double)</strong></p>
<ul>
<li><input disabled="" type="checkbox"> <strong>Dummy</strong> – 테스트에 필요하지만 실제로 사용되지 않는 더미 객체  </li>
<li><input disabled="" type="checkbox"> <strong>Stub</strong> – 특정 메서드 호출 시 미리 정의된 값 반환  </li>
<li><input disabled="" type="checkbox"> <strong>Fake</strong> – 실제 구현과 비슷하지만 단순화된 객체 (예: In-memory DB)  </li>
<li><input disabled="" type="checkbox"> <strong>Mock</strong> – 호출 여부, 인자 등을 검증할 수 있는 객체  </li>
<li><input disabled="" type="checkbox"> <strong>Spy</strong> – 실제 객체를 그대로 사용하면서 일부 동작만 감시/검증</li>
</ul>
</blockquote>
<blockquote>
<p>🧪 <strong>E2E 테스트</strong></p>
<ul>
<li><input disabled="" type="checkbox"> <code>TestRestTemplate</code> – Spring Boot 서버 띄워 HTTP 요청/응답 검증</li>
<li><input disabled="" type="checkbox"> <code>WebTestClient</code> – WebFlux, 실제 HTTP 호출  </li>
<li><input disabled="" type="checkbox"> RestAssured – REST API E2E 테스트
🧠 <strong>통합 테스트</strong></li>
<li><input disabled="" type="checkbox"> <code>@SpringBootTest</code> – 전체 스프링 컨텍스트  </li>
<li><input disabled="" type="checkbox"> <code>TestContainers</code> – DB/Kafka/Redis 연동</li>
</ul>
</blockquote>
<blockquote>
<p>🧱 <strong>단위 테스트</strong></p>
<ul>
<li><input disabled="" type="checkbox"> <code>Repository</code> / <code>Service</code> / <code>Controller</code>  </li>
<li><input disabled="" type="checkbox"> <code>JUnit</code> / <code>Kotest</code>  </li>
<li><input disabled="" type="checkbox"> <code>Mock</code> / <code>Spy</code> (Mockito)  </li>
<li><input disabled="" type="checkbox"> <code>@ParameterizedTest</code> / <code>@ValueSource</code></li>
</ul>
</blockquote>
<blockquote>
<p>🔍 <strong>슬라이스 테스트</strong></p>
<ul>
<li><input disabled="" type="checkbox"> <code>@WebMvcTest</code> – Controller만, Service/Repo Mock  </li>
<li><input disabled="" type="checkbox"> <code>@DataJpaTest</code> – Repository/JPA  </li>
<li><input disabled="" type="checkbox"> <code>@RestClientTest</code> – RestTemplate/WebClient  </li>
<li><input disabled="" type="checkbox"> <code>@JsonTest</code> – JSON 직렬화/역직렬화  </li>
<li><input disabled="" type="checkbox"> <code>MockMvc</code> – 컨트롤러 테스트</li>
</ul>
</blockquote>
<blockquote>
<p>🎨 <strong>코드 포맷 / 스타일</strong></p>
<ul>
<li><input disabled="" type="checkbox"> <code>Spock</code> – BDD 스타일</li>
</ul>
</blockquote>
<blockquote>
<p>🗂 <strong>테스트 데이터</strong></p>
<ul>
<li><input disabled="" type="checkbox"> <code>Fixture Monkey</code> – 코드 기반 픽스처 생성</li>
<li><input disabled="" type="checkbox"> <code>DBRider</code> – DB 기반 픽스처 생성</li>
</ul>
</blockquote>
]]></description>
        </item>
        <item>
            <title><![CDATA[React Native New Architecture와 iOS 외부 앱 이미지 공유 시스템 구현기(Claude)]]></title>
            <link>https://velog.io/@be_zion/React-Native-New-Architecture%EC%99%80-iOS-%EC%99%B8%EB%B6%80-%EC%95%B1-%EC%9D%B4%EB%AF%B8%EC%A7%80-%EA%B3%B5%EC%9C%A0-%EC%8B%9C%EC%8A%A4%ED%85%9C-%EA%B5%AC%ED%98%84%EA%B8%B0Claude</link>
            <guid>https://velog.io/@be_zion/React-Native-New-Architecture%EC%99%80-iOS-%EC%99%B8%EB%B6%80-%EC%95%B1-%EC%9D%B4%EB%AF%B8%EC%A7%80-%EA%B3%B5%EC%9C%A0-%EC%8B%9C%EC%8A%A4%ED%85%9C-%EA%B5%AC%ED%98%84%EA%B8%B0Claude</guid>
            <pubDate>Fri, 12 Sep 2025 13:47:46 GMT</pubDate>
            <description><![CDATA[<h2 id="프로젝트-개요">프로젝트 개요</h2>
<p>  이전에 Android로 구현한 React Native 앱에서 외부 앱(사진, 카메라, 갤러리 등)에서 공유되는 이미지를 받아서 처리하는 기능을 iOS으로 개발하는 작업이었습니다.</p>
<h2 id="🔄-기술-스택-선택의-여정">🔄 기술 스택 선택의 여정</h2>
<h3 id="swift에서-objective-c로의-전환">Swift에서 Objective-C로의 전환</h3>
<p>  처음에는 Swift로 개발을 시작했습니다. 최신 언어이고 문법이 깔끔하며, iOS 개발에서 애플이 권장하는 언어이기 때문이었죠.</p>
<p>  하지만 Native Module 개발에서는 Objective-C를 강력히 권장하고 있었습니다:</p>
<h3 id="react-native가-objective-c를-권장하는-이유">React Native가 Objective-C를 권장하는 이유</h3>
<ol>
<li>TurboModule 지원: React Native의 새로운 아키텍처인 TurboModule은 Objective-C 기반으로 설계</li>
<li>코드 생성: react-native-codegen이 Objective-C 헤더 파일을 자동 생성</li>
<li>브릿지 안정성: JavaScript와 Native 간의 브릿지가 Objective-C에서 더 안정적</li>
<li>타입 안정성: C++ 백엔드와의 연동이 Objective-C에서 더 매끄러움</li>
</ol>
<h3 id="codegen-지원">CodeGen 지원</h3>
<p>  React Native 0.68+ 버전의 New Architecture를 지원하려면 CodeGen을 사용해야 했는데, 이 도구가 생성하는 인터페이스가 순수 <strong>Objective-C</strong>였습니다. Swift에서 이를 구현하려면 복잡한 브릿징 코드가 필요했지만, Objective-C에서는 아래 코드로 바로 구현 가능했습니다.</p>
<pre><code>  #import &quot;LPShareModuleSpec.h&quot;

  @interface LPShareModule : NativeLPShareModuleSpecBase 
  &lt;NativeLPShareModuleSpec&gt;
  @end

  @implementation LPShareModule
  @end</code></pre><h2 id="🔗-cross-platform-인터페이스-통일">🔗 Cross-Platform 인터페이스 통일</h2>
<h3 id="기존-android-모듈-활용">기존 Android 모듈 활용</h3>
<p>  가장 중요했던 것은 기존 Android용으로 만들어진 TypeScript 인터페이스를 그대로 활용하는 것이었습니다. 이미 Android 개발 시 만들어진 NativeLPShareModule.ts가 있었고, iOS에서도 동일한 인터페이스로 동작하도록 구현했습니다.</p>
<h4 id="기존-typescript-인터페이스">기존 TypeScript 인터페이스</h4>
<pre><code>// src/NativeLPShareModule.ts - 이미 Android용으로 구현됨
  import type {TurboModule} from &#39;react-native/Libraries/TurboModule/RCTExport&#39;;
  import {TurboModuleRegistry} from &#39;react-native&#39;;

  export interface SharedData {
    readonly type: string | null;
    readonly uri?: string;
    readonly uriList?: ReadonlyArray&lt;string&gt;;
  }

  export interface Spec extends TurboModule {
    sendSharedData(eventName: string, data: string): void;
    testMethod(): Promise&lt;string&gt;;
    getSharedData(): Promise&lt;SharedData&gt;;
  }

  export default TurboModuleRegistry.getEnforcing&lt;Spec&gt;(&#39;LPShareModule&#39;);</code></pre><p>이렇게 구현한 덕분에 React Native 코드에서는 플랫폼을 신경 쓰지 않고 동일하게 사용할 수 있었습니다:</p>
<h4 id="통합의-이점">통합의 이점</h4>
<ol>
<li>코드 재사용성: 상위 레벨 React Native 코드는 플랫폼 구분 없이 재사용</li>
<li>유지보수성: 인터페이스 변경 시 TypeScript 파일 하나만 수정</li>
<li>타입 안정성: TypeScript가 두 플랫폼 모두의 타입 검사</li>
<li>개발 효율성: 새로운 기능 추가 시 스펙 정의 후 각 플랫폼에서 구현만 하면 됨</li>
</ol>
<h2 id="🏗️-아키텍처-설계">🏗️ 아키텍처 설계</h2>
<p>  iOS에서 외부 앱 공유를 처리하는 방법은 Share Extension을 사용하는 것입니다. 전체 구조는 다음과 같습니다:</p>
<p>  외부 앱 → Share Extension → App Group → Main App → React Native</p>
<p>  주요 구성요소</p>
<ol>
<li>Share Extension: 외부 앱에서 공유된 이미지를 받는 진입점</li>
<li>App Group: Share Extension과 Main App 간의 데이터 공유</li>
<li>Native Module: React Native Bridge를 통한 데이터 전달</li>
</ol>
<h2 id="🔧-구현-과정">🔧 구현 과정</h2>
<h3 id="1-share-extension-생성">1. Share Extension 생성</h3>
<p>  먼저 Xcode에서 새로운 Share Extension Target을 생성했습니다.</p>
<h4 id="infoplist-설정">Info.plist 설정</h4>
<pre><code>  &lt;key&gt;NSExtensionActivationRule&lt;/key&gt;
  &lt;dict&gt;
      &lt;!-- 최대 10개 이미지 지원 --&gt;
      &lt;key&gt;NSExtensionActivationSupportsImageWithMaxCount&lt;/key&gt;
      &lt;integer&gt;10&lt;/integer&gt;

      &lt;!-- iOS 14+ 방식 --&gt;
      &lt;key&gt;NSExtensionActivationSupportsImage&lt;/key&gt;
      &lt;true/&gt;

      &lt;!-- iOS 13 이하 호환성 --&gt;
      &lt;key&gt;NSExtensionActivationSupportsAttachmentsWithMatchingUTIs&lt;/key&gt;
      &lt;array&gt;
          &lt;string&gt;public.image&lt;/string&gt;
      &lt;/array&gt;
  &lt;/dict&gt;
</code></pre><h3 id="2-shareviewcontroller-구현">2. ShareViewController 구현</h3>
<p>  핵심 로직을 담은 ShareViewController:</p>
<pre><code>  - (void)handleSharedContent {
      NSExtensionContext *extensionContext = self.extensionContext;
      NSArray&lt;NSExtensionItem *&gt; *inputItems = extensionContext.inputItems;

      // 이미지 타입 첨부파일만 필터링
      NSMutableArray&lt;NSItemProvider *&gt; *imageAttachments = [[NSMutableArray
  alloc] init];
      for (NSExtensionItem *item in inputItems) {
          for (NSItemProvider *attachment in item.attachments) {
              if ([attachment
  hasItemConformingToTypeIdentifier:UTTypeImage.identifier]) {
                  [imageAttachments addObject:attachment];
              }
          }
      }

      if ([imageAttachments count] == 1) {
          // 단일 이미지 처리
          [self processImageAttachment:imageAttachments[0] completion:^(NSString
   *uri) {
              [self saveSharedDataToUserDefaults:@&quot;single&quot; uri:uri uriList:nil];
              [self openMainApp];
          }];
      } else {
          // 다중 이미지 처리
          [self processMultipleImageAttachments:imageAttachments
  completion:^(NSArray&lt;NSString *&gt; *uris) {
              [self saveSharedDataToUserDefaults:@&quot;multiple&quot;
  uri:uris.firstObject uriList:uris];
              [self openMainApp];
          }];
      }
  }</code></pre><h3 id="3-app-group을-통한-데이터-공유">3. App Group을 통한 데이터 공유</h3>
<p>  Share Extension과 Main App 간의 데이터 공유를 위해 App Group을 설정:</p>
<pre><code>  - (void)saveSharedDataToUserDefaults:(NSString *)type uri:(NSString *)uri
  uriList:(NSArray&lt;NSString *&gt; *)uriList {
      NSUserDefaults *groupUserDefaults = [[NSUserDefaults alloc]
          initWithSuiteName:@&quot;group.io.itmca.lifepuzzle&quot;];
      NSUserDefaults *standardUserDefaults = [NSUserDefaults
  standardUserDefaults];

      // Group UserDefaults에 우선 저장
      if (groupUserDefaults) {
          [groupUserDefaults setObject:type forKey:@&quot;SharedDataType&quot;];
          if (uri) [groupUserDefaults setObject:uri forKey:@&quot;SharedDataURI&quot;];
          if (uriList) [groupUserDefaults setObject:uriList
  forKey:@&quot;SharedDataURIList&quot;];
          [groupUserDefaults synchronize];
      }

      // Standard UserDefaults에 백업 저장
      [standardUserDefaults setObject:type forKey:@&quot;SharedDataType&quot;];
      // ... 동일한 로직
  }</code></pre><h3 id="4-native-module-구현">4. Native Module 구현</h3>
<p>  React Native와 iOS 간의 브릿지 역할을 하는 Native Module:</p>
<pre><code>
  - (void)getSharedData:(RCTPromiseResolveBlock)resolve
  reject:(RCTPromiseRejectBlock)reject {
      NSUserDefaults *groupUserDefaults = [[NSUserDefaults alloc]
          initWithSuiteName:@&quot;group.io.itmca.lifepuzzle&quot;];

      NSString *sharedType = [groupUserDefaults stringForKey:@&quot;SharedDataType&quot;];
      NSString *sharedURI = [groupUserDefaults stringForKey:@&quot;SharedDataURI&quot;];
      NSArray&lt;NSString *&gt; *sharedURIList = [groupUserDefaults
  arrayForKey:@&quot;SharedDataURIList&quot;];

      if (sharedType) {
          NSMutableDictionary *result = [[NSMutableDictionary alloc] init];
          [result setObject:sharedType forKey:@&quot;type&quot;];

          if ([sharedType isEqualToString:@&quot;single&quot;] &amp;&amp; sharedURI) {
              [result setObject:sharedURI forKey:@&quot;uri&quot;];
          } else if ([sharedType isEqualToString:@&quot;multiple&quot;] &amp;&amp; sharedURIList)
  {
              [result setObject:sharedURIList forKey:@&quot;uriList&quot;];
          }

          // 사용 후 데이터 삭제
          [groupUserDefaults removeObjectForKey:@&quot;SharedDataType&quot;];
          [groupUserDefaults removeObjectForKey:@&quot;SharedDataURI&quot;];
          [groupUserDefaults removeObjectForKey:@&quot;SharedDataURIList&quot;];
          [groupUserDefaults synchronize];

          resolve(result);
      } else {
          resolve(@{@&quot;type&quot;: [NSNull null]});
      }
  }</code></pre><h2 id="🎯-설정-및-권한">🎯 설정 및 권한</h2>
<h3 id="entitlements-설정">Entitlements 설정</h3>
<p>  각 Target별로 App Groups 권한 설정:</p>
<pre><code>  LifePuzzleShareRelease.entitlements:
  &lt;key&gt;com.apple.security.application-groups&lt;/key&gt;
  &lt;array&gt;
     &lt;string&gt;group.io.itmca.lifepuzzle&lt;/string&gt;
  &lt;/array&gt;</code></pre><h2 id="💡-배운-점들">💡 배운 점들</h2>
<h3 id="1-기술-선택의-중요성">1. 기술 선택의 중요성</h3>
<p>  처음엔 &quot;최신 = 최선&quot;이라고 생각했지만, 프레임워크의 권장사항을 따르는 것이 더 중요하다는 것을 깨달았습니다.</p>
<h3 id="2-react-native의-철학-이해">2. React Native의 철학 이해</h3>
<p>  React Native가 왜 Objective-C를 권장하는지 이해하게 되었습니다:</p>
<ul>
<li>안정성 &gt; 모던함</li>
<li>생태계 호환성 &gt; 개인 선호도</li>
</ul>
<h3 id="3-인터페이스-통일의-중요성">3. 인터페이스 통일의 중요성</h3>
<p>  Android에서 이미 만들어진 TypeScript 인터페이스를 그대로 사용함으로써:</p>
<ul>
<li>개발 시간 단축</li>
<li>버그 위험 감소</li>
<li>코드 일관성 확보</li>
<li>유지보수성 향상</li>
</ul>
<hr>
<h2 id="🎉-결과">🎉 결과</h2>
<h3 id="ios-카카오톡-이미지-공유-iphone">ios (카카오톡 이미지 공유, iPhone)</h3>
<table>
<thead>
<tr>
<th><img src="https://github.com/user-attachments/assets/9faf5044-1123-4889-9b1e-16711e647c82" alt="이미지1" width="200"></th>
<th><img src="https://github.com/user-attachments/assets/d52c2539-d340-4249-9240-2bd34900194f" alt="이미지1" width="200"></th>
<th><img src="https://github.com/user-attachments/assets/5d2ea4c2-42b0-4acb-a6ca-0c54fa28cf09" alt="이미지1" width="200"></th>
<th><img src="https://github.com/user-attachments/assets/69e1bff0-1a3d-4aa4-9076-95576e3d26f0" alt="이미지2" width="200"></th>
</tr>
</thead>
</table>
<ul>
<li>✅ 외부 앱에서 단일/다중 이미지 공유 지원</li>
<li>✅ iOS 13+ 호환성 확보</li>
<li>✅ 메모리 누수 방지 및 자동 정리</li>
<li>✅ 안정적인 데이터 전달 메커니즘</li>
<li>✅ TypeScript 타입 안정성</li>
<li>✅ React Native New Architecture 완벽 지원</li>
<li>✅ Android와 동일한 인터페이스로 Cross-Platform 코드 재사용</li>
</ul>
<blockquote>
<p>이제 iOS 사용자들도 갤러리, 카메라, 다른 앱에서 이미지를 LifePuzzle 앱으로
  직접 공유할 수 있게 되었습니다! 🎊</p>
</blockquote>
]]></description>
        </item>
        <item>
            <title><![CDATA[React Native New Architecture와 Android 외부 앱 이미지 공유 시스템 구현기(Claude)]]></title>
            <link>https://velog.io/@be_zion/React-Native-New-Architecture%EC%99%80-%EC%99%B8%EB%B6%80-%EC%95%B1-%EC%9D%B4%EB%AF%B8%EC%A7%80-%EA%B3%B5%EC%9C%A0-%EC%8B%9C%EC%8A%A4%ED%85%9C-%EA%B5%AC%ED%98%84%EA%B8%B0Claude</link>
            <guid>https://velog.io/@be_zion/React-Native-New-Architecture%EC%99%80-%EC%99%B8%EB%B6%80-%EC%95%B1-%EC%9D%B4%EB%AF%B8%EC%A7%80-%EA%B3%B5%EC%9C%A0-%EC%8B%9C%EC%8A%A4%ED%85%9C-%EA%B5%AC%ED%98%84%EA%B8%B0Claude</guid>
            <pubDate>Thu, 28 Aug 2025 11:34:15 GMT</pubDate>
            <description><![CDATA[<h2 id="들어가며">들어가며</h2>
<p>React Native 0.68부터 도입된 New Architecture는 기존의 Bridge 방식에서 TurboModule과 Fabric으로 전환하면서 성능상의 많은 이점을 가져다주었습니다. 하지만 기존 Legacy 모듈들과의 호환성 문제로 인해 많은 개발자들이 마이그레이션에 어려움을 겪고 있습니다.</p>
<p>이번 글에서는 React Native New Architecture 환경에서 <strong>외부 앱에서 이미지를 공유받아 처리하는 시스템</strong>을 구현하면서 마주했던 7가지 핵심 문제들과 그 해결 과정을 상세히 공유하겠습니다.</p>
<h2 id="프로젝트-개요">프로젝트 개요</h2>
<p><strong>목표</strong>: 갤러리나 카메라 앱에서 &quot;공유&quot; 버튼을 통해 우리 React Native 앱으로 이미지를 전송받아 처리하는 시스템 구현</p>
<p><strong>기술 스택</strong>:</p>
<ul>
<li>React Native 0.74+ (New Architecture 활성화)</li>
<li>TurboModule (Kotlin/Java)</li>
<li>Recoil (상태 관리)</li>
<li>TypeScript</li>
</ul>
<h2 id="문제-1-react-native-new-architecture-호환성-문제">문제 1: React Native New Architecture 호환성 문제</h2>
<h3 id="🚨-문제-상황">🚨 문제 상황</h3>
<p>기존의 Legacy ReactModule로 구현된 네이티브 모듈이 New Architecture의 Bridgeless 모드와 호환되지 않았습니다.</p>
<pre><code class="language-kotlin">// ❌ Legacy 방식 - New Architecture 비호환
class ShareModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaModule(reactContext) {
    // Legacy implementation
}</code></pre>
<h3 id="🔍-원인-분석">🔍 원인 분석</h3>
<ul>
<li>React Native New Architecture는 2025년 8월 현재 안정화 단계</li>
<li>TurboModule로 전환 시 Bridgeless 모드에서 런타임 오류 발생</li>
<li>Codegen 관련 설정 누락으로 인한 빌드 실패</li>
</ul>
<h3 id="✅-해결-방법">✅ 해결 방법</h3>
<p><strong>1단계: Codegen 아티팩트 생성</strong></p>
<pre><code class="language-bash">./gradlew generateCodegenArtifactsFromSchema</code></pre>
<p><strong>2단계: TurboModule 스펙 정의</strong></p>
<pre><code class="language-typescript">// NativeLPShareModule.ts
import type { TurboModule } from &#39;react-native&#39;;
import { TurboModuleRegistry } from &#39;react-native&#39;;

export interface Spec extends TurboModule {
  getSharedData(): Promise&lt;string | null&gt;;
  clearSharedData(): Promise&lt;void&gt;;
}

export default TurboModuleRegistry.getEnforcing&lt;Spec&gt;(&#39;LPShareModule&#39;);</code></pre>
<p><strong>3단계: Kotlin TurboModule 구현</strong></p>
<pre><code class="language-kotlin">// LPShareModule.kt
class LPShareModule(reactContext: ReactApplicationContext) : NativeLPShareModuleSpec(reactContext) {

    companion object {
        const val NAME = &quot;LPShareModule&quot;
        private var pendingSingleImageUri: String? = null
        private var pendingMultipleImageUris: List&lt;String&gt;? = null
    }

    override fun getName(): String = NAME

    ...
}</code></pre>
<h2 id="문제-2-nativesharemodulespecjsi-중복-정의-오류">문제 2: NativeShareModuleSpecJSI 중복 정의 오류</h2>
<h3 id="🚨-문제-상황-1">🚨 문제 상황</h3>
<pre><code>Duplicate symbol: facebook::react::NativeShareModuleSpecJSI::NativeShareModuleSpecJSI</code></pre><p>React Native core의 내장 Share API와 모듈명이 충돌하면서 빌드 오류가 발생했습니다.</p>
<h3 id="🔍-원인-분석-1">🔍 원인 분석</h3>
<p>React Native는 이미 <code>Share</code>라는 이름의 TurboModule을 내장하고 있었고, 동일한 이름으로 커스텀 모듈을 만들면서 C++ 레벨에서 심볼 충돌이 발생했습니다.</p>
<h3 id="✅-해결-방법-1">✅ 해결 방법</h3>
<p>모듈명을 고유한 이름으로 변경하여 충돌을 회피했습니다.</p>
<pre><code class="language-typescript">// ❌ 충돌 발생
export default TurboModuleRegistry.getEnforcing&lt;Spec&gt;(&#39;ShareModule&#39;);

// ✅ 충돌 해결
export default TurboModuleRegistry.getEnforcing&lt;Spec&gt;(&#39;LPShareModule&#39;);</code></pre>
<h2 id="문제-3-event-emitter-방식의-한계">문제 3: Event Emitter 방식의 한계</h2>
<h3 id="🚨-문제-상황-2">🚨 문제 상황</h3>
<p>초기에는 Event Emitter 방식으로 네이티브에서 JavaScript로 데이터를 전송하려 했지만, 앱이 완전히 초기화되기 전에는 이벤트 리스너가 설정되지 않아 데이터 손실이 발생했습니다.</p>
<pre><code class="language-javascript">// ❌ 데이터 손실 가능성이 있는 방식
useEffect(() =&gt; {
  const subscription = shareEventEmitter.addListener(&#39;imageShared&#39;, handleImageData);
  return () =&gt; subscription?.remove();
}, []);</code></pre>
<h3 id="✅-해결-방법-2">✅ 해결 방법</h3>
<p>Event 방식 대신 <strong>Polling + Static 저장소</strong> 패턴을 도입했습니다.</p>
<pre><code class="language-typescript">// JavaScript에서 데이터 확인
const checkForSharedData = useCallback(async () =&gt; {
  try {
    const sharedData = await LPShareModule.getSharedData();
    if (sharedData) {
      // 공유된 데이터 처리
      handleSharedData(sharedData);
    }
  } catch (error) {
    console.log(&#39;공유 데이터 확인 실패:&#39;, error);
  }
}, []);</code></pre>
<h2 id="문제-4-react-context-타이밍-문제">문제 4: React Context 타이밍 문제</h2>
<h3 id="🚨-문제-상황-3">🚨 문제 상황</h3>
<p>앱이 완전히 시작되기 전에 외부에서 이미지 공유가 발생하면, React Native 컨텍스트가 준비되지 않아 모듈을 찾을 수 없는 오류가 발생했습니다.</p>
<h3 id="🔍-원인-분석-2">🔍 원인 분석</h3>
<p>MainActivity에서 ShareModule을 호출하는 시점과 React Native가 초기화되는 시점 사이의 레이스 컨디션이 문제였습니다.</p>
<h3 id="✅-해결-방법-pending-data-패턴">✅ 해결 방법: Pending Data 패턴</h3>
<p><strong>MainActivity에서 임시 저장</strong></p>
<pre><code class="language-kotlin">// MainActivity.kt
override fun onCreate(savedInstanceState: Bundle?) {
    SplashView.showSplashView(this)
    super.onCreate(savedInstanceState)
    handleShareIntent(intent)
}

override fun onNewIntent(intent: Intent) {
    super.onNewIntent(intent)
    intent?.let { handleShareIntent(it) }
}

private fun handleShareIntent(intent: Intent?) {
    if (intent == null) return

    val action = intent.action
    val type = intent.type

    if (Intent.ACTION_SEND == action &amp;&amp; type != null &amp;&amp; type.startsWith(&quot;image/&quot;)) {
        // 단일 이미지 처리
        val imageUri: Uri? = intent.getParcelableExtra(Intent.EXTRA_STREAM)
        imageUri?.let {
            val module = (application as MainApplication).reactNativeHost.reactInstanceManager
                        .currentReactContext?.getNativeModule(LPShareModule::class.java)
            if (module != null) {
                module.setSharedImageUri(it.toString())
            } else {
                // React Native 초기화 전이면 pending 저장소에 저장
                pendingSingleImageUri = it.toString()
                pendingMultipleImageUris = null
            }
        }
    } else if (Intent.ACTION_SEND_MULTIPLE == action &amp;&amp; type != null &amp;&amp; type.startsWith(&quot;image/&quot;)) {
        // 다중 이미지 처리
        val imageUris: ArrayList&lt;Uri&gt;? = intent.getParcelableArrayListExtra(Intent.EXTRA_STREAM)
        imageUris?.let { uris -&gt;
            val uriStrings = uris.map { it.toString() }
            val module = (application as MainApplication).reactNativeHost.reactInstanceManager
                        .currentReactContext?.getNativeModule(LPShareModule::class.java)
            if (module != null) {
                module.setSharedImageUris(uriStrings)
            } else {
                // React Native 초기화 전이면 pending 저장소에 저장
                pendingMultipleImageUris = uriStrings
                pendingSingleImageUri = null
            }
        }
    }
}

companion object {
    private var pendingSingleImageUri: String? = null
    private var pendingMultipleImageUris: List&lt;String&gt;? = null

    fun hasPendingShareData(): Boolean {
        return pendingSingleImageUri != null || pendingMultipleImageUris != null
    }

    fun processPendingShareData(): Pair&lt;String?, List&lt;String&gt;?&gt; {
        val result = Pair(pendingSingleImageUri, pendingMultipleImageUris)
        pendingSingleImageUri = null
        pendingMultipleImageUris = null
        return result
    }
}</code></pre>
<h2 id="문제-5-sharemodule-인스턴스-격리-문제">문제 5: ShareModule 인스턴스 격리 문제</h2>
<h3 id="🚨-문제-상황-4">🚨 문제 상황</h3>
<p><code>setSharedImageUri()</code>와 <code>getSharedData()</code>가 서로 다른 TurboModule 인스턴스에서 실행되어 데이터 공유가 되지 않았습니다.</p>
<h3 id="🔍-원인-분석-3">🔍 원인 분석</h3>
<p>TurboModule 시스템에서는 메서드 호출마다 새로운 인스턴스가 생성될 수 있어, 인스턴스 변수로는 데이터 공유가 불가능했습니다.</p>
<h3 id="✅-해결-방법-companion-object-활용">✅ 해결 방법: Companion Object 활용</h3>
<pre><code class="language-kotlin">class LPShareModule(reactContext: ReactApplicationContext) : NativeLPShareModuleSpec(reactContext) {

    companion object {
        // 단일/다중 이미지를 모두 처리할 수 있는 Static 변수
        private var pendingSingleImageUri: String? = null
        private var pendingMultipleImageUris: List&lt;String&gt;? = null

        fun setSharedImageUri(uri: String) {
            pendingSingleImageUri = uri
            pendingMultipleImageUris = null
        }

        fun setSharedImageUris(uris: List&lt;String&gt;) {
            pendingMultipleImageUris = uris
            pendingSingleImageUri = null
        }
    }

    override fun getSharedData(promise: Promise) {
        try {
            // MainActivity의 pending 데이터도 함께 확인
            val mainActivity = currentActivity as? MainActivity
            if (mainActivity?.hasPendingShareData() == true) {
                val (singleUri, multipleUris) = mainActivity.processPendingShareData()
                if (singleUri != null) {
                    pendingSingleImageUri = singleUri
                } else if (multipleUris != null) {
                    pendingMultipleImageUris = multipleUris
                }
            }

            // 데이터 반환 및 초기화
            val sharedData = pendingSingleImageUri ?: pendingMultipleImageUris?.firstOrNull()
            pendingSingleImageUri = null
            pendingMultipleImageUris = null

            promise.resolve(sharedData)
        } catch (e: Exception) {
            promise.reject(&quot;GET_SHARED_DATA_ERROR&quot;, e.message, e)
        }
    }
}</code></pre>
<h2 id="문제-6-순환-참조-문제">문제 6: 순환 참조 문제</h2>
<h3 id="🚨-문제-상황-5">🚨 문제 상황</h3>
<p><code>getSharedData()</code> → <code>processPendingShareData()</code> → <code>setSharedImageUri()</code> → 무한 루프 발생</p>
<h3 id="🔍-원인-분석-4">🔍 원인 분석</h3>
<p>ShareModule에서 MainActivity의 메서드를 호출할 때, 다시 ShareModule을 찾으려 해서 순환 참조가 발생했습니다.</p>
<h3 id="✅-해결-방법-단순화된-직접-처리">✅ 해결 방법: 단순화된 직접 처리</h3>
<pre><code class="language-kotlin">// ❌ 순환 참조 발생 가능한 복잡한 구조
fun getSharedData() {
    val mainActivity = getCurrentActivity() as? MainActivity
    mainActivity?.processPendingShareData() // 위험!
}

// ✅ 이중 저장소로 순환 참조 방지
class LPShareModule {
    companion object {
        private var pendingSingleImageUri: String? = null
        private var pendingMultipleImageUris: List&lt;String&gt;? = null
    }

    override fun getSharedData(promise: Promise) {
        try {
            // 1. MainActivity의 pending 데이터 확인
            val mainActivity = currentActivity as? MainActivity
            if (mainActivity?.hasPendingShareData() == true) {
                val (singleUri, multipleUris) = mainActivity.processPendingShareData()
                // 2. TurboModule의 저장소로 이동
                if (singleUri != null) {
                    pendingSingleImageUri = singleUri
                } else if (multipleUris != null) {
                    pendingMultipleImageUris = multipleUris
                }
            }

            // 3. 데이터 반환 및 초기화 (순환 참조 없음)
            val sharedData = pendingSingleImageUri ?: pendingMultipleImageUris?.firstOrNull()
            pendingSingleImageUri = null
            pendingMultipleImageUris = null

            promise.resolve(sharedData)
        } catch (e: Exception) {
            promise.reject(&quot;GET_SHARED_DATA_ERROR&quot;, e.message, e)
        }
    }
}

// MainActivity는 단순히 pending 데이터만 관리
class MainActivity {
    companion object {
        fun processPendingShareData(): Pair&lt;String?, List&lt;String&gt;?&gt; {
            val result = Pair(pendingSingleImageUri, pendingMultipleImageUris)
            pendingSingleImageUri = null
            pendingMultipleImageUris = null
            return result
        }
    }
}</code></pre>
<h2 id="문제-7-다른-화면에서-공유-데이터-미처리-문제">문제 7: 다른 화면에서 공유 데이터 미처리 문제</h2>
<h3 id="🚨-문제-상황-6">🚨 문제 상황</h3>
<p>사용자가 HomePage가 아닌 다른 화면에 있을 때 외부에서 이미지를 공유하면, 해당 이미지가 처리되지 않았습니다.</p>
<h3 id="🔍-원인-분석-5">🔍 원인 분석</h3>
<p><code>checkSharedData()</code> 함수가 HomePage에서만 실행되어, 다른 화면에서는 공유 데이터를 감지할 수 없었습니다.</p>
<h3 id="✅-해결-방법-전역-공유-데이터-감지-시스템">✅ 해결 방법: 전역 공유 데이터 감지 시스템</h3>
<p><strong>1단계: Recoil 전역 상태 관리</strong></p>
<pre><code class="language-typescript">// atoms/sharedImageAtom.ts
import { atom } from &#39;recoil&#39;;

export interface SharedImageData {
  uri: string;
  timestamp: number;
}

export const sharedImageDataState = atom&lt;SharedImageData | null&gt;({
  key: &#39;sharedImageDataState&#39;,
  default: null,
});</code></pre>
<p><strong>2단계: App.tsx에서 전역 감지</strong></p>
<pre><code class="language-typescript">// App.tsx
const App = () =&gt; {
  const [, setSharedImageData] = useRecoilState(sharedImageDataState);
  const navigation = useNavigation();

  const checkForSharedData = useCallback(async () =&gt; {
    try {
      const sharedData = await LPShareModule.getSharedData();
      if (sharedData) {
        console.log(&#39;공유된 데이터 감지:&#39;, sharedData);

        // 전역 상태 업데이트
        setSharedImageData({
          uri: sharedData,
          timestamp: Date.now(),
        });

        // HomePage로 자동 네비게이션
        navigation.navigate(&#39;Home&#39; as never);
      }
    } catch (error) {
      console.log(&#39;공유 데이터 확인 실패:&#39;, error);
    }
  }, [setSharedImageData, navigation]);

  // 앱 상태 변화 감지 (앱이 열리거나 포커스될 때만 체크)
  useEffect(() =&gt; {
    // 앱 시작할 때 한 번 체크
    checkForSharedData();

    // AppState 변화 리스너
    const handleAppStateChange = (nextAppState: string) =&gt; {
      if (nextAppState === &#39;active&#39;) {
        // 앱이 활성화될 때마다 체크
        checkForSharedData();
      }
    };

    const subscription = AppState.addEventListener(&#39;change&#39;, handleAppStateChange);

    return () =&gt; subscription?.remove();
  }, [checkForSharedData]);

  return &lt;NavigationContainer&gt;{/* ... */}&lt;/NavigationContainer&gt;;
};</code></pre>
<p><strong>3단계: HomePage에서 상태 감지 및 처리</strong></p>
<pre><code class="language-typescript">// HomePage.tsx
const HomePage = () =&gt; {
  const [sharedImageData, setSharedImageData] = useRecoilState(sharedImageDataState);
  const [isBottomSheetOpen, setIsBottomSheetOpen] = useState(false);

  useEffect(() =&gt; {
    if (sharedImageData) {
      console.log(&#39;HomePage에서 공유 이미지 감지:&#39;, sharedImageData);

      // BottomSheet 자동 열기
      setIsBottomSheetOpen(true);

      // 처리 완료 후 상태 초기화
      // setSharedImageData(null); // 필요에 따라 즉시 또는 나중에 초기화
    }
  }, [sharedImageData]);

  const handleImageUpload = async () =&gt; {
    if (sharedImageData) {
      try {
        // 이미지 업로드 로직
        await uploadImage(sharedImageData.uri);

        // 성공 후 상태 초기화
        setSharedImageData(null);
        setIsBottomSheetOpen(false);
      } catch (error) {
        console.error(&#39;이미지 업로드 실패:&#39;, error);
      }
    }
  };

  return (
    &lt;View style={{ flex: 1 }}&gt;
      {/* 기존 HomePage 컴포넌트들 */}

      &lt;BottomSheet
        isOpen={isBottomSheetOpen}
        onClose={() =&gt; {
          setIsBottomSheetOpen(false);
          setSharedImageData(null);
        }}
      &gt;
        {sharedImageData &amp;&amp; (
          &lt;SharedImagePreview
            imageUri={sharedImageData.uri}
            onUpload={handleImageUpload}
            onCancel={() =&gt; {
              setIsBottomSheetOpen(false);
              setSharedImageData(null);
            }}
          /&gt;
        )}
      &lt;/BottomSheet&gt;
    &lt;/View&gt;
  );
};</code></pre>
<h2 id="최종-아키텍처-및-데이터-흐름">최종 아키텍처 및 데이터 흐름</h2>
<h3 id="🏗️-시스템-아키텍처">🏗️ 시스템 아키텍처</h3>
<pre><code>📱 외부 앱 (갤러리)
    ↓ Intent.ACTION_SEND / ACTION_SEND_MULTIPLE
🏠 MainActivity (이중 저장 시스템)
    ↓ setSharedImageUri(s)
💾 LPShareModule + MainActivity Pending 저장소
    ↓ 앱 상태 변화 감지
🌐 App.tsx (전역 감지)
    ↓ Recoil 상태 업데이트
⚛️ HomePage (상태 구독)
    ↓ BottomSheet 자동 열기
👤 사용자 확인 및 업로드</code></pre><h3 id="🔄-완벽한-데이터-흐름">🔄 완벽한 데이터 흐름</h3>
<ol>
<li><strong>📱 갤러리 앱에서 &quot;공유&quot; 버튼 클릭 (단일 또는 다중 이미지)</strong></li>
<li><strong>🏠 MainActivity에서 Intent 수신 및 이중 저장 시스템 활용</strong></li>
<li><strong>💾 LPShareModule과 MainActivity 양쪽에 URI 보관</strong></li>
<li><strong>🌐 App.tsx에서 앱 상태 변화 시점에 감지 수행</strong></li>
<li><strong>⚛️ Recoil 상태로 앱 전체에 데이터 전파</strong></li>
<li><strong>🧭 HomePage로 자동 네비게이션</strong></li>
<li><strong>📋 BottomSheet 자동 열기 및 이미지 미리보기</strong></li>
<li><strong>👤 사용자 확인 후 이미지 업로드</strong></li>
</ol>
<h3 id="🎯-핵심-기술적-결정사항">🎯 핵심 기술적 결정사항</h3>
<table>
<thead>
<tr>
<th>문제</th>
<th>해결 방법</th>
<th>기술적 근거</th>
</tr>
</thead>
<tbody><tr>
<td>New Architecture 호환성</td>
<td>TurboModule 구현</td>
<td>성능 향상 및 미래 호환성</td>
</tr>
<tr>
<td>이름 충돌</td>
<td>고유한 모듈명 (LPShareModule)</td>
<td>빌드 오류 방지</td>
</tr>
<tr>
<td>타이밍 문제</td>
<td>Pending Data 패턴</td>
<td>레이스 컨디션 해결</td>
</tr>
<tr>
<td>인스턴스 격리</td>
<td>Companion Object (Static) + 이중 저장소</td>
<td>데이터 공유 보장</td>
</tr>
<tr>
<td>순환 참조</td>
<td>이중 저장소 패턴</td>
<td>코드 복잡도 감소</td>
</tr>
<tr>
<td>크로스 스크린 감지</td>
<td>전역 상태 감지 + Recoil</td>
<td>앱 전체 상태 관리</td>
</tr>
<tr>
<td>다중 이미지 지원</td>
<td>ACTION_SEND_MULTIPLE 처리</td>
<td>확장성 확보</td>
</tr>
</tbody></table>
<h2 id="마무리">마무리</h2>
<p>React Native New Architecture 환경에서 외부 앱 이미지 공유 시스템을 구현하면서 다음과 같은 핵심 인사이트를 얻었습니다:</p>
<h3 id="🎓-주요-학습-포인트">🎓 주요 학습 포인트</h3>
<ol>
<li><strong>TurboModule의 중요성</strong>: New Architecture에서는 TurboModule 구현이 필수</li>
<li><strong>Static 데이터 공유</strong>: 인스턴스 간 데이터 공유를 위해서는 Static 변수 활용</li>
<li><strong>전역 상태 관리</strong>: 앱 전체에서 공유 데이터를 감지하려면 전역 상태 관리가 필요</li>
<li><strong>타이밍 문제 해결</strong>: 이중 저장소 패턴으로 초기화 타이밍 문제 해결</li>
<li><strong>효율적인 감지 시스템</strong>: 주기적 폴링보다 AppState 기반 감지가 더 효율적</li>
<li><strong>다중 이미지 처리</strong>: ACTION_SEND_MULTIPLE 지원으로 확장성 확보</li>
</ol>
<h3 id="🚀-프로덕션-준비-완료">🚀 프로덕션 준비 완료</h3>
<p>현재 시스템은 다음과 같은 특징으로 프로덕션 환경에서 안정적으로 작동합니다:</p>
<ul>
<li>✅ New Architecture 완전 호환</li>
<li>✅ 단일/다중 이미지 모두 지원</li>
<li>✅ 모든 화면에서 공유 데이터 감지</li>
<li>✅ 자동 네비게이션 및 UI 표시</li>
<li>✅ 이중 저장소로 데이터 안정성 확보</li>
<li>✅ 오류 처리 및 복구</li>
<li>✅ 사용자 친화적 UX</li>
</ul>
<h3 id="💡-향후-개선-방향">💡 향후 개선 방향</h3>
<ol>
<li><strong>푸시 알림 통합</strong>: 공유 데이터 수신 시 알림 표시</li>
<li><strong>멀티미디어 지원</strong>: 비디오 및 기타 파일 형식 지원 확장</li>
<li><strong>배치 처리</strong>: 여러 이미지 동시 공유 처리</li>
<li><strong>캐싱 시스템</strong>: 반복적인 공유 작업 최적화</li>
</ol>
<p>이번 구현을 통해 React Native New Architecture의 강력함과 동시에 기존 시스템과의 호환성 문제를 해결하는 과정을 경험할 수 있었습니다. 앞으로 New Architecture가 더욱 안정화되면서 이런 구현들이 더욱 간편해질 것으로 기대됩니다.</p>
<hr>
<h3 id="🎉-결과">🎉 결과</h3>
<h4 id="android-사진앱-이미지-공유-android-emulator">android (사진앱 이미지 공유, Android Emulator)</h4>
<table>
<thead>
<tr>
<th><img src="https://github.com/user-attachments/assets/67e2d50b-7c60-4ac5-be1d-a4fba66b2e4a" alt="이미지1" width="200"></th>
<th><img src="https://github.com/user-attachments/assets/b752e6ea-b77f-4283-aca1-b6f8a238f53f" alt="이미지1" width="200"></th>
<th><img src="https://github.com/user-attachments/assets/86d4d560-417a-46ad-b6b4-2f20060570f4" alt="이미지1" width="200"></th>
<th><img src="https://github.com/user-attachments/assets/27076826-0e63-4e4d-a3b5-843926e0e58a" alt="이미지2" width="200"></th>
</tr>
</thead>
</table>
<blockquote>
<p>💬 <strong>궁금한 점이나 개선 아이디어가 있다면 댓글로 공유해주세요!</strong><br>React Native New Architecture 관련 다른 문제들도 함께 논의해보면 좋겠습니다. 🚀</p>
</blockquote>
]]></description>
        </item>
        <item>
            <title><![CDATA[macOS 10.15 홈서버 구축 -(3)CI/CD]]></title>
            <link>https://velog.io/@be_zion/macOS-10.15-%ED%99%88%EC%84%9C%EB%B2%84-%EA%B5%AC%EC%B6%95-4CICD</link>
            <guid>https://velog.io/@be_zion/macOS-10.15-%ED%99%88%EC%84%9C%EB%B2%84-%EA%B5%AC%EC%B6%95-4CICD</guid>
            <pubDate>Thu, 10 Jul 2025 15:33:59 GMT</pubDate>
            <description><![CDATA[<h3 id="jenkinskubernetes-방식으로-사용시선택">Jenkins(Kubernetes 방식)으로 사용시(선택)</h3>
<ol>
<li>설정<pre><code>kubectl create namespace jenkins
kubectl apply -f jenkins.yaml //Jenkins 배포
minikube service jenkins-service -n jenkins //Jenkins 접속</code></pre></li>
<li>초기 비밀번호 확인<pre><code>kubectl exec -n jenkins -it deployment/jenkins -- cat /var/jenkins_home/secrets/initialAdminPassword
</code></pre></li>
</ol>
<pre><code>
3. secret 생성</code></pre><p>kubectl apply -f jenkins-serviceaccount.yaml
kubectl create secret generic jenkins-sa-token <br>  --namespace jenkins <br>  --type kubernetes.io/service-account-token <br>  --from-literal=extra=dummy <br>  --dry-run=client -o yaml | <br>  sed &#39;/extra:/d&#39; | <br>  tee jenkins-sa-token.yaml
kubectl apply -f jenkins-sa-token.yaml
kubectl get secret jenkins-sa-token -n jenkins -o jsonpath=&quot;{.data.token}&quot; | base64 --decode</p>
<pre><code>### minicube 고정 포트 사용</code></pre><p>minikube service zion-web
curl http://[minikube ip]:32209</p>
<pre><code>#### 고정 URL 연결 실패시,
##### 1) port-forward 방식</code></pre><p>kubectl port-forward svc/zion-web 8080:80</p>
<pre><code>##### 2) LoadBalancer 방식</code></pre><p>kubectl delete svc zion-web
kubectl expose deployment zion-web --type=LoadBalancer --port=80
minikube tunnel</p>
<pre><code>##### EXTERNAL-IP 생성확인</code></pre><p>kubectl get svc</p>
<pre><code>
### Jenkins 빌드 실패
1. No credentials specified
**checkout scm** :Jenkins가 브랜치와 자격증명 다 관리

2. npm: command not found v18.20.2</code></pre><p>curl -O <a href="https://nodejs.org/dist/v18.20.2/node-v18.20.2-darwin-x64.tar.xz">https://nodejs.org/dist/v18.20.2/node-v18.20.2-darwin-x64.tar.xz</a>
tar -xf node-v18.20.2-darwin-x64.tar.xz
mv node-v18.20.2-darwin-x64 ~/node
echo &#39;export PATH=$HOME/node/bin:$PATH&#39; &gt;&gt; ~/.zshrc
source ~/.zshrc</p>
<p>```
<img src="https://velog.velcdn.com/images/be_zion/post/936f7543-e89b-4fa3-8839-882b84517b7a/image.png" alt=""></p>
<p>[GitHub Push]
     ↓ (Webhook)
[Jenkins Git Pull]
     ↓
[Docker Build (Minikube Docker)]
     ↓
[kubectl로 배포]
     ↓
[Minikube 앱 서비스]</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[macOS 10.15 홈서버 구축 -(2)보안설정]]></title>
            <link>https://velog.io/@be_zion/macOS-10.15-%ED%99%88%EC%84%9C%EB%B2%84-%EA%B5%AC%EC%B6%95-2%EB%B3%B4%EC%95%88%EC%84%A4%EC%A0%95</link>
            <guid>https://velog.io/@be_zion/macOS-10.15-%ED%99%88%EC%84%9C%EB%B2%84-%EA%B5%AC%EC%B6%95-2%EB%B3%B4%EC%95%88%EC%84%A4%EC%A0%95</guid>
            <pubDate>Sat, 14 Jun 2025 13:47:22 GMT</pubDate>
            <description><![CDATA[<h2 id="openssl자체서명">openssl(자체서명)</h2>
<p>: openssl 설정후, Let’s Encrypt 로 변경하려고 했으나, brew install certbot 설치 실패가 발생해, openssl 까지만 진행. cloudflare ssl : full 설정일 경우 자체서명 사용가능</p>
<h3 id="1-openssl-설정파일-생성">1. OpenSSL 설정파일 생성</h3>
<p>nano ~/openssl-san.cnf</p>
<pre><code>[req]
default_bits       = 2048
prompt             = no
default_md         = sha256
req_extensions     = req_ext
distinguished_name = dn

[dn]
C  = KR
ST = Seoul
L  = Seoul
O  = ZionDev
OU = Dev
CN = localhost

[req_ext]
subjectAltName = @alt_names

[alt_names]
DNS.1 = localhost
IP.1  = 127.0.0.1</code></pre><h3 id="2-인증서-및-키-생성">2. 인증서 및 키 생성</h3>
<pre><code>openssl req -x509 -nodes -days 365 \
  -newkey rsa:2048 \
  -keyout ~/localhost.key \
  -out ~/localhost.crt \
  -config ~/openssl-san.cnf \
  -extensions req_ext</code></pre><h3 id="3-nginx-설정-수정">3. nginx 설정 수정</h3>
<p>:기존 8080, 443 포워딩 설정은 cloudflare 기능으로 대제 된다.</p>
<pre><code>listen 443 ssl;
server_name  [도메인 이름];
ssl_certificate     [localhost.crt 주소];
ssl_certificate_key [localhost.key 주소];</code></pre><pre><code>openssl x509 -in [파일 주소].crt -text -noout | grep -A1 &quot;Subject Alternative Name&quot;</code></pre><h3 id="4-nginx-재시작">4. nginx 재시작</h3>
<pre><code>sudo nginx -s reload</code></pre><h2 id="도메인-설정">도메인 설정</h2>
<h3 id="1-도메인구매-및-cloudflare-설정">1. 도메인구매 및 cloudflare 설정</h3>
<p><a href="http://www.namecheap.com">www.namecheap.com</a>
<a href="https://dash.cloudflare.com">https://dash.cloudflare.com</a></p>
<pre><code>brew install cloudflared
cloudflared tunnel login
cloudflared tunnel create zionlee-website-tunnel 
cloudflared tunnel run zionlee-website-tunnel </code></pre><h3 id="2-cloudflare-cname-설정">2. cloudflare cname 설정</h3>
<p>: 초기 A, CNAME 레코드 삭제후 실행</p>
<pre><code>cloudflared tunnel route dns zionlee-website-tunnel zionlee.website</code></pre><h2 id="연결확인">연결확인</h2>
<h3 id="1-nginx-443-포트-listen-실행확인">1. nginx 443 포트 LISTEN 실행확인</h3>
<p>: 오류 확인시, nginx 실행 여부 및 설정 확인</p>
<pre><code>sudo lsof -i :443
curl -vk https://localhost</code></pre><h3 id="2-nginx-연결-오류시">2. nginx 연결 오류시</h3>
<h4 id="1-pid-확인">1) PID 확인</h4>
<pre><code>ps aux | grep nginx
cat /usr/local/var/run/nginx.pid</code></pre><h4 id="2-pid-파일-수동-삭제-후-nginx-재시작">2) PID 파일 수동 삭제 후 nginx 재시작</h4>
<pre><code>sudo rm -f /usr/local/var/run/nginx.pid
sudo nginx
sudo nginx -s reload</code></pre><h3 id="3-외부-접근-확인">3. 외부 접근 확인</h3>
<p>: 실패시, cloudflare 설정, 방화벽 확인</p>
<pre><code>curl -vk [도메인 주소]</code></pre><h3 id="4-url-확인">4. URL 확인</h3>
<p><img src="https://velog.velcdn.com/images/be_zion/post/d17219ce-a436-42c8-990d-2298657e3e3b/image.png" alt=""></p>
]]></description>
        </item>
    </channel>
</rss>