<?xml version="1.0" encoding="utf-8"?><feed xmlns="http://www.w3.org/2005/Atom" xml:lang="ko-kr"><generator uri="https://jekyllrb.com/" version="3.10.0">Jekyll</generator><link href="https://dngur6344.github.io/feed.xml" rel="self" type="application/atom+xml" /><link href="https://dngur6344.github.io/" rel="alternate" type="text/html" hreflang="ko-kr" /><updated>2026-08-28T13:50:16+09:00</updated><id>https://dngur6344.github.io/feed.xml</id><title type="html">나를 구성하는 별들</title><subtitle>WooHyuk의 마크다운 블로그.
</subtitle><author><name>WooHyuk</name><email></email></author><entry><title type="html">Transformer는 어떻게 문맥을 계산하는가</title><link href="https://dngur6344.github.io/develop/2026-06-18-transformer-attention-mlp/" rel="alternate" type="text/html" title="Transformer는 어떻게 문맥을 계산하는가" /><published>2026-06-18T00:00:00+09:00</published><updated>2026-06-18T00:00:00+09:00</updated><id>https://dngur6344.github.io/develop/transformer-attention-mlp</id><content type="html" xml:base="https://dngur6344.github.io/develop/2026-06-18-transformer-attention-mlp/"><![CDATA[<p>Transformer를 처음 보면 이상한 이름들이 한꺼번에 나온다. Token, embedding, query, key, value, attention, multi-head, MLP, residual, layer norm. 이름만 따라가면 커피가 식기도 전에 길을 잃기 쉽다.</p>

<p>하지만 안쪽의 큰 흐름은 차분하다.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>토큰을 벡터로 바꾼다.
각 토큰이 다른 토큰을 얼마나 참고할지 계산한다.
참고한 정보를 섞어 문맥이 반영된 벡터를 만든다.
그 벡터를 MLP로 다시 해석하고 다음 층으로 보낸다.
</code></pre></div></div>

<style>
.transformer-visual {
  --tx-bg: linear-gradient(135deg, rgba(54, 34, 24, .96), rgba(8, 11, 25, .98));
  --tx-panel: rgba(255, 250, 242, .078);
  --tx-panel-strong: rgba(255, 250, 242, .13);
  --tx-line: rgba(255, 250, 242, .18);
  --tx-ink: #fffaf2;
  --tx-muted: rgba(255, 250, 242, .72);
  --tx-gold: #dfb976;
  --tx-blue: #96bfe6;
  --tx-green: #97cfa8;
  --tx-red: #e29a9a;
  --tx-violet: #b8a4ed;
  margin: 1.25rem 0 1.65rem;
  padding: .95rem;
  border: 1px solid rgba(255, 250, 242, .14);
  border-radius: 8px;
  color: var(--tx-ink);
  background: var(--tx-bg);
  box-shadow: 0 1rem 2.4rem rgba(8, 10, 17, .22);
}

.transformer-title {
  margin: 0 0 .75rem;
  color: var(--tx-ink);
  font-size: .78rem;
  font-weight: 700;
}

.transformer-grid,
.transformer-flow,
.transformer-stack,
.transformer-matrix,
.transformer-token-row,
.transformer-table-wrap {
  display: grid;
  gap: .65rem;
}

.transformer-grid.two {
  grid-template-columns: repeat(2, minmax(0, 1fr));
}

.transformer-grid.three {
  grid-template-columns: repeat(3, minmax(0, 1fr));
}

.transformer-flow.four {
  grid-template-columns: repeat(4, minmax(0, 1fr));
}

.transformer-stack {
  grid-template-columns: .95fr 1.1fr .95fr;
  align-items: stretch;
}

.transformer-card,
.transformer-step,
.transformer-block,
.transformer-token,
.transformer-weight,
.transformer-chip {
  min-width: 0;
  border: 1px solid var(--tx-line);
  border-radius: 6px;
  background: var(--tx-panel);
}

.transformer-card,
.transformer-block {
  padding: .72rem;
}

.transformer-step {
  position: relative;
  padding: .62rem;
}

.transformer-step:not(:last-child)::after {
  content: "->";
  position: absolute;
  right: -.49rem;
  top: 50%;
  color: rgba(255, 250, 242, .58);
  font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
  font-size: .66rem;
  transform: translate(50%, -50%);
}

.transformer-visual b,
.transformer-visual strong {
  display: block;
  color: var(--tx-ink);
  font-size: .68rem;
  line-height: 1.35;
}

.transformer-visual span,
.transformer-visual p {
  display: block;
  margin: .22rem 0 0;
  color: var(--tx-muted);
  font-size: .62rem;
  line-height: 1.45;
}

.transformer-visual code {
  color: var(--tx-ink);
  background: rgba(255, 250, 242, .1);
}

.transformer-token-row {
  grid-template-columns: repeat(4, minmax(0, 1fr));
}

.transformer-token {
  padding: .55rem .45rem;
  text-align: center;
}

.transformer-token em {
  display: block;
  color: var(--tx-gold);
  font-style: normal;
  font-size: .62rem;
}

.transformer-matrix {
  grid-template-columns: 1fr 1.15fr 1fr;
  align-items: center;
}

.transformer-weight {
  padding: .55rem;
}

.transformer-bar {
  height: .42rem;
  margin-top: .3rem;
  border-radius: 999px;
  background: rgba(255, 250, 242, .13);
  overflow: hidden;
}

.transformer-bar i {
  display: block;
  height: 100%;
  border-radius: inherit;
  background: linear-gradient(90deg, var(--tx-gold), var(--tx-blue));
}

.transformer-chip {
  display: inline-block;
  margin: .14rem .12rem 0 0;
  padding: .25rem .38rem;
  color: var(--tx-ink);
  font-size: .58rem;
  line-height: 1.25;
}

.transformer-block.attn {
  border-color: rgba(150, 191, 230, .42);
}

.transformer-block.mlp {
  border-color: rgba(223, 185, 118, .42);
}

.transformer-block.norm {
  border-color: rgba(151, 207, 168, .38);
}

.transformer-table {
  width: 100%;
  margin: .15rem 0 0;
  border-collapse: collapse;
  color: var(--tx-ink);
  background: rgba(255, 250, 242, .035);
  font-size: .66rem;
  line-height: 1.45;
}

.transformer-table th,
.transformer-table td {
  border: 1px solid rgba(255, 250, 242, .16);
  padding: .56rem .6rem;
  color: var(--tx-ink);
  background: rgba(255, 250, 242, .055);
  vertical-align: top;
}

.transformer-table th {
  color: #19130f;
  background: rgba(223, 185, 118, .82);
  font-weight: 700;
}

.transformer-table tr:nth-child(even) td {
  background: rgba(255, 250, 242, .085);
}

.transformer-table td:first-child {
  color: var(--tx-gold);
  font-weight: 700;
}

html[data-mode="dark"] .transformer-table th,
html[data-theme="dark"] .transformer-table th,
body.dark-mode .transformer-table th {
  color: #19130f;
}

@media (max-width: 760px) {
  .transformer-grid.two,
  .transformer-grid.three,
  .transformer-flow.four,
  .transformer-stack,
  .transformer-matrix,
  .transformer-token-row {
    grid-template-columns: 1fr;
  }

  .transformer-step:not(:last-child)::after {
    content: "↓";
    right: 50%;
    top: auto;
    bottom: -.6rem;
    transform: translate(50%, 50%);
  }
}
</style>

<h2 id="먼저-신경망은-무엇을-학습하나">먼저 신경망은 무엇을 학습하나</h2>

<p>딥러닝 모델을 아주 건조하게 말하면, 파라미터를 가진 함수다.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>예측값 = f_theta(x)
theta = 학습으로 바뀌는 모든 숫자
</code></pre></div></div>

<p>완전연결층이라면 <code class="language-plaintext highlighter-rouge">W</code>, <code class="language-plaintext highlighter-rouge">b</code>가 파라미터다. CNN이라면 필터 안의 숫자도 파라미터고, Transformer라면 embedding table, <code class="language-plaintext highlighter-rouge">W_Q</code>, <code class="language-plaintext highlighter-rouge">W_K</code>, <code class="language-plaintext highlighter-rouge">W_V</code>, MLP의 가중치들이 모두 파라미터다. 학습은 손실 <code class="language-plaintext highlighter-rouge">L(theta)</code>를 줄이도록 이 숫자들을 조금씩 바꾸는 과정이다.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>h = sigma(xW + b)
theta &lt;- theta - eta * grad_theta L(theta)
</code></pre></div></div>

<p>여기서 중요한 점은 입력 데이터 자체가 파라미터는 아니라는 것이다. 모델은 데이터를 보고, 그 데이터를 더 잘 설명하는 내부 숫자들을 업데이트한다. 실무적으로는 “경사하강으로 업데이트되는 값들 전부”를 파라미터라고 보면 된다.</p>

<div class="transformer-visual">
  <p class="transformer-title">신경망 계열이 데이터를 다루는 방식</p>
  <div class="transformer-flow four">
    <div class="transformer-step">
      <strong>DNN / MLP</strong>
      <span>벡터 전체를 선형 변환과 비선형 함수로 처리한다.</span>
    </div>
    <div class="transformer-step">
      <strong>CNN</strong>
      <span>근처 픽셀/토큰의 지역 패턴을 필터로 훑는다.</span>
    </div>
    <div class="transformer-step">
      <strong>RNN</strong>
      <span>시퀀스를 왼쪽에서 오른쪽으로 읽으며 상태에 압축한다.</span>
    </div>
    <div class="transformer-step">
      <strong>Transformer</strong>
      <span>각 토큰이 다른 토큰을 직접 조회하며 문맥을 만든다.</span>
    </div>
  </div>
</div>

<h2 id="cnn과-rnn을-거쳐-transformer로">CNN과 RNN을 거쳐 Transformer로</h2>

<p>Transformer가 갑자기 하늘에서 떨어진 구조는 아니다. 기존 신경망의 장점과 한계를 보면 왜 attention이 중심으로 올라왔는지 보인다.</p>

<h3 id="dnn--mlp">DNN / MLP</h3>

<p>MLP는 벡터를 받아 여러 층의 선형 변환과 비선형 함수를 통과시킨다.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>h_1 = sigma(xW_1 + b_1)
h_2 = sigma(h_1W_2 + b_2)
</code></pre></div></div>

<p>이 구조는 강력하지만 입력의 구조를 별도로 가정하지 않는다. 이미지의 2차원 공간 구조나 문장의 순서를 처음부터 잘 살려 주지는 않는다. 모든 것을 벡터로 펼쳐서 처리하면 표현력은 있지만, 데이터가 가진 자연스러운 구조를 활용하기 어렵다.</p>

<h3 id="cnn">CNN</h3>

<p>CNN은 이미지처럼 공간 구조가 있는 데이터에 잘 맞는다. 핵심은 두 가지다.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>local receptive field: 가까운 영역만 본다.
weight sharing: 같은 필터를 여러 위치에 반복 적용한다.
</code></pre></div></div>

<p>합성곱층의 계산은 보통 다음처럼 볼 수 있다. 실제 딥러닝 프레임워크의 convolution layer는 엄밀한 수학적 convolution이라기보다 cross-correlation 형태로 구현되는 경우가 많지만, 학습되는 필터 관점에서는 같은 패턴 감지기로 이해해도 충분하다.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>y[i, j, k] = b[k] + sum_u sum_v sum_c W[u, v, c, k] * x[i+u, j+v, c]
</code></pre></div></div>

<p>CNN은 “어디에 있든 비슷한 지역 패턴은 같은 필터로 찾는다”는 강한 귀납 편향을 가진다. 그래서 이미지에서는 오래 강력했다. 다만 멀리 떨어진 요소 사이의 관계를 직접 계산하려면 층을 깊게 쌓거나 별도 구조가 필요하다.</p>

<h3 id="rnn">RNN</h3>

<p>RNN은 순서가 있는 데이터를 한 토큰씩 읽는다.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>h_t = phi(W_x x_t + W_h h_{t-1} + b)
</code></pre></div></div>

<p>여기서 <code class="language-plaintext highlighter-rouge">h_t</code>는 지금까지 읽은 정보를 담은 상태다. 구조가 직관적이고 효율적이지만, 긴 문맥에서는 과거 전체를 하나의 상태에 계속 압축해야 한다. LSTM/GRU는 이 문제를 완화하려고 게이트를 도입했지만, 기본적으로 순차 계산이라는 성격은 남는다.</p>

<p>이 차이는 기억을 저장하는 방식으로도 볼 수 있다. RNN은 과거를 하나의 상태에 압축하는 쪽에 가깝고, Transformer attention은 과거 토큰의 정보를 직접 조회하는 쪽에 가깝다. 둘 중 어느 하나가 항상 정답이라기보다, 과거 정보를 어느 해상도로 저장하고 어떻게 검색할지가 핵심이다.</p>

<h2 id="transformer의-핵심-전환">Transformer의 핵심 전환</h2>

<p>Transformer의 핵심은 recurrent state를 중심에 두지 않는다는 점이다. 각 토큰은 한 번에 다른 토큰들을 바라보고, 필요한 정보를 가중합으로 가져온다.</p>

<p>Transformer 원 논문의 표현을 빌리면, attention 함수는 query와 key-value 쌍들을 받아 output을 만든다. output은 value들의 가중합이고, 각 value에 주는 weight는 query와 key의 compatibility로 계산된다.</p>

<p>한 토큰이 다른 토큰을 읽는 흐름은 다음과 같다.</p>

<div class="transformer-visual">
  <p class="transformer-title">Self-attention 한 층의 직관</p>
  <div class="transformer-token-row">
    <div class="transformer-token"><em>토큰 1</em><strong>고양이</strong></div>
    <div class="transformer-token"><em>토큰 2</em><strong>생선을</strong></div>
    <div class="transformer-token"><em>토큰 3</em><strong>조용히</strong></div>
    <div class="transformer-token"><em>토큰 4</em><strong>먹었다</strong></div>
  </div>
  <div class="transformer-matrix">
    <div class="transformer-block attn">
      <strong>Query</strong>
      <span>`먹었다`가 지금 알고 싶은 것</span>
      <span>누가 먹었지? 무엇을 먹었지?</span>
    </div>
    <div>
      <div class="transformer-weight">
        <strong>고양이 0.36</strong>
        <span>주어 후보로 강하게 참고</span>
        <div class="transformer-bar"><i style="width:36%"></i></div>
      </div>
      <div class="transformer-weight">
        <strong>생선을 0.44</strong>
        <span>목적어 후보로 가장 강하게 참고</span>
        <div class="transformer-bar"><i style="width:44%"></i></div>
      </div>
      <div class="transformer-weight">
        <strong>조용히 0.12</strong>
        <span>행동 방식으로 약하게 참고</span>
        <div class="transformer-bar"><i style="width:12%"></i></div>
      </div>
      <div class="transformer-weight">
        <strong>먹었다 0.08</strong>
        <span>자기 자신도 조금 참고</span>
        <div class="transformer-bar"><i style="width:8%"></i></div>
      </div>
    </div>
    <div class="transformer-block mlp">
      <strong>Weighted sum of Values</strong>
      <span>`먹었다`의 새 표현은 주변 토큰 정보가 섞인 문맥 벡터가 된다.</span>
      <span class="transformer-chip">행동</span>
      <span class="transformer-chip">주어</span>
      <span class="transformer-chip">목적어</span>
      <span class="transformer-chip">어조</span>
    </div>
  </div>
</div>

<p>여기서 주의할 점이 있다. 이 그림의 숫자는 이해를 위한 예시다. 실제 모델에서는 head와 layer마다 attention weight가 다르고, 그 weight가 사람이 붙인 문법 규칙처럼 깔끔하게 해석된다고 보장할 수는 없다. 그래도 “토큰이 다른 토큰의 정보를 동적으로 가져온다”는 큰 직관은 맞다.</p>

<h2 id="q-k-v는-무엇인가">Q, K, V는 무엇인가</h2>

<p>입력 토큰 벡터들을 행렬 <code class="language-plaintext highlighter-rouge">X</code>라고 하자. 시퀀스 길이가 <code class="language-plaintext highlighter-rouge">n</code>, hidden dimension이 <code class="language-plaintext highlighter-rouge">d_model</code>이면 다음처럼 둘 수 있다.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>X in R^(n x d_model)
Q = X W_Q
K = X W_K
V = X W_V
</code></pre></div></div>

<p>각 토큰은 같은 원본 벡터에서 세 가지 역할의 벡터를 만든다.</p>

<table class="transformer-table">
  <thead>
    <tr>
      <th>벡터</th>
      <th>역할</th>
      <th>직관</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Query</td>
      <td>현재 토큰이 찾고 싶은 정보의 방향</td>
      <td>나는 지금 무엇을 물어보고 있나?</td>
    </tr>
    <tr>
      <td>Key</td>
      <td>각 토큰이 자신을 찾을 수 있게 내거는 색인</td>
      <td>나는 어떤 질문에 잘 맞는 정보인가?</td>
    </tr>
    <tr>
      <td>Value</td>
      <td>실제로 전달되는 정보 벡터</td>
      <td>나를 참고한다면 어떤 내용을 가져갈 것인가?</td>
    </tr>
  </tbody>
</table>

<p>attention의 전체 식은 다음과 같다.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Attention(Q, K, V) = softmax(QK^T / sqrt(d_k)) V
</code></pre></div></div>

<p>계산은 네 단계로 읽으면 된다.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>1. QK^T: 각 토큰의 query와 모든 토큰의 key를 내적한다.
2. / sqrt(d_k): 내적 값이 너무 커져 softmax가 뾰족해지는 것을 완화한다.
3. softmax: 참고 비중을 확률분포처럼 만든다.
4. * V: value들을 그 비중대로 가중합한다.
</code></pre></div></div>

<p>Q/K는 “얼마나 볼지”를 정하고 V는 “무엇을 가져올지”를 담당한다. 그래서 attention은 정보를 섞는 라우팅 시스템에 가깝다.</p>

<h2 id="multi-head-attention은-왜-필요한가">Multi-head attention은 왜 필요한가</h2>

<p>하나의 attention만 있으면 모든 관계를 한 가지 관점으로 봐야 한다. 문장 안에는 여러 관계가 동시에 있다. 주어-동사 관계, 수식어 관계, 지시어 관계, 문장 부호의 역할, 장거리 의존성 같은 것들이 서로 다른 방식으로 중요해진다.</p>

<p>Multi-head attention은 같은 입력을 여러 projection 공간으로 보내고, 각 head가 다른 attention을 계산하게 한다.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>head_i = Attention(XW_Q_i, XW_K_i, XW_V_i)
MultiHead(X) = Concat(head_1, ..., head_h) W_O
</code></pre></div></div>

<p>각 head가 반드시 사람이 이름 붙일 수 있는 역할 하나를 맡는다고 단정하면 위험하다. 그러나 여러 head가 서로 다른 부분공간에서 관계를 계산하기 때문에, 하나의 attention보다 다양한 상호작용을 표현할 수 있다.</p>

<h2 id="위치-정보가-없으면-순서를-모른다">위치 정보가 없으면 순서를 모른다</h2>

<p>self-attention만 보면 토큰 집합을 한꺼번에 비교한다. 그래서 별도 위치 정보가 없으면 순서가 바뀌어도 구조적으로 구분하기 어렵다. Transformer 원 논문은 sinusoidal positional encoding을 embedding에 더했다. 이후 모델들은 learned positional embedding, relative position, RoPE 같은 여러 방식을 쓴다.</p>

<p>핵심은 하나다.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>토큰의 의미 벡터 + 위치 신호 = 순서를 가진 토큰 표현
</code></pre></div></div>

<p>“나는 어떤 단어인가”와 “나는 어디에 있는가”를 같이 넣어야 문장 구조를 계산할 수 있다.</p>

<h2 id="mlp는-attention-뒤에서-무엇을-하나">MLP는 attention 뒤에서 무엇을 하나</h2>

<p>Transformer block 안에는 attention만 있는 것이 아니다. attention 뒤에는 position-wise feed-forward network, 흔히 MLP 또는 FFN이라 부르는 부분이 있다.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>FFN(x) = W_2 sigma(W_1 x + b_1) + b_2
</code></pre></div></div>

<p>Transformer 원 논문에서는 ReLU를 썼고, 이후 많은 모델은 GELU 같은 활성 함수를 사용한다. 일반적으로 <code class="language-plaintext highlighter-rouge">d_model</code>보다 큰 <code class="language-plaintext highlighter-rouge">d_ff</code>로 확장했다가 다시 줄인다.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>d_model -&gt; d_ff -&gt; d_model
예: 768 -&gt; 3072 -&gt; 768
</code></pre></div></div>

<p>중요한 구분은 이것이다.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Attention: 토큰 사이의 정보를 섞는다.
MLP/FFN: 각 토큰 벡터를 독립적으로 비선형 변환한다.
</code></pre></div></div>

<p>MLP는 토큰 간 통신을 직접 하지는 않는다. 그 일은 attention이 맡는다. 대신 attention이 모아 온 문맥 벡터를 더 복잡한 feature 공간으로 보내고, 비선형 변환을 거쳐 다음 층이 쓰기 좋은 표현으로 바꾼다.</p>

<div class="transformer-visual">
  <p class="transformer-title">Transformer block의 반복 구조</p>
  <div class="transformer-stack">
    <div class="transformer-block">
      <strong>입력 표현</strong>
      <span>token embedding + position</span>
      <span class="transformer-chip">x</span>
    </div>
    <div class="transformer-grid two">
      <div class="transformer-block attn">
        <strong>Multi-head attention</strong>
        <span>토큰 간 정보를 주고받는다.</span>
      </div>
      <div class="transformer-block norm">
        <strong>Add &amp; Norm</strong>
        <span>residual connection과 normalization으로 깊은 층을 안정화한다.</span>
      </div>
      <div class="transformer-block mlp">
        <strong>MLP / FFN</strong>
        <span>각 토큰의 문맥 벡터를 비선형 변환한다.</span>
      </div>
      <div class="transformer-block norm">
        <strong>Add &amp; Norm</strong>
        <span>다음 block으로 넘길 표현을 정돈한다.</span>
      </div>
    </div>
    <div class="transformer-block">
      <strong>출력 표현</strong>
      <span>더 깊은 문맥이 반영된 token state</span>
      <span class="transformer-chip">x'</span>
    </div>
  </div>
</div>

<p>여기서 residual connection은 입력을 바로 더해 주는 길이다. 깊은 모델에서 매 층이 표현을 완전히 갈아엎는 것이 아니라, 기존 표현 위에 필요한 변화량을 더하게 만든다. Layer normalization은 값의 스케일을 정돈해 학습을 안정적으로 만든다.</p>

<h2 id="encoder-decoder-decoder-only">Encoder, decoder, decoder-only</h2>

<p>Transformer는 처음에는 machine translation을 위한 encoder-decoder 구조로 제안되었다.</p>

<table class="transformer-table">
  <thead>
    <tr>
      <th>구조</th>
      <th>attention 방식</th>
      <th>대표 사용</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Encoder</td>
      <td>입력 전체를 양방향으로 본다.</td>
      <td>BERT 계열, 분류, 검색용 임베딩</td>
    </tr>
    <tr>
      <td>Decoder</td>
      <td>미래 토큰을 보지 못하게 causal mask를 쓴다.</td>
      <td>GPT 계열, 다음 토큰 생성</td>
    </tr>
    <tr>
      <td>Encoder-Decoder</td>
      <td>encoder 입력을 decoder가 cross-attention으로 참고한다.</td>
      <td>번역, 요약, 입력-출력 변환</td>
    </tr>
  </tbody>
</table>

<p>LLM이 다음 토큰을 생성할 때는 보통 decoder-only 구조를 쓴다. 현재까지의 토큰만 보고 다음 토큰 분포를 만든다.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>hidden state -&gt; vocabulary logits -&gt; softmax -&gt; next token distribution
</code></pre></div></div>

<p>여기서도 중요한 구분이 있다. embedding table이나 <code class="language-plaintext highlighter-rouge">W_Q</code>, <code class="language-plaintext highlighter-rouge">W_K</code>, <code class="language-plaintext highlighter-rouge">W_V</code> 같은 파라미터는 학습 후 추론 중에는 고정되어 있다. 그러나 각 토큰의 최종 표현과 attention weight는 입력 문맥에 따라 매번 달라진다. 즉 초기 임베딩은 고정된 테이블에서 시작하지만, 레이어를 지난 문맥 표현은 동적으로 바뀐다.</p>

<h2 id="cnn-rnn-transformer를-같은-지도-위에-놓기">CNN, RNN, Transformer를 같은 지도 위에 놓기</h2>

<p>세 구조는 서로를 단순히 대체했다기보다, 데이터의 어떤 구조를 먼저 믿을 것인가가 다르다.</p>

<div class="transformer-visual">
  <p class="transformer-title">구조별 귀납 편향과 비용</p>
  <div class="transformer-table-wrap">
    <table class="transformer-table">
      <thead>
        <tr>
          <th>구조</th>
          <th>먼저 믿는 것</th>
          <th>강점</th>
          <th>주의점</th>
        </tr>
      </thead>
      <tbody>
        <tr>
          <td>DNN / MLP</td>
          <td>충분한 파라미터와 비선형 변환</td>
          <td>일반적인 함수 근사에 강하다.</td>
          <td>입력 구조를 직접 활용하는 편향은 약하다.</td>
        </tr>
        <tr>
          <td>CNN</td>
          <td>지역성, 가중치 공유, 위치 이동에 대한 견고함</td>
          <td>이미지와 격자형 데이터에서 효율적이다.</td>
          <td>멀리 떨어진 요소 관계는 직접 보기 어렵다.</td>
        </tr>
        <tr>
          <td>RNN / LSTM</td>
          <td>순서대로 읽으며 상태에 기억을 누적</td>
          <td>streaming 처리와 순차 구조에 자연스럽다.</td>
          <td>긴 과거를 하나의 상태에 압축해야 하고 병렬화가 어렵다.</td>
        </tr>
        <tr>
          <td>Transformer</td>
          <td>토큰 간 직접 조회와 동적 가중합</td>
          <td>장거리 관계를 직접 모델링하고 병렬화가 좋다.</td>
          <td>일반 self-attention은 시퀀스 길이에 대해 대략 O(n^2) 비용이 든다.</td>
        </tr>
      </tbody>
    </table>
  </div>
</div>

<p>Transformer가 강력한 이유는 “모든 문제에서 CNN/RNN보다 항상 낫다”가 아니다. 핵심은 token들이 서로를 직접 조회하면서 문맥 표현을 동적으로 만든다는 점이다. 대신 긴 시퀀스에서는 attention matrix가 커진다. 그래서 efficient attention, sparse attention, linear attention, recurrent memory, memory caching 같은 연구들이 계속 이어진다.</p>

<h2 id="한-층을-실제-계산처럼-따라가기">한 층을 실제 계산처럼 따라가기</h2>

<p>문장 <code class="language-plaintext highlighter-rouge">고양이 생선을 먹었다</code>를 아주 작게 벡터화했다고 생각해 보자.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>X = [x_고양이, x_생선을, x_먹었다]
Q = XW_Q
K = XW_K
V = XW_V
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">먹었다</code> 위치의 query를 <code class="language-plaintext highlighter-rouge">q_먹었다</code>라고 하면, 이 query는 모든 key와 내적된다.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>s = [
  q_먹었다 · k_고양이,
  q_먹었다 · k_생선을,
  q_먹었다 · k_먹었다
]
</code></pre></div></div>

<p>그 다음 softmax를 거친다.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>alpha = softmax(s / sqrt(d_k))
</code></pre></div></div>

<p>마지막으로 value를 섞는다.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>z_먹었다 =
  alpha_1 * v_고양이 +
  alpha_2 * v_생선을 +
  alpha_3 * v_먹었다
</code></pre></div></div>

<p>이 <code class="language-plaintext highlighter-rouge">z_먹었다</code>는 더 이상 단순히 “먹었다”라는 토큰의 정적 벡터가 아니다. “고양이가 생선을 먹었다”라는 문맥이 반영된 벡터다. 다음 block으로 넘어가면 이 과정이 다시 반복되고, 더 추상적인 관계가 쌓인다.</p>

<h2 id="오해하기-쉬운-부분">오해하기 쉬운 부분</h2>

<p>첫째, attention weight가 곧 완전한 설명은 아니다. 특정 head의 weight가 어떤 토큰을 많이 본다고 해서, 모델의 최종 판단 이유가 그 토큰 하나라고 단정할 수는 없다. 여러 head, 여러 layer, MLP, residual path가 함께 작동한다.</p>

<p>둘째, MLP의 뉴런 하나가 항상 사람이 읽을 수 있는 feature 하나를 담당한다고 보면 위험하다. 어떤 뉴런이나 방향이 특정 feature와 강하게 상관될 수는 있지만, 실제 표현은 대개 여러 차원에 분산되어 있다.</p>

<p>셋째, positional encoding이 “순서를 학습한다”는 말은 조금 조심해야 한다. 위치 신호를 넣어 주면 attention이 위치까지 포함한 관계를 계산할 수 있게 된다. 위치 정보를 어떤 방식으로 넣을지는 모델마다 다르다.</p>

<p>넷째, Transformer는 recurrence와 convolution을 기본 골격에서 제거했지만, 현대 모델 생태계에서는 CNN, RNN, attention, memory가 다시 섞이고 있다. 특히 vision transformer는 이미지를 patch sequence로 바꿔 attention을 적용하고, 반대로 일부 언어 모델 연구는 recurrent memory나 state-space 계열을 다시 탐색한다.</p>

<h2 id="정리">정리</h2>

<p>Transformer를 한 문장으로 정리하면 이렇다.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>각 토큰이 문맥 속의 다른 토큰들을 직접 조회해 새 표현을 만들고,
그 표현을 MLP로 다시 해석하는 block을 깊게 쌓은 모델.
</code></pre></div></div>

<p>DNN은 벡터를 비선형 함수로 바꿨고, CNN은 지역 패턴을 효율적으로 찾았고, RNN은 순서를 상태에 누적했다. Transformer는 여기서 한 걸음 옮겨 “지금 이 토큰이 어떤 토큰을 참고해야 하는가”를 매 입력마다 다시 계산한다.</p>

<p>그래서 같은 단어라도 문맥이 바뀌면 다른 벡터가 된다. 커피잔 옆에 놓인 “별”과 천문학 문서 속의 “별”은 같은 글자일 수 있지만, 모델 안에서는 서로 다른 밤하늘을 지나간다.</p>

<h2 id="검증하며-보정한-점">검증하며 보정한 점</h2>

<ul>
  <li>Transformer의 self-attention 수식과 encoder-decoder 구조는 Vaswani et al.의 원 논문을 기준으로 확인했다.</li>
  <li>attention 자체의 계보는 Bahdanau, Cho, Bengio의 neural machine translation 논문에서 제안된 alignment/attention 아이디어와 함께 보는 것이 정확하다.</li>
  <li>CNN 설명은 LeCun et al.의 LeNet/문서 인식 논문을 기준으로, 지역 수용영역과 가중치 공유 중심으로 정리했다.</li>
  <li>RNN의 장기 의존성 문제와 LSTM의 위치는 Hochreiter &amp; Schmidhuber의 논문을 기준으로 확인했다.</li>
  <li>MLP 뉴런을 “특정 feature detector”로 단정하는 표현은 분산 표현 관점에서 완화했다.</li>
  <li>“embedding은 고정이지만 문맥 표현은 동적”이라는 설명은 추론 시 학습 파라미터는 고정되고, activation/attention weight는 입력에 따라 달라진다는 식으로 보정했다.</li>
</ul>

<h2 id="참고-자료">참고 자료</h2>

<ul>
  <li>Vaswani et al., <a href="https://arxiv.org/abs/1706.03762">Attention Is All You Need</a>, 2017.</li>
  <li>Bahdanau, Cho, Bengio, <a href="https://arxiv.org/abs/1409.0473">Neural Machine Translation by Jointly Learning to Align and Translate</a>, 2014.</li>
  <li>LeCun et al., <a href="https://ieeexplore.ieee.org/document/726791">Gradient-Based Learning Applied to Document Recognition</a>, 1998.</li>
  <li>Hochreiter, Schmidhuber, <a href="https://direct.mit.edu/neco/article/9/8/1735/6109/Long-Short-Term-Memory">Long Short-Term Memory</a>, 1997.</li>
  <li>Rumelhart, Hinton, Williams, <a href="https://www.nature.com/articles/323533a0">Learning representations by back-propagating errors</a>, 1986.</li>
  <li>Hendrycks, Gimpel, <a href="https://arxiv.org/abs/1606.08415">Gaussian Error Linear Units (GELUs)</a>, 2016.</li>
  <li>Dosovitskiy et al., <a href="https://arxiv.org/abs/2010.11929">An Image is Worth 16x16 Words: Transformers for Image Recognition at Scale</a>, 2020.</li>
  <li>Behrouz et al., <a href="https://arxiv.org/abs/2602.24281">Memory Caching: RNNs with Growing Memory</a>, 2026.</li>
</ul>]]></content><author><name>WooHyuk</name></author><category term="develop" /><category term="ai" /><category term="transformer" /><category term="attention" /><category term="neural-network" /><category term="deep-learning" /><category term="llm" /><summary type="html"><![CDATA[DNN, CNN, RNN에서 Transformer로 이어지는 흐름과 self-attention, multi-head attention, MLP/FFN, positional encoding을 수식과 직관으로 정리합니다.]]></summary></entry><entry><title type="html">마르코프 체인은 어떻게 현재만 보고 미래를 계산하는가</title><link href="https://dngur6344.github.io/develop/2026-06-14-markov-chain/" rel="alternate" type="text/html" title="마르코프 체인은 어떻게 현재만 보고 미래를 계산하는가" /><published>2026-06-14T00:00:00+09:00</published><updated>2026-06-14T00:00:00+09:00</updated><id>https://dngur6344.github.io/develop/markov-chain</id><content type="html" xml:base="https://dngur6344.github.io/develop/2026-06-14-markov-chain/"><![CDATA[<p>마르코프 체인은 어떤 시스템이 여러 상태 사이를 확률적으로 이동하는 과정을 다룬다. 날씨가 맑음에서 흐림으로 바뀌거나, 사용자가 홈 화면에서 상품 페이지로 이동하거나, 서버가 정상 상태에서 과부하 상태로 바뀌는 일을 하나의 상태 전이 시스템으로 보는 방식이다.</p>

<p>핵심은 단순하다.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>다음 상태의 분포는 과거 전체가 아니라 현재 상태에 의해 결정된다.
</code></pre></div></div>

<p>이 말은 과거가 아무 의미 없다는 뜻이 아니다. 과거의 정보가 현재 상태 안에 충분히 요약되어 있다면, 다음을 예측할 때는 현재 상태만 보면 된다는 뜻이다.</p>

<style>
.markov-visual {
  --markov-bg: linear-gradient(135deg, rgba(50, 34, 25, .96), rgba(8, 12, 24, .97));
  --markov-panel: rgba(255, 250, 242, .075);
  --markov-line: rgba(255, 250, 242, .18);
  --markov-ink: #fffaf2;
  --markov-muted: rgba(255, 250, 242, .72);
  --markov-gold: #dfb976;
  --markov-blue: #92b9df;
  --markov-green: #93c7a3;
  --markov-red: #db8d8d;
  margin: 1.25rem 0 1.6rem;
  padding: .95rem;
  border: 1px solid rgba(255, 250, 242, .13);
  border-radius: 8px;
  color: var(--markov-ink);
  background: var(--markov-bg);
  box-shadow: 0 1rem 2.4rem rgba(8, 10, 17, .2);
}

.markov-title {
  margin: 0 0 .75rem;
  color: var(--markov-ink);
  font-size: .78rem;
  font-weight: 700;
}

.markov-grid,
.markov-flow,
.markov-state-map,
.markov-balance {
  display: grid;
  gap: .65rem;
}

.markov-grid.two {
  grid-template-columns: repeat(2, minmax(0, 1fr));
}

.markov-grid.three,
.markov-state-map {
  grid-template-columns: repeat(3, minmax(0, 1fr));
}

.markov-flow.four {
  grid-template-columns: repeat(4, minmax(0, 1fr));
}

.markov-card,
.markov-step,
.markov-state,
.markov-balance-row {
  min-width: 0;
  border: 1px solid var(--markov-line);
  border-radius: 6px;
  background: var(--markov-panel);
}

.markov-card,
.markov-state,
.markov-balance-row {
  padding: .72rem;
}

.markov-step {
  position: relative;
  padding: .62rem;
}

.markov-step:not(:last-child)::after {
  content: "->";
  position: absolute;
  right: -.49rem;
  top: 50%;
  color: rgba(255, 250, 242, .58);
  font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
  font-size: .66rem;
  transform: translate(50%, -50%);
}

.markov-visual b,
.markov-visual strong {
  display: block;
  color: var(--markov-ink);
  font-size: .68rem;
  line-height: 1.35;
}

.markov-visual span,
.markov-visual p {
  display: block;
  margin: .22rem 0 0;
  color: var(--markov-muted);
  font-size: .62rem;
  line-height: 1.45;
}

.markov-visual code {
  color: var(--markov-ink);
  background: rgba(8, 10, 17, .34);
}

.markov-state {
  text-align: center;
}

.markov-state b {
  margin: 0 auto .45rem;
  width: 2.35rem;
  height: 2.35rem;
  border: 1px solid rgba(223, 185, 118, .48);
  border-radius: 50%;
  background: rgba(223, 185, 118, .13);
  font-size: .84rem;
  line-height: 2.35rem;
}

.markov-state em {
  display: block;
  margin-top: .25rem;
  color: var(--markov-muted);
  font-style: normal;
  font-size: .58rem;
  line-height: 1.4;
}

.markov-balance {
  grid-template-columns: repeat(3, minmax(0, 1fr));
}

.markov-balance-row {
  border-color: rgba(147, 199, 163, .3);
  background: rgba(147, 199, 163, .09);
}

.markov-chip-row {
  display: flex;
  flex-wrap: wrap;
  gap: .4rem;
  margin-top: .62rem;
}

.markov-chip {
  padding: .32rem .46rem;
  border: 1px solid rgba(223, 185, 118, .38);
  border-radius: 999px;
  color: var(--markov-ink);
  background: rgba(223, 185, 118, .12);
  font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
  font-size: .56rem;
}

.markov-chip[data-tone="blue"] {
  border-color: rgba(146, 185, 223, .42);
  background: rgba(146, 185, 223, .13);
}

.markov-chip[data-tone="green"] {
  border-color: rgba(147, 199, 163, .42);
  background: rgba(147, 199, 163, .13);
}

.markov-chip[data-tone="red"] {
  border-color: rgba(219, 141, 141, .42);
  background: rgba(219, 141, 141, .12);
}

.markov-table {
  width: 100%;
  border: 1px solid rgba(143, 94, 60, .32);
  border-collapse: separate;
  border-spacing: 0;
  border-radius: 6px;
  overflow: hidden;
  background: rgba(255, 250, 242, .94) !important;
  font-size: .88rem;
}

.markov-table th,
.markov-table td {
  border: 0;
  border-bottom: 1px solid rgba(143, 94, 60, .22);
  color: var(--coffee-ink) !important;
  vertical-align: top;
}

.markov-table th {
  background: rgba(47, 33, 24, .92) !important;
  color: #fffaf2 !important;
  font-weight: 700;
}

.markov-table tbody tr:nth-child(odd) td {
  background: rgba(255, 250, 242, .92) !important;
}

.markov-table tbody tr:nth-child(even) td {
  background: rgba(247, 236, 222, .94) !important;
}

.markov-table tbody tr:last-child td {
  border-bottom: 0;
}

.markov-table td:first-child {
  color: #4d2d1e !important;
  font-weight: 700;
}

body.dark-mode .markov-table {
  background: rgba(9, 13, 22, .9) !important;
  border-color: rgba(231, 212, 189, .24) !important;
  box-shadow: 0 1rem 2rem rgba(0, 0, 0, .22) !important;
}

body.dark-mode .markov-table th {
  color: #fff4e5 !important;
  background: rgba(244, 234, 220, .12) !important;
}

body.dark-mode .markov-table td {
  color: #ead8c3 !important;
  border-color: rgba(231, 212, 189, .18) !important;
}

body.dark-mode .markov-table tbody tr:nth-child(odd) td {
  background: rgba(255, 250, 242, .045) !important;
}

body.dark-mode .markov-table tbody tr:nth-child(even) td {
  background: rgba(255, 250, 242, .074) !important;
}

body.dark-mode .markov-table td:first-child {
  color: #f2c98c !important;
}

body.dark-mode .markov-table code {
  color: #fff4e5 !important;
  background: rgba(255, 250, 242, .08) !important;
}

@media (max-width: 760px) {
  .markov-grid.two,
  .markov-grid.three,
  .markov-flow.four,
  .markov-state-map,
  .markov-balance {
    grid-template-columns: 1fr;
  }

  .markov-step:not(:last-child)::after {
    content: "";
    display: none;
  }
}
</style>

<div class="markov-visual">
  <p class="markov-title">마르코프 체인의 기본 형태</p>
  <div class="markov-flow four">
    <div class="markov-step">
      <b>State</b>
      <span>시스템이 지금 놓인 상태다. 예: 맑음, 흐림, 비.</span>
    </div>
    <div class="markov-step">
      <b>Transition</b>
      <span>현재 상태에서 다음 상태로 이동할 확률이다.</span>
    </div>
    <div class="markov-step">
      <b>Matrix</b>
      <span>모든 전이 확률을 행렬 <code>P</code>에 모은다.</span>
    </div>
    <div class="markov-step">
      <b>Distribution</b>
      <span>시간이 흐를수록 상태 확률 벡터가 변한다.</span>
    </div>
  </div>
</div>

<h2 id="마르코프-성질">마르코프 성질</h2>

<p>확률 과정 <code class="language-plaintext highlighter-rouge">X_0, X_1, X_2, ...</code>가 있을 때, 이 과정이 마르코프 체인이라는 말은 다음 조건을 만족한다는 뜻이다.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>P(X_{n+1} = j | X_n = i, X_{n-1}, ..., X_0)
=
P(X_{n+1} = j | X_n = i)
</code></pre></div></div>

<p>미래를 예측할 때 과거 전체를 다시 펼쳐 보지 않고, 현재 상태 <code class="language-plaintext highlighter-rouge">X_n</code>만 사용한다. 이 성질을 마르코프 성질이라고 한다.</p>

<p>주의할 점이 있다. 마르코프 성질은 “다음 상태가 현재 상태와 독립”이라는 뜻이 아니다. 오히려 다음 상태는 현재 상태에 강하게 의존한다. 다만 현재 상태를 알고 나면, 그 이전의 이력은 추가 정보를 주지 않는다는 뜻이다.</p>

<h2 id="전이확률행렬">전이확률행렬</h2>

<p>상태가 <code class="language-plaintext highlighter-rouge">A</code>, <code class="language-plaintext highlighter-rouge">B</code>, <code class="language-plaintext highlighter-rouge">C</code> 세 개라고 하자. 한 단계 뒤 어디로 갈지의 확률은 행렬로 표현할 수 있다.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>       next A  next B  next C
A      0.6     0.4     0.0
B      0.3     0.5     0.2
C      0.0     0.6     0.4
</code></pre></div></div>

<p>이 글에서는 행 벡터 관례를 사용한다. 즉, 현재 분포 <code class="language-plaintext highlighter-rouge">pi^(0)</code>를 왼쪽에 두고 오른쪽에 전이행렬 <code class="language-plaintext highlighter-rouge">P</code>를 곱한다.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>pi^(1) = pi^(0) P
pi^(2) = pi^(0) P^2
pi^(n) = pi^(0) P^n
</code></pre></div></div>

<p>행렬의 각 행은 현재 상태 하나를 의미한다. 어떤 상태에 있든 다음 단계에는 반드시 어딘가의 상태가 되어야 하므로, 각 행의 합은 1이어야 한다. 이런 행렬을 row-stochastic matrix라고 부른다.</p>

<div class="markov-visual">
  <p class="markov-title">상태 전이 예시</p>
  <div class="markov-state-map">
    <div class="markov-state">
      <b>A</b>
      <span>A -&gt; A: 0.6</span>
      <em>A -&gt; B: 0.4</em>
    </div>
    <div class="markov-state">
      <b>B</b>
      <span>B -&gt; A: 0.3</span>
      <em>B -&gt; B: 0.5, B -&gt; C: 0.2</em>
    </div>
    <div class="markov-state">
      <b>C</b>
      <span>C -&gt; B: 0.6</span>
      <em>C -&gt; C: 0.4</em>
    </div>
  </div>
</div>

<h2 id="n-step-전이-계산">n-step 전이 계산</h2>

<p>초기 상태가 반드시 <code class="language-plaintext highlighter-rouge">A</code>라고 하자.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>pi^(0) = [1, 0, 0]
</code></pre></div></div>

<p>한 단계 뒤에는 전이행렬의 첫 번째 행 그대로가 된다.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>pi^(1) = [1, 0, 0] P
       = [0.6, 0.4, 0.0]
</code></pre></div></div>

<p>두 단계 뒤에는 다시 한 번 <code class="language-plaintext highlighter-rouge">P</code>를 곱한다.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>pi^(2) = [0.6, 0.4, 0.0] P
       = [0.48, 0.44, 0.08]
</code></pre></div></div>

<p>이 값이 중요하다. Notion 원문에는 같은 예시의 두 단계 뒤 분포가 <code class="language-plaintext highlighter-rouge">[0.42, 0.44, 0.14]</code>로 적혀 있었지만, 행 벡터 관례와 위 전이행렬을 그대로 사용하면 올바른 값은 <code class="language-plaintext highlighter-rouge">[0.48, 0.44, 0.08]</code>이다.</p>

<h2 id="정지분포와-수렴분포">정지분포와 수렴분포</h2>

<p>정지분포는 전이 이후에도 변하지 않는 분포다.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>pi = pi P
sum(pi_i) = 1
pi_i &gt;= 0
</code></pre></div></div>

<p>위 예시의 정지분포를 풀어보면 다음과 같다.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>pi = [0.36, 0.48, 0.16]
</code></pre></div></div>

<p>확인해보면 <code class="language-plaintext highlighter-rouge">pi P = pi</code>가 된다.</p>

<div class="markov-visual">
  <p class="markov-title">정지분포의 의미</p>
  <div class="markov-balance">
    <div class="markov-balance-row">
      <b>A</b>
      <span>장기적으로 약 36%의 시간은 A에 있다.</span>
    </div>
    <div class="markov-balance-row">
      <b>B</b>
      <span>장기적으로 약 48%의 시간은 B에 있다.</span>
    </div>
    <div class="markov-balance-row">
      <b>C</b>
      <span>장기적으로 약 16%의 시간은 C에 있다.</span>
    </div>
  </div>
</div>

<p>정지분포와 수렴분포는 비슷해 보이지만 구분해야 한다.</p>

<p>정지분포는 “이미 이 분포로 시작하면 한 단계 뒤에도 그대로”라는 대수적 조건이다. 반면 수렴분포는 “어떤 초기 상태에서 시작해도 시간이 충분히 지나면 그 분포로 가까워지는가”라는 극한 조건이다.</p>

<p>유한 상태 마르코프 체인에서는 다음처럼 정리할 수 있다.</p>

<table class="markov-table">
  <thead>
    <tr>
      <th>조건</th>
      <th>의미</th>
      <th>결과</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>유한 상태</td>
      <td>상태 수가 유한하다.</td>
      <td>적어도 하나의 정지분포가 존재한다.</td>
    </tr>
    <tr>
      <td>불가약</td>
      <td>어떤 상태에서든 충분한 단계 뒤 다른 모든 상태로 갈 수 있다.</td>
      <td>유한 체인에서는 정지분포가 유일하다.</td>
    </tr>
    <tr>
      <td>비주기적</td>
      <td>특정 주기마다만 되돌아오는 구조가 아니다.</td>
      <td>불가약성과 함께 있으면 분포가 정지분포로 수렴한다.</td>
    </tr>
    <tr>
      <td>가산 무한 상태</td>
      <td>상태가 무한하지만 셀 수 있다.</td>
      <td>불가약, 비주기성만으로는 부족하고 positive recurrence 조건이 필요하다.</td>
    </tr>
  </tbody>
</table>

<p>즉 “불가약 + 비주기적이면 항상 하나의 분포로 수렴한다”는 말은 유한 상태 체인에서는 안전하지만, 무한 상태 공간까지 일반화하려면 positive recurrence를 함께 확인해야 한다.</p>

<h2 id="상태를-분류하는-말들">상태를 분류하는 말들</h2>

<p>마르코프 체인을 읽다 보면 여러 성질이 나온다. 처음에는 아래 정도를 구분하면 충분하다.</p>

<table class="markov-table">
  <thead>
    <tr>
      <th>개념</th>
      <th>뜻</th>
      <th>왜 중요한가</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>불가약</td>
      <td>모든 상태가 하나의 연결된 세계 안에 있다.</td>
      <td>장기 분포가 초기 상태에 덜 의존하게 된다.</td>
    </tr>
    <tr>
      <td>주기성</td>
      <td>어떤 상태로 돌아오는 시간이 특정 주기의 배수로만 가능하다.</td>
      <td>주기가 있으면 분포가 흔들리며 수렴하지 않을 수 있다.</td>
    </tr>
    <tr>
      <td>흡수 상태</td>
      <td>들어가면 빠져나오지 못하는 상태다.</td>
      <td>장기적으로 어디에 흡수되는지가 핵심 문제가 된다.</td>
    </tr>
    <tr>
      <td>재귀 상태</td>
      <td>언젠가 다시 돌아올 확률이 1인 상태다.</td>
      <td>무한 상태 체인에서 positive/null recurrence를 구분해야 한다.</td>
    </tr>
    <tr>
      <td>가역성</td>
      <td><code>pi_i P_ij = pi_j P_ji</code>를 만족한다.</td>
      <td>상세균형을 이용해 정지분포를 쉽게 검증할 수 있다.</td>
    </tr>
  </tbody>
</table>

<h2 id="흡수-마르코프-체인">흡수 마르코프 체인</h2>

<p>흡수 상태가 있는 체인은 장기분포를 볼 때 조심해야 한다. 예를 들어 <code class="language-plaintext highlighter-rouge">종료</code>, <code class="language-plaintext highlighter-rouge">장애</code>, <code class="language-plaintext highlighter-rouge">결제 완료</code> 같은 상태는 한 번 들어가면 더 이상 빠져나오지 않는 상태로 모델링할 수 있다.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>P(absorb -&gt; absorb) = 1
</code></pre></div></div>

<p>이런 체인에서는 “전체가 하나의 평형으로 섞인다”보다 “어느 흡수 상태에 도달할 확률이 얼마인가”, “흡수되기까지 평균 몇 단계가 걸리는가”가 더 자연스러운 질문이다.</p>

<h2 id="연속시간-마르코프-체인">연속시간 마르코프 체인</h2>

<p>Notion 원문에는 DTMC와 CTMC가 함께 언급되어 있다. 둘은 상태 전이의 철학은 같지만 시간 모델이 다르다.</p>

<table class="markov-table">
  <thead>
    <tr>
      <th>구분</th>
      <th>시간</th>
      <th>핵심 객체</th>
      <th>정지 조건</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>DTMC</td>
      <td>0, 1, 2처럼 단계가 나뉜다.</td>
      <td>전이확률행렬 <code>P</code></td>
      <td><code>pi = pi P</code></td>
    </tr>
    <tr>
      <td>CTMC</td>
      <td>시간이 연속적으로 흐른다.</td>
      <td>전이율 행렬 또는 generator <code>Q</code></td>
      <td><code>pi Q = 0</code></td>
    </tr>
  </tbody>
</table>

<p>CTMC에서는 “다음 단계로 갈 확률”보다 “얼마의 rate로 다른 상태로 점프하는가”가 중심이 된다. 예를 들어 서버 장애 모델에서 정상 상태가 평균적으로 100시간 유지되고, 복구 상태가 평균적으로 2시간 걸리는 식의 시간을 직접 다룰 수 있다.</p>

<h2 id="어디에-쓰이는가">어디에 쓰이는가</h2>

<p>마르코프 체인은 단순한 수학 장난이 아니라, “현재 상태를 잘 정의하면 다음 흐름을 확률적으로 계산할 수 있다”는 틀이다.</p>

<div class="markov-visual">
  <p class="markov-title">응용을 읽는 방식</p>
  <div class="markov-grid three">
    <div class="markov-card">
      <b>PageRank</b>
      <span>웹 페이지를 상태로 보고, 링크 클릭을 전이로 본다. 장기 방문 비율이 중요도 점수가 된다.</span>
    </div>
    <div class="markov-card">
      <b>HMM</b>
      <span>품사, 음성, 생물정보처럼 숨은 상태가 있고 관측값만 보이는 문제에 사용된다.</span>
    </div>
    <div class="markov-card">
      <b>MCMC</b>
      <span>원하는 분포를 정지분포로 갖는 체인을 만들어 복잡한 분포에서 샘플링한다.</span>
    </div>
    <div class="markov-card">
      <b>추천/세션</b>
      <span>사용자 행동을 페이지나 상품 상태의 이동으로 보고 다음 행동을 예측한다.</span>
    </div>
    <div class="markov-card">
      <b>시스템 신뢰성</b>
      <span>정상, 과부하, 장애, 복구 상태 사이의 이동을 확률적으로 분석한다.</span>
    </div>
    <div class="markov-card">
      <b>대기행렬</b>
      <span>요청 수, 큐 길이, 서버 상태가 시간에 따라 어떻게 변하는지 모델링한다.</span>
    </div>
  </div>
</div>

<p>PageRank는 특히 좋은 예다. 웹 페이지를 그래프의 노드로 두고, 사용자가 링크를 따라 무작위로 이동한다고 보면 하나의 마르코프 체인이 된다. 장기적으로 어떤 페이지에 오래 머무는지가 그 페이지의 중요도와 연결된다. 실제 PageRank에는 dangling node와 spider trap 문제를 피하기 위한 teleportation이 들어가는데, 이 역시 체인이 잘 섞이도록 만드는 장치로 이해할 수 있다.</p>

<h2 id="글을-읽을-때의-기준">글을 읽을 때의 기준</h2>

<p>마르코프 체인 문서를 볼 때는 아래 질문을 순서대로 던지면 덜 헷갈린다.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>1. 상태는 무엇인가?
2. 시간은 discrete인가, continuous인가?
3. 전이확률행렬 P 또는 generator Q는 어떻게 정의되는가?
4. 행 기준인가, 열 기준인가?
5. 정지분포를 묻는가, 수렴분포를 묻는가?
6. 불가약성, 비주기성, 흡수 상태는 어떻게 되는가?
</code></pre></div></div>

<p>특히 4번이 중요하다. 어떤 책은 행 벡터를 쓰고 <code class="language-plaintext highlighter-rouge">pi P</code>를 계산한다. 어떤 책은 열 벡터를 쓰고 <code class="language-plaintext highlighter-rouge">P pi</code>를 계산한다. 둘 중 어느 쪽이든 수학적으로는 가능하지만, 한 글 안에서는 관례를 섞으면 계산이 틀어진다.</p>

<h2 id="정리">정리</h2>

<p>마르코프 체인은 복잡한 이력을 현재 상태 하나로 접어 넣는 모델이다. 상태 정의가 잘 되어 있으면, 다음 상태는 전이확률행렬로 계산할 수 있고, 여러 단계를 지나면 <code class="language-plaintext highlighter-rouge">P^n</code>이 시스템의 장기 행동을 보여준다.</p>

<p>다만 장기적으로 안정된 분포가 생기는지는 조건을 봐야 한다. 유한 상태에서 불가약이고 비주기적인 체인은 유일한 정지분포로 수렴한다. 흡수 상태가 있거나 주기성이 있거나 무한 상태 공간이라면 이야기가 달라질 수 있다.</p>

<p>그래서 마르코프 체인의 핵심은 “현재만 본다”가 아니라, “미래를 계산하기에 충분한 현재 상태를 어떻게 정의할 것인가”에 있다.</p>

<h2 id="참고한-자료">참고한 자료</h2>

<ul>
  <li><a href="https://ocw.mit.edu/courses/6-041-probabilistic-systems-analysis-and-applied-probability-fall-2010/resources/lecture-16-markov-chains-i/">MIT OpenCourseWare, Lecture 16: Markov Chains I</a></li>
  <li><a href="https://www.math.cmu.edu/~gautam/c/2025-326/notes/markov2-stationary.html">CMU Math, Markov Chains: The Stationary Distribution</a></li>
  <li><a href="https://www.probabilitycourse.com/chapter11/11_2_6_stationary_and_limiting_distributions.php">ProbabilityCourse.com, Stationary and Limiting Distributions for DTMC</a></li>
  <li><a href="https://www.probabilitycourse.com/chapter11/11_3_2_stationary_and_limiting_distributions.php">ProbabilityCourse.com, Stationary and Limiting Distributions for CTMC</a></li>
  <li><a href="https://stanford.edu/~rezab/classes/cme323/S15/notes/lec7.pdf">Stanford CME 323, PageRank lecture notes</a></li>
</ul>]]></content><author><name>WooHyuk</name></author><category term="develop" /><category term="math" /><category term="probability" /><category term="markov-chain" /><category term="stochastic-process" /><summary type="html"><![CDATA[마르코프 성질, 전이확률행렬, n-step 전이, 정지분포, 수렴 조건, 흡수 상태와 가역성을 중심으로 마르코프 체인을 정리합니다.]]></summary></entry><entry><title type="html">Diffusion 모델은 어떻게 노이즈에서 이미지를 꺼낼까</title><link href="https://dngur6344.github.io/develop/2026-06-12-diffusion-models/" rel="alternate" type="text/html" title="Diffusion 모델은 어떻게 노이즈에서 이미지를 꺼낼까" /><published>2026-06-12T00:00:00+09:00</published><updated>2026-06-12T00:00:00+09:00</updated><id>https://dngur6344.github.io/develop/diffusion-models</id><content type="html" xml:base="https://dngur6344.github.io/develop/2026-06-12-diffusion-models/"><![CDATA[<p>Diffusion 모델을 한 문장으로 줄이면 이렇다.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>데이터를 일부러 노이즈로 흩뜨린 뒤,
그 흩어진 길을 거꾸로 따라오는 방법을 학습하는 생성 모델.
</code></pre></div></div>

<p>이미지 생성 결과만 보면 마법처럼 느껴지지만, 안쪽의 학습 신호는 의외로 단정하다. 깨끗한 이미지에 우리가 직접 가우시안 노이즈를 섞고, 모델에게 “지금 섞인 노이즈가 무엇인지 맞혀보라”고 시킨다. 이 일을 충분히 잘하게 되면, 모델은 순수한 노이즈에서 시작해서 조금씩 자연스러운 이미지 쪽으로 이동하는 길을 만들 수 있다.</p>

<p>이 글은 Notion의 Diffusion, 이미지 분포, Stable Diffusion, AE/VAE/Latent 관련 문서들을 하나로 묶어 정리한 글이다. 직관은 포근하게 잡되, 수식과 용어는 가능한 정확하게 두었다.</p>

<style>
.diffusion-visual {
  --diff-bg: linear-gradient(135deg, rgba(50, 34, 25, .96), rgba(8, 12, 24, .97));
  --diff-panel: rgba(255, 250, 242, .075);
  --diff-panel-strong: rgba(255, 250, 242, .13);
  --diff-line: rgba(255, 250, 242, .18);
  --diff-ink: #fffaf2;
  --diff-muted: rgba(255, 250, 242, .72);
  --diff-gold: #dfb976;
  --diff-blue: #92b9df;
  --diff-green: #93c7a3;
  --diff-red: #db8d8d;
  margin: 1.25rem 0 1.6rem;
  padding: .95rem;
  border: 1px solid rgba(255, 250, 242, .13);
  border-radius: 8px;
  color: var(--diff-ink);
  background: var(--diff-bg);
  box-shadow: 0 1rem 2.4rem rgba(8, 10, 17, .2);
}

.diffusion-title {
  margin: 0 0 .75rem;
  color: var(--diff-ink);
  font-size: .78rem;
  font-weight: 700;
}

.diffusion-grid,
.diffusion-flow,
.diffusion-lanes,
.diffusion-stack,
.diffusion-tensor,
.diffusion-field {
  display: grid;
  gap: .65rem;
}

.diffusion-grid.two,
.diffusion-lanes {
  grid-template-columns: repeat(2, minmax(0, 1fr));
}

.diffusion-grid.three {
  grid-template-columns: repeat(3, minmax(0, 1fr));
}

.diffusion-flow.four {
  grid-template-columns: repeat(4, minmax(0, 1fr));
}

.diffusion-flow.five {
  grid-template-columns: repeat(5, minmax(0, 1fr));
}

.diffusion-stack {
  grid-template-columns: .9fr 1.15fr .9fr;
  align-items: stretch;
}

.diffusion-card,
.diffusion-step,
.diffusion-lane,
.diffusion-tensor-cell,
.diffusion-field-cell {
  min-width: 0;
  border: 1px solid var(--diff-line);
  border-radius: 6px;
  background: var(--diff-panel);
}

.diffusion-card,
.diffusion-lane,
.diffusion-tensor-cell {
  padding: .72rem;
}

.diffusion-step {
  position: relative;
  padding: .62rem;
}

.diffusion-step:not(:last-child)::after {
  content: "->";
  position: absolute;
  right: -.49rem;
  top: 50%;
  color: rgba(255, 250, 242, .58);
  font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
  font-size: .66rem;
  transform: translate(50%, -50%);
}

.diffusion-visual b,
.diffusion-visual strong {
  display: block;
  color: var(--diff-ink);
  font-size: .68rem;
  line-height: 1.35;
}

.diffusion-visual span,
.diffusion-visual p {
  display: block;
  margin: .22rem 0 0;
  color: var(--diff-muted);
  font-size: .62rem;
  line-height: 1.45;
}

.diffusion-visual code {
  color: var(--diff-ink);
  background: rgba(8, 10, 17, .34);
}

.diffusion-pill-row {
  display: flex;
  flex-wrap: wrap;
  gap: .4rem;
  margin-top: .62rem;
}

.diffusion-pill {
  padding: .32rem .46rem;
  border: 1px solid rgba(223, 185, 118, .38);
  border-radius: 999px;
  color: var(--diff-ink);
  background: rgba(223, 185, 118, .12);
  font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
  font-size: .56rem;
}

.diffusion-pill[data-tone="blue"] {
  border-color: rgba(146, 185, 223, .42);
  background: rgba(146, 185, 223, .13);
}

.diffusion-pill[data-tone="green"] {
  border-color: rgba(147, 199, 163, .42);
  background: rgba(147, 199, 163, .13);
}

.diffusion-pill[data-tone="red"] {
  border-color: rgba(219, 141, 141, .42);
  background: rgba(219, 141, 141, .12);
}

.diffusion-field {
  grid-template-columns: repeat(5, minmax(0, 1fr));
}

.diffusion-field-cell {
  padding: .45rem .35rem;
  color: var(--diff-muted);
  font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
  font-size: .72rem;
  text-align: center;
}

.diffusion-field-cell.hot {
  color: var(--diff-ink);
  border-color: rgba(223, 185, 118, .55);
  background: rgba(223, 185, 118, .16);
}

.diffusion-tensor {
  grid-template-columns: repeat(4, minmax(0, 1fr));
}

.diffusion-tensor-cell {
  min-height: 4.2rem;
  background:
    linear-gradient(135deg, rgba(255, 250, 242, .08), rgba(255, 250, 242, .025)),
    repeating-linear-gradient(0deg, transparent 0, transparent .72rem, rgba(255, 250, 242, .05) .73rem),
    repeating-linear-gradient(90deg, transparent 0, transparent .72rem, rgba(255, 250, 242, .05) .73rem);
}

.diffusion-table {
  width: 100%;
  border: 1px solid rgba(143, 94, 60, .32);
  border-collapse: separate;
  border-spacing: 0;
  border-radius: 6px;
  overflow: hidden;
  background: rgba(255, 250, 242, .94) !important;
  font-size: .88rem;
}

.diffusion-table th,
.diffusion-table td {
  border: 0;
  border-bottom: 1px solid rgba(143, 94, 60, .22);
  color: var(--coffee-ink) !important;
  vertical-align: top;
}

.diffusion-table th {
  background: rgba(47, 33, 24, .92) !important;
  color: #fffaf2 !important;
  font-weight: 700;
}

.diffusion-table tbody tr:nth-child(odd) td {
  background: rgba(255, 250, 242, .92) !important;
}

.diffusion-table tbody tr:nth-child(even) td {
  background: rgba(247, 236, 222, .94) !important;
}

.diffusion-table tbody tr:last-child td {
  border-bottom: 0;
}

.diffusion-table td:first-child {
  color: #4d2d1e !important;
  font-weight: 700;
}

body.dark-mode .diffusion-table {
  background: rgba(9, 13, 22, .9) !important;
  border-color: rgba(231, 212, 189, .24) !important;
  box-shadow: 0 1rem 2rem rgba(0, 0, 0, .22) !important;
}

body.dark-mode .diffusion-table th {
  color: #fff4e5 !important;
  background: rgba(244, 234, 220, .12) !important;
}

body.dark-mode .diffusion-table td {
  color: #ead8c3 !important;
  border-color: rgba(231, 212, 189, .18) !important;
}

body.dark-mode .diffusion-table tbody tr:nth-child(odd) td {
  background: rgba(255, 250, 242, .045) !important;
}

body.dark-mode .diffusion-table tbody tr:nth-child(even) td {
  background: rgba(255, 250, 242, .074) !important;
}

body.dark-mode .diffusion-table td:first-child {
  color: #f2c98c !important;
}

body.dark-mode .diffusion-table code {
  color: #fff4e5 !important;
  background: rgba(255, 250, 242, .08) !important;
}

@media (max-width: 760px) {
  .diffusion-grid.two,
  .diffusion-grid.three,
  .diffusion-flow.four,
  .diffusion-flow.five,
  .diffusion-lanes,
  .diffusion-stack,
  .diffusion-tensor {
    grid-template-columns: 1fr;
  }

  .diffusion-step:not(:last-child)::after {
    content: "";
    display: none;
  }
}
</style>

<div class="diffusion-visual">
  <p class="diffusion-title">Diffusion 전체 지도</p>
  <div class="diffusion-lanes">
    <div class="diffusion-lane">
      <b>Forward process</b>
      <span>깨끗한 데이터 <code>x0</code>에 단계별로 가우시안 노이즈를 더한다. 마지막에는 거의 표준정규 노이즈에 가까워진다.</span>
      <div class="diffusion-pill-row">
        <span class="diffusion-pill">x0</span>
        <span class="diffusion-pill">x_t</span>
        <span class="diffusion-pill">x_T ~ N(0, I)</span>
      </div>
    </div>
    <div class="diffusion-lane">
      <b>Reverse process</b>
      <span>모델이 예측한 노이즈 또는 score를 이용해 <code>x_T</code>에서 <code>x0</code>처럼 보이는 새 샘플로 되돌아간다.</span>
      <div class="diffusion-pill-row">
        <span class="diffusion-pill" data-tone="blue">noise</span>
        <span class="diffusion-pill" data-tone="blue">denoise</span>
        <span class="diffusion-pill" data-tone="blue">sample</span>
      </div>
    </div>
  </div>
</div>

<h2 id="데이터-분포는-의미-라벨이-아니다">데이터 분포는 의미 라벨이 아니다</h2>

<p>Diffusion에서 말하는 <code class="language-plaintext highlighter-rouge">p_data(x)</code>는 “이 이미지는 고양이다” 같은 라벨 분포가 아니다. <code class="language-plaintext highlighter-rouge">x</code>는 픽셀 값 또는 latent 값으로 이루어진 좌표다. 그러니까 <code class="language-plaintext highlighter-rouge">p_data(x)</code>는 이미지 공간에서 자연스러운 이미지들이 어디에 많이 모여 있는지를 나타내는 확률 밀도다.</p>

<p>자연 이미지는 전체 픽셀 공간에 고르게 퍼져 있지 않다. 하늘은 하늘다운 색과 질감의 규칙이 있고, 얼굴은 얼굴다운 대칭과 구조가 있으며, 책상 위 사물들은 서로 그럴듯한 위치 관계를 가진다. 이런 통계적 구조 때문에 데이터는 거대한 좌표 공간 안의 일부 영역, 흔히 말하는 데이터 매니폴드 근처에 몰린다.</p>

<p>라벨은 필수가 아니다. 라벨이나 텍스트 조건이 없어도 모델은 데이터가 자주 나타나는 구조를 배울 수 있다. 다만 텍스트 조건을 주면 <code class="language-plaintext highlighter-rouge">p(x)</code>가 아니라 <code class="language-plaintext highlighter-rouge">p(x | text)</code>처럼 조건에 맞는 부분 분포를 따라가도록 안내할 수 있다.</p>

<div class="diffusion-visual">
  <p class="diffusion-title">Score field의 직관</p>
  <div class="diffusion-stack">
    <div class="diffusion-card">
      <b>Low density</b>
      <span>무작위 픽셀이나 노이즈처럼 데이터셋에서 거의 보지 못한 영역이다.</span>
    </div>
    <div>
      <div class="diffusion-field">
        <div class="diffusion-field-cell">↘</div>
        <div class="diffusion-field-cell">↓</div>
        <div class="diffusion-field-cell">↙</div>
        <div class="diffusion-field-cell">↙</div>
        <div class="diffusion-field-cell">←</div>
        <div class="diffusion-field-cell">→</div>
        <div class="diffusion-field-cell hot">high</div>
        <div class="diffusion-field-cell hot">density</div>
        <div class="diffusion-field-cell">←</div>
        <div class="diffusion-field-cell">↙</div>
        <div class="diffusion-field-cell">↗</div>
        <div class="diffusion-field-cell hot">data</div>
        <div class="diffusion-field-cell hot">manifold</div>
        <div class="diffusion-field-cell">←</div>
        <div class="diffusion-field-cell">↖</div>
      </div>
    </div>
    <div class="diffusion-card">
      <b>Score</b>
      <span><code>gradient log p_t(x)</code>는 이 노이즈 수준에서 더 그럴듯한 쪽을 가리키는 벡터장에 가깝다.</span>
    </div>
  </div>
</div>

<h2 id="정방향-과정-이미지를-일부러-흐리게-만든다">정방향 과정: 이미지를 일부러 흐리게 만든다</h2>

<p>DDPM 계열의 기본 정방향 과정은 마르코프 체인으로 표현한다.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>q(x_t | x_{t-1}) = N(sqrt(1 - beta_t) x_{t-1}, beta_t I)
</code></pre></div></div>

<p>여기서 <code class="language-plaintext highlighter-rouge">beta_t</code>는 t번째 단계에서 노이즈를 얼마나 더할지 정하는 스케줄이다. 보통 한 번에 이미지를 망가뜨리지 않고, 작은 노이즈를 여러 단계에 걸쳐 누적한다.</p>

<p>실제 학습에서는 매번 <code class="language-plaintext highlighter-rouge">x0 -&gt; x1 -&gt; ... -&gt; x_t</code>를 순서대로 계산하지 않아도 된다. 누적값을 쓰면 임의의 t에 대해 바로 <code class="language-plaintext highlighter-rouge">x_t</code>를 만들 수 있다.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>alpha_t = 1 - beta_t
alpha_bar_t = alpha_1 * alpha_2 * ... * alpha_t

x_t = sqrt(alpha_bar_t) x0 + sqrt(1 - alpha_bar_t) epsilon
epsilon ~ N(0, I)
</code></pre></div></div>

<p>이 식이 중요하다. 깨끗한 이미지 <code class="language-plaintext highlighter-rouge">x0</code>, 임의의 시간 <code class="language-plaintext highlighter-rouge">t</code>, 우리가 뽑은 노이즈 <code class="language-plaintext highlighter-rouge">epsilon</code>만 있으면 노이즈 낀 입력 <code class="language-plaintext highlighter-rouge">x_t</code>를 만들 수 있다. 그리고 우리가 어떤 노이즈를 섞었는지도 정확히 알고 있다.</p>

<h2 id="학습-모델은-원본-이미지가-아니라-노이즈를-맞힌다">학습: 모델은 원본 이미지가 아니라 노이즈를 맞힌다</h2>

<p>Diffusion 모델의 가장 흔한 학습 목표는 <code class="language-plaintext highlighter-rouge">epsilon prediction</code>이다.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>loss = E[ || epsilon - epsilon_theta(x_t, t, condition) ||^2 ]
</code></pre></div></div>

<p>모델 입력은 노이즈가 섞인 <code class="language-plaintext highlighter-rouge">x_t</code>, 노이즈 수준 <code class="language-plaintext highlighter-rouge">t</code>, 선택적으로 텍스트나 클래스 같은 조건이다. 출력은 <code class="language-plaintext highlighter-rouge">x_t</code>에 섞였던 노이즈의 예측값이다.</p>

<p>겉으로는 정답이 있는 회귀 문제처럼 보인다. 하지만 정답 <code class="language-plaintext highlighter-rouge">epsilon</code>은 사람이 라벨링한 값이 아니라, 학습 과정에서 우리가 직접 만든 값이다. 그래서 Diffusion은 보통 비지도 생성 모델의 범주에 놓이지만, 구현되는 학습 신호는 자기지도 denoising objective라고 보는 편이 더 정확하다.</p>

<div class="diffusion-visual">
  <p class="diffusion-title">학습 루프</p>
  <div class="diffusion-flow four">
    <div class="diffusion-step">
      <b>1. sample</b>
      <span>데이터 <code>x0</code>, 시간 <code>t</code>, 노이즈 <code>epsilon</code>을 뽑는다.</span>
    </div>
    <div class="diffusion-step">
      <b>2. corrupt</b>
      <span><code>x_t</code>를 닫힌 형태 식으로 만든다.</span>
    </div>
    <div class="diffusion-step">
      <b>3. predict</b>
      <span>U-Net이 <code>epsilon_theta(x_t, t)</code>를 예측한다.</span>
    </div>
    <div class="diffusion-step">
      <b>4. update</b>
      <span>진짜 노이즈와 예측 노이즈의 MSE를 줄인다.</span>
    </div>
  </div>
</div>

<p>이 손실이 좋은 이유는 명확하다. 원본 이미지를 직접 회귀하는 것보다, 각 노이즈 수준에서 “무엇을 빼야 자연스러운 데이터 쪽으로 가까워지는지”를 배우게 된다.</p>

<h2 id="score-관점-노이즈-예측은-방향을-배운다">Score 관점: 노이즈 예측은 방향을 배운다</h2>

<p>Diffusion을 더 깊게 보면 score-based generative modeling과 연결된다. score는 확률밀도의 로그 기울기다.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>score(x_t, t) = gradient_x log p_t(x_t)
</code></pre></div></div>

<p>직관적으로 score는 현재 위치에서 확률밀도가 커지는 방향을 가리킨다. DDPM의 노이즈 예측 모델은 일정한 스케일 변환을 거치면 이 score와 같은 정보를 담는다.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>score(x_t, t) is approximately
- epsilon_theta(x_t, t) / sqrt(1 - alpha_bar_t)
</code></pre></div></div>

<p>따라서 샘플링은 “노이즈에서 시작해 score field를 따라 데이터 밀도가 높은 쪽으로 이동하는 수치적분”으로 볼 수 있다. 원본 한 장을 복구하는 것이 아니라, 학습 데이터 분포와 비슷한 새 샘플을 만들어내는 과정이다.</p>

<h2 id="역방향-과정-노이즈에서-새-이미지를-샘플링한다">역방향 과정: 노이즈에서 새 이미지를 샘플링한다</h2>

<p>학습이 끝나면 생성은 반대로 진행한다.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>x_T ~ N(0, I)

for t = T, ..., 1:
  epsilon_pred = epsilon_theta(x_t, t, condition)
  x_{t-1} = denoise_step(x_t, epsilon_pred, t)
</code></pre></div></div>

<p>DDPM은 역방향 과정을 확률적으로 샘플링한다. 각 단계에서 평균 방향으로 denoise하면서도 약간의 가우시안 변동을 남긴다. 이 변동은 다양성을 만드는 데 도움을 준다.</p>

<p>DDIM은 같은 학습 목표를 유지하면서 비마르코프적 경로를 구성해 더 적은 단계로 샘플링할 수 있게 했다. 이후 DPM-Solver, UniPC, Euler, Heun 같은 sampler들은 역방향 과정을 수치적으로 더 효율적으로 적분하려는 시도들이다. 중요한 점은 sampler가 바뀌어도 모델이 배운 핵심은 여전히 “각 노이즈 수준에서 무엇을 제거해야 하는가”라는 것이다.</p>

<h2 id="텍스트-조건과-classifier-free-guidance">텍스트 조건과 classifier-free guidance</h2>

<p>텍스트-이미지 모델에서는 U-Net이 이미지 또는 latent 특징만 보지 않는다. 텍스트 인코더가 만든 토큰 임베딩을 cross-attention으로 넣어준다.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Q = image or latent features
K, V = text token embeddings
</code></pre></div></div>

<p>이렇게 하면 모델은 <code class="language-plaintext highlighter-rouge">p(x)</code>가 아니라 <code class="language-plaintext highlighter-rouge">p(x | text)</code>에 가까운 방향으로 denoise한다.</p>

<p>Classifier-free guidance는 조건부 예측과 무조건부 예측을 함께 사용해 조건의 힘을 조절한다.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>epsilon_hat =
  epsilon_uncond + guidance_scale * (epsilon_cond - epsilon_uncond)
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">guidance_scale</code>을 키우면 프롬프트를 더 세게 따르는 경향이 생긴다. 대신 너무 크면 색이 과해지거나 구도가 경직되거나 세부가 깨질 수 있다. 실무에서 CFG는 “프롬프트 충실도와 다양성 사이의 손잡이”라고 이해하면 편하다.</p>

<div class="diffusion-visual">
  <p class="diffusion-title">텍스트 조건이 들어가는 자리</p>
  <div class="diffusion-flow five">
    <div class="diffusion-step">
      <b>Prompt</b>
      <span>사용자가 원하는 장면을 텍스트로 쓴다.</span>
    </div>
    <div class="diffusion-step">
      <b>Text encoder</b>
      <span>문장을 토큰 임베딩으로 바꾼다.</span>
    </div>
    <div class="diffusion-step">
      <b>Cross-attention</b>
      <span>U-Net의 이미지 특징이 텍스트 토큰을 참조한다.</span>
    </div>
    <div class="diffusion-step">
      <b>CFG</b>
      <span>조건부 방향과 무조건부 방향의 차이를 키운다.</span>
    </div>
    <div class="diffusion-step">
      <b>Sample</b>
      <span>조건에 맞는 데이터 분포 쪽으로 이동한다.</span>
    </div>
  </div>
</div>

<h2 id="autoencoder부터-잡고-가기">AutoEncoder부터 잡고 가기</h2>

<p>Latent Diffusion을 이해하려면 먼저 AutoEncoder를 짚는 편이 좋다. AutoEncoder는 입력을 한 번 압축했다가 다시 복원하는 신경망이다.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>x -&gt; Encoder -&gt; z -&gt; Decoder -&gt; x_hat
</code></pre></div></div>

<p>여기서 <code class="language-plaintext highlighter-rouge">x</code>는 원본 이미지, <code class="language-plaintext highlighter-rouge">z</code>는 latent representation, <code class="language-plaintext highlighter-rouge">x_hat</code>은 복원 이미지다. 기본 AutoEncoder의 손실은 보통 입력과 최종 출력의 차이로 잡는다.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>L_recon = || x - x_hat ||^2
</code></pre></div></div>

<p>중요한 점은 latent를 기준으로 encoder와 decoder의 중간 레이어를 대칭 비교하지 않는다는 것이다. 손실은 최종 복원 결과와 원본 사이에서 계산되고, 그 오차가 decoder를 거쳐 latent, 다시 encoder 쪽으로 역전파된다. Notion의 AutoEncoder 역전파 문서에서 정리한 것처럼, latent는 비교 대상이라기보다 gradient가 지나가는 병목 통로에 가깝다.</p>

<div class="diffusion-visual">
  <p class="diffusion-title">AutoEncoder 학습 흐름</p>
  <div class="diffusion-flow five">
    <div class="diffusion-step">
      <b>Input</b>
      <span>원본 이미지 <code>x</code>가 들어온다.</span>
    </div>
    <div class="diffusion-step">
      <b>Encoder</b>
      <span>복원에 필요한 정보를 압축해 <code>z</code>를 만든다.</span>
    </div>
    <div class="diffusion-step">
      <b>Bottleneck</b>
      <span>모든 픽셀을 그대로 외우지 못하게 정보 통로를 좁힌다.</span>
    </div>
    <div class="diffusion-step">
      <b>Decoder</b>
      <span><code>z</code>에서 다시 <code>x_hat</code>을 복원한다.</span>
    </div>
    <div class="diffusion-step">
      <b>Loss</b>
      <span><code>x</code>와 <code>x_hat</code>의 차이가 전체 네트워크로 역전파된다.</span>
    </div>
  </div>
</div>

<p>AutoEncoder가 하는 일은 단순한 압축 파일 만들기와 다르다. 좋은 encoder는 복원에 필요한 형태, 색감, 질감, 구도 같은 정보를 작은 표현에 담아야 한다. 좋은 decoder는 그 작은 표현에서 다시 눈에 자연스러운 이미지를 펼쳐야 한다.</p>

<p>다만 기본 AE에는 생성 모델로서의 약점이 있다. <code class="language-plaintext highlighter-rouge">z = E(x)</code>가 결정론적 점이기 때문에, latent 공간의 아무 지점이나 골라 decoder에 넣었을 때 자연스러운 이미지가 나온다는 보장이 약하다. 훈련 데이터가 지나간 지점 근처에서는 복원이 되지만, 그 사이 공간이나 바깥 공간이 매끄럽게 정돈되어 있지 않을 수 있다.</p>

<h2 id="ae와-vae는-무엇이-다른가">AE와 VAE는 무엇이 다른가</h2>

<p>VAE는 AutoEncoder처럼 encoder와 decoder를 갖지만, latent를 하나의 점이 아니라 분포로 다룬다.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>AE:
  z = E(x)

VAE:
  q_phi(z | x) = N(mu_phi(x), diag(sigma_phi(x)^2))
  z = mu + sigma * epsilon
  epsilon ~ N(0, I)
</code></pre></div></div>

<p>VAE encoder는 입력 이미지 하나에 대해 <code class="language-plaintext highlighter-rouge">mu</code>와 <code class="language-plaintext highlighter-rouge">sigma</code>를 예측한다. <code class="language-plaintext highlighter-rouge">mu</code>는 그 이미지가 latent 공간에서 있을 법한 중심이고, <code class="language-plaintext highlighter-rouge">sigma</code>는 그 주변에서 허용되는 변동 폭이다. 그 다음 reparameterization trick으로 <code class="language-plaintext highlighter-rouge">z</code>를 샘플링한다. 이렇게 해야 샘플링이 들어가도 gradient가 encoder 쪽으로 흘러갈 수 있다.</p>

<p>학습 목표는 복원 품질과 latent 정규화를 함께 본다.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>ELBO = E_q[log p_theta(x | z)]
       - KL(q_phi(z | x) || p(z))

loss = reconstruction loss + KL regularization
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">p(z)</code>는 보통 표준정규분포 <code class="language-plaintext highlighter-rouge">N(0, I)</code>로 둔다. KL 항은 encoder가 만든 <code class="language-plaintext highlighter-rouge">q_phi(z | x)</code>가 이 prior에서 너무 멀어지지 않게 붙잡는다. 그래서 VAE의 latent 공간은 AE보다 샘플링하기 쉬운 형태로 정돈된다.</p>

<table class="diffusion-table">
  <thead>
    <tr>
      <th>구분</th>
      <th>AE</th>
      <th>VAE</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>latent 형태</td>
      <td>입력마다 하나의 결정론적 벡터 <code>z</code></td>
      <td>입력마다 <code>mu</code>, <code>sigma</code>로 표현되는 분포</td>
    </tr>
    <tr>
      <td>학습 목표</td>
      <td>주로 복원 오차를 줄인다.</td>
      <td>복원 오차와 KL 정규화를 함께 최적화한다.</td>
    </tr>
    <tr>
      <td>생성 관점</td>
      <td>latent 공간 샘플링이 자연스럽다는 보장이 약하다.</td>
      <td>prior에서 샘플링해 decoder로 생성하기 쉬운 공간을 만든다.</td>
    </tr>
    <tr>
      <td>주의점</td>
      <td>복원은 선명할 수 있지만 latent 공간이 듬성듬성할 수 있다.</td>
      <td>KL을 강하게 걸면 latent가 정보를 덜 담거나 복원이 흐려질 수 있다.</td>
    </tr>
  </tbody>
</table>

<p>그래서 “VAE가 AE보다 항상 복원을 더 잘한다”라고 말하면 부정확하다. VAE는 복원만 최적화하는 모델이 아니라, 복원과 생성 가능한 latent 공간 사이의 균형을 맞추는 모델이다. Latent Diffusion에서 VAE가 중요한 이유도 이 균형 때문이다. diffusion이 다룰 latent가 너무 임의적인 모양이면 가우시안 노이즈 스케줄과 잘 맞지 않고, decoder가 복원할 수 있는 의미 있는 공간 안에서 denoise가 진행되어야 한다.</p>

<h2 id="stable-diffusion은-왜-latent에서-diffusion을-하는가">Stable Diffusion은 왜 latent에서 diffusion을 하는가</h2>

<p>픽셀 공간에서 diffusion을 돌리면 계산량이 크다. 512 x 512 RGB 이미지는 786,432개의 숫자를 가진다. 이를 매 denoise step마다 U-Net으로 처리하면 학습과 추론 모두 비싸진다.</p>

<p>Latent Diffusion의 핵심은 이미지를 먼저 더 작은 latent로 압축하고, diffusion은 그 latent 공간에서 수행하는 것이다.</p>

<div class="diffusion-visual">
  <p class="diffusion-title">Latent Diffusion 흐름</p>
  <div class="diffusion-flow five">
    <div class="diffusion-step">
      <b>Image</b>
      <span><code>512 x 512 x 3</code> 같은 픽셀 공간의 이미지다.</span>
    </div>
    <div class="diffusion-step">
      <b>VAE encoder</b>
      <span>이미지를 압축된 latent 분포로 보낸다.</span>
    </div>
    <div class="diffusion-step">
      <b>Latent</b>
      <span>예: SD v1 계열에서는 흔히 <code>64 x 64 x 4</code> 텐서로 다룬다.</span>
    </div>
    <div class="diffusion-step">
      <b>U-Net</b>
      <span>픽셀이 아니라 latent에 노이즈를 더하고 제거한다.</span>
    </div>
    <div class="diffusion-step">
      <b>VAE decoder</b>
      <span>denoise된 latent를 다시 이미지로 복원한다.</span>
    </div>
  </div>
</div>

<p>Latent는 단순한 썸네일이 아니다. 공간 구조는 유지하지만, 각 값은 RGB처럼 사람이 바로 읽을 수 있는 색이 아니다. VAE가 학습한 압축 표현이고, 여러 시각적 특징이 얽혀 있다.</p>

<p>예를 들어 Stable Diffusion v1 계열처럼 <code class="language-plaintext highlighter-rouge">512 x 512 x 3</code> 이미지가 <code class="language-plaintext highlighter-rouge">64 x 64 x 4</code> latent로 내려간다고 하자. 공간적으로는 대략 8배 다운샘플된 격자처럼 볼 수 있다. 하지만 latent의 한 칸이 원본의 정확한 8 x 8 패치를 독립적으로 뜻한다고 단정하면 곤란하다. convolution과 attention, decoder의 receptive field 때문에 주변 정보와 함께 해석된다.</p>

<div class="diffusion-visual">
  <p class="diffusion-title">Latent tensor를 읽는 법</p>
  <div class="diffusion-grid two">
    <div class="diffusion-card">
      <b>공간 위치</b>
      <span><code>64 x 64</code> 격자는 이미지의 거친 위치 정보를 보존한다. 왼쪽 위 latent 값은 대체로 이미지 왼쪽 위 영역과 관련된다.</span>
    </div>
    <div class="diffusion-card">
      <b>채널 값</b>
      <span><code>4</code>개 채널은 RGB가 아니다. 형태, 색, 질감, 조명 같은 특징이 섞인 학습된 표현이다.</span>
    </div>
  </div>
  <div class="diffusion-tensor" style="margin-top: .7rem;">
    <div class="diffusion-tensor-cell">
      <b>Channel 0</b>
      <span>-1.24, 0.43, ...</span>
    </div>
    <div class="diffusion-tensor-cell">
      <b>Channel 1</b>
      <span>0.12, -0.88, ...</span>
    </div>
    <div class="diffusion-tensor-cell">
      <b>Channel 2</b>
      <span>1.03, 0.35, ...</span>
    </div>
    <div class="diffusion-tensor-cell">
      <b>Channel 3</b>
      <span>-0.41, 1.76, ...</span>
    </div>
  </div>
</div>

<p>여기서 <code class="language-plaintext highlighter-rouge">-1.24</code>, <code class="language-plaintext highlighter-rouge">0.43</code> 같은 부동소수점 값은 “이 위치의 어떤 학습된 feature가 어느 방향과 강도로 활성화되었는가”에 가깝다. 값의 부호와 크기가 의미는 있지만, 사람이 “이 값은 눈”, “이 채널은 조명”처럼 직접 이름 붙일 수 있는 축은 아니다. 이런 표현은 대부분 entangled representation이다.</p>

<h2 id="vae는-latent를-하나의-점이-아니라-분포로-본다">VAE는 latent를 하나의 점이 아니라 분포로 본다</h2>

<p>VAE의 인코더는 보통 입력 이미지 <code class="language-plaintext highlighter-rouge">x</code>를 받아 latent <code class="language-plaintext highlighter-rouge">z</code> 하나를 바로 내놓지 않는다. 대신 approximate posterior의 파라미터를 낸다.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>q_phi(z | x) = N(mu_phi(x), diag(sigma_phi(x)^2))
z = mu + sigma * epsilon
epsilon ~ N(0, I)
</code></pre></div></div>

<p>이때 <code class="language-plaintext highlighter-rouge">mu</code>는 입력 이미지가 latent 공간에서 있을 법한 중심이고, <code class="language-plaintext highlighter-rouge">sigma</code>는 그 주변의 불확실성 또는 허용되는 변동 폭이다. 학습은 reconstruction term과 KL term으로 구성된 ELBO를 최적화한다.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>ELBO =
  reconstruction quality
  - KL(q_phi(z | x) || p(z))

p(z) is usually N(0, I)
</code></pre></div></div>

<p>정확히 말하면 VAE가 “알 수 없는 실제 posterior를 직접 구한다”기보다, 다루기 쉬운 분포 <code class="language-plaintext highlighter-rouge">q_phi(z | x)</code>로 근사하고 그 하한을 최적화한다. 이 점은 오래된 VAE 설명에서 자주 흐려지는 부분이다.</p>

<p>데이터셋 전체로 보면 latent 분포는 각 샘플의 가우시안 posterior가 모인 aggregated posterior다.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>q_phi(z) ~= average_i q_phi(z | x_i)
</code></pre></div></div>

<p>평균과 분산은 이 분포의 중심과 스케일을 요약한다. 다만 평균과 분산만으로 복잡한 다봉 분포의 모든 구조를 알 수는 없다. 그래도 latent가 표준정규에 맞게 잘 정규화되어 있는지, 특정 차원이 거의 쓰이지 않는지, diffusion noise schedule과 스케일이 맞는지는 점검할 수 있다.</p>

<p>Diffusers의 <code class="language-plaintext highlighter-rouge">AutoencoderKL</code>도 <code class="language-plaintext highlighter-rouge">scaling_factor</code>를 가진다. Stable Diffusion 계열에서 자주 보이는 <code class="language-plaintext highlighter-rouge">0.18215</code> 같은 값은 latent를 diffusion 모델에 넣기 전 단위 분산에 가깝게 맞추기 위한 스케일이다. 이 값은 모든 VAE에 보편적인 상수가 아니라 모델과 학습 설정에 묶인 구현 세부사항이다.</p>

<h2 id="stable-diffusion에서-vae가-실제로-맡는-일">Stable Diffusion에서 VAE가 실제로 맡는 일</h2>

<p>Stable Diffusion의 VAE를 “그림을 작게 줄이는 모듈” 정도로만 보면 부족하다. VAE는 diffusion이 일할 좌표계를 미리 만든다. 이 좌표계가 좋아야 U-Net이 노이즈를 더하고 빼는 일이 쉬워진다.</p>

<div class="diffusion-visual">
  <p class="diffusion-title">VAE와 Diffusion의 역할 분담</p>
  <div class="diffusion-grid three">
    <div class="diffusion-card">
      <b>1. 압축</b>
      <span>픽셀 공간의 중복을 줄여 작은 latent tensor로 보낸다. 계산량을 낮추는 첫 번째 이유다.</span>
    </div>
    <div class="diffusion-card">
      <b>2. 정규화</b>
      <span>latent가 너무 제멋대로 흩어지지 않게 prior와 스케일을 맞춘다. 노이즈 스케줄과 궁합이 중요하다.</span>
    </div>
    <div class="diffusion-card">
      <b>3. 복원</b>
      <span>denoise가 끝난 latent를 다시 이미지로 펼친다. 색감, 선명도, 질감 일부는 VAE 품질의 영향을 받는다.</span>
    </div>
  </div>
</div>

<p>훈련 과정을 분리해서 보면 더 선명하다.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>1. Autoencoder/VAE를 먼저 학습한다.
   x -&gt; E(x) -&gt; z -&gt; D(z) -&gt; x_hat

2. 학습된 encoder/decoder를 고정하거나 재사용한다.
   z0 = E(x)

3. diffusion U-Net은 pixel이 아니라 z0에 노이즈를 섞고 제거하는 법을 배운다.
   z_t = sqrt(alpha_bar_t) z0 + sqrt(1 - alpha_bar_t) epsilon
</code></pre></div></div>

<p>추론할 때는 반대다. 처음부터 이미지를 압축하지 않는다. 표준정규에 가까운 latent noise에서 시작한다.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>z_T ~ N(0, I)
z_T -&gt; z_{T-1} -&gt; ... -&gt; z_0
image = Decoder(z_0)
</code></pre></div></div>

<p>여기서 decoder는 “아무 latent나 이미지로 바꾸는 마술 상자”가 아니다. 자신이 학습한 latent 공간 근처의 표현을 가장 잘 복원한다. 그래서 diffusion U-Net은 decoder가 이해할 수 있는 latent manifold 근처로 샘플을 이동시켜야 한다.</p>

<p>Latent Diffusion 논문은 이 지점을 “복잡도 감소와 디테일 보존 사이의 균형”으로 본다. 너무 강하게 압축하면 U-Net은 빨라지지만 디테일이 사라진다. 너무 약하게 압축하면 픽셀 diffusion과 비용 차이가 줄어든다. Stable Diffusion 계열에서 VAE가 이미지의 약 8배 downsampling latent를 쓰는 것도 이 균형점의 한 예다.</p>

<h2 id="latent의-평균과-분산으로-무엇을-알-수-있나">latent의 평균과 분산으로 무엇을 알 수 있나</h2>

<p>VAE encoder가 각 이미지마다 <code class="language-plaintext highlighter-rouge">mu_i</code>, <code class="language-plaintext highlighter-rouge">sigma_i</code>를 낸다면, 데이터셋 전체 latent 분포는 개별 posterior들의 평균적인 혼합으로 볼 수 있다.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>q_phi(z) ~= (1 / N) sum_i q_phi(z | x_i)
</code></pre></div></div>

<p>이때 전체 평균과 공분산은 다음처럼 요약할 수 있다.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>m = mean_i(mu_i)
Sigma = mean_i(diag(sigma_i^2) + mu_i mu_i^T) - m m^T
</code></pre></div></div>

<p>이 식은 “샘플을 여러 번 뽑아 봐야만 분포를 알 수 있다”는 뜻이 아니다. <code class="language-plaintext highlighter-rouge">q_phi(z | x)</code>를 대각 가우시안으로 둔 VAE에서는 encoder가 낸 <code class="language-plaintext highlighter-rouge">mu</code>, <code class="language-plaintext highlighter-rouge">sigma</code>만으로 1차/2차 모멘트를 계산할 수 있다.</p>

<p>다만 평균과 분산이 분포의 모든 것을 말해 주는 것은 아니다. 데이터셋 전체의 aggregated posterior는 복잡한 혼합분포일 수 있다. 평균과 공분산은 중심, 스케일, 축별 사용량, collapse 여부를 보는 데 유용하지만, 여러 모드가 어떻게 갈라지는지까지 완전히 설명하지는 못한다.</p>

<p>실무적으로는 다음을 점검할 수 있다.</p>

<table class="diffusion-table">
  <thead>
    <tr>
      <th>점검 항목</th>
      <th>의미</th>
      <th>주의할 점</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>평균이 0 근처인가</td>
      <td>latent가 prior 중심에서 크게 밀려나지 않았는지 본다.</td>
      <td>0에 가깝다고 항상 좋은 생성 품질을 보장하지는 않는다.</td>
    </tr>
    <tr>
      <td>분산이 적절한가</td>
      <td>diffusion noise schedule과 latent 스케일이 잘 맞는지 본다.</td>
      <td>모델마다 scaling factor가 다르므로 상수를 일반화하면 안 된다.</td>
    </tr>
    <tr>
      <td>어떤 차원이 거의 안 쓰이는가</td>
      <td>posterior collapse나 정보 사용 부족을 의심할 수 있다.</td>
      <td>일부 차원이 조용하다고 곧바로 오류는 아니다. 모델 설계와 함께 봐야 한다.</td>
    </tr>
    <tr>
      <td>축들이 강하게 상관되는가</td>
      <td>latent feature가 얽혀 있는 정도를 볼 수 있다.</td>
      <td>entangled representation은 자연스러운 현상이며, 해석 가능한 축을 보장하지 않는다.</td>
    </tr>
  </tbody>
</table>

<h2 id="모델-변형은-어디가-다른가">모델 변형은 어디가 다른가</h2>

<p>Diffusion 계열 이름이 많지만, 처음에는 아래처럼 구분하면 충분하다.</p>

<table class="diffusion-table">
  <thead>
    <tr>
      <th>계열</th>
      <th>핵심 차이</th>
      <th>읽는 관점</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>DDPM</td>
      <td>정방향 가우시안 노이즈 과정과 학습된 역방향 마르코프 체인을 둔다.</td>
      <td>가장 기본이 되는 denoising probabilistic model.</td>
    </tr>
    <tr>
      <td>DDIM</td>
      <td>같은 학습 목표를 유지하면서 더 빠른 비마르코프 샘플링 경로를 만든다.</td>
      <td>샘플링 step 수를 줄이는 관점.</td>
    </tr>
    <tr>
      <td>Score/SDE</td>
      <td>노이즈 추가와 제거를 연속 시간 SDE/ODE로 일반화한다.</td>
      <td>score field를 수치적분하는 관점.</td>
    </tr>
    <tr>
      <td>Latent Diffusion</td>
      <td>픽셀 대신 VAE latent 공간에서 diffusion을 수행한다.</td>
      <td>계산량을 줄이고 고해상도 생성을 현실화하는 관점.</td>
    </tr>
    <tr>
      <td>Consistency / LCM</td>
      <td>여러 단계의 denoise 경로를 적은 step으로 근사하거나 증류한다.</td>
      <td>실시간성, 저 step 생성의 관점.</td>
    </tr>
  </tbody>
</table>

<h2 id="다른-생성-모델과-비교하면">다른 생성 모델과 비교하면</h2>

<table class="diffusion-table">
  <thead>
    <tr>
      <th>모델</th>
      <th>강점</th>
      <th>약점</th>
      <th>Diffusion과의 관계</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>GAN</td>
      <td>한 번의 forward로 샘플을 만들 수 있어 빠르다.</td>
      <td>적대적 학습이 불안정하고 mode collapse 위험이 있다.</td>
      <td>Diffusion은 보통 더 안정적으로 학습되지만, 여러 step 샘플링 비용이 있다.</td>
    </tr>
    <tr>
      <td>VAE</td>
      <td>ELBO 기반 확률 모델이고 latent sampling이 자연스럽다.</td>
      <td>단독 생성에서는 결과가 흐릿해지기 쉽다.</td>
      <td>Latent Diffusion에서는 VAE가 압축기와 복원기로 쓰인다.</td>
    </tr>
    <tr>
      <td>Autoregressive</td>
      <td>토큰 순서의 likelihood를 직접 모델링하기 좋다.</td>
      <td>긴 시퀀스에서는 순차 생성 비용이 커진다.</td>
      <td>Diffusion은 step 수가 병목이고, AR은 token 길이가 병목이다.</td>
    </tr>
    <tr>
      <td>Normalizing Flow</td>
      <td>가역 변환으로 정확한 likelihood 계산이 가능하다.</td>
      <td>가역성 제약 때문에 아키텍처 설계가 제한된다.</td>
      <td>Diffusion은 명시적 likelihood보다 샘플 품질과 유연성 쪽으로 강하다.</td>
    </tr>
  </tbody>
</table>

<h2 id="헷갈리기-쉬운-표현들">헷갈리기 쉬운 표현들</h2>

<h3 id="stable-diffusion은-비지도-학습인가">“Stable Diffusion은 비지도 학습인가?”</h3>

<p>문제 범주로는 비지도 생성 모델이라고 말할 수 있다. 외부 라벨 없이 이미지 데이터의 분포를 모델링할 수 있기 때문이다. 하지만 학습 구현은 자기지도에 가깝다. 우리가 직접 만든 노이즈 <code class="language-plaintext highlighter-rouge">epsilon</code>을 정답으로 삼아 예측하게 하므로, “라벨이 전혀 없는 순수 무감독 최적화”처럼 이해하면 조금 어긋난다.</p>

<h3 id="모델은-원본-이미지를-복원하는가">“모델은 원본 이미지를 복원하는가?”</h3>

<p>학습 중에는 <code class="language-plaintext highlighter-rouge">x0</code>에서 만든 <code class="language-plaintext highlighter-rouge">x_t</code>를 보고 노이즈를 맞힌다. 하지만 생성 시 목표는 특정 학습 이미지를 되살리는 것이 아니다. 순수 노이즈에서 출발해 데이터 분포의 고밀도 영역으로 이동하고, 그 결과 학습 데이터와 통계적으로 비슷한 새 샘플을 만든다.</p>

<h3 id="latent-채널은-사람이-읽을-수-있는-의미-축인가">“latent 채널은 사람이 읽을 수 있는 의미 축인가?”</h3>

<p>대체로 아니다. latent 값은 공간적 위치와 어느 정도 대응하지만, 각 채널이 RGB처럼 명시적인 의미를 갖지는 않는다. 어떤 방향이 조명, 스타일, 나이, 표정 같은 속성과 상관될 수는 있지만, 이는 분석이나 조작으로 찾아낸 방향이지 모델이 처음부터 사람이 읽기 좋게 이름 붙인 축은 아니다.</p>

<h3 id="vae는-정확한-데이터-밀도를-구하는가">“VAE는 정확한 데이터 밀도를 구하는가?”</h3>

<p>정확한 marginal likelihood는 보통 직접 계산하기 어렵다. VAE는 variational lower bound를 최적화해 근사 inference와 generation을 가능하게 만든다. 따라서 “intractable density를 직접 구한다”보다 “intractable posterior/lower bound 문제를 다룰 수 있게 만든다”가 더 정확하다.</p>

<h3 id="aevae의-파라미터는-latent-값-자체인가">“AE/VAE의 파라미터는 latent 값 자체인가?”</h3>

<p>아니다. <code class="language-plaintext highlighter-rouge">x</code>, <code class="language-plaintext highlighter-rouge">z</code>, <code class="language-plaintext highlighter-rouge">mu</code>, <code class="language-plaintext highlighter-rouge">sigma</code>, <code class="language-plaintext highlighter-rouge">x_hat</code>은 forward pass에서 계산되는 데이터 또는 activation이다. 학습되는 파라미터는 encoder와 decoder 안의 weight, bias, convolution kernel 같은 값들이다. <code class="language-plaintext highlighter-rouge">mu</code>와 <code class="language-plaintext highlighter-rouge">sigma</code>는 encoder 파라미터가 직접 저장한 상수가 아니라, 입력 <code class="language-plaintext highlighter-rouge">x</code>와 현재 파라미터로 계산한 출력이다.</p>

<h3 id="latent의-한-칸은-원본-8-x-8-픽셀을-정확히-뜻하는가">“latent의 한 칸은 원본 8 x 8 픽셀을 정확히 뜻하는가?”</h3>

<p>공간 해상도가 8배 줄어든 모델에서는 대략적인 위치 대응이 있다. 하지만 한 latent cell이 원본의 독립적인 8 x 8 패치를 그대로 뜻한다고 보면 안 된다. convolution, attention, decoder의 receptive field 때문에 주변 latent와 함께 해석된다. 위치 대응은 있지만, 의미 대응은 분산되어 있다.</p>

<h2 id="한-번에-정리하기">한 번에 정리하기</h2>

<p>Diffusion 모델은 노이즈를 더하는 쉬운 과정을 먼저 정하고, 그 반대 방향을 학습한다. 모델은 원본 이미지를 외우는 대신, 각 노이즈 수준에서 데이터 밀도가 높아지는 방향을 배운다. 그래서 샘플링은 순수한 노이즈가 학습된 score field를 따라 자연 이미지의 매니폴드 쪽으로 이동하는 과정으로 볼 수 있다.</p>

<p>Stable Diffusion은 이 과정을 픽셀 공간이 아니라 VAE latent 공간에서 수행한다. VAE는 이미지를 더 작은 연속 latent로 압축하고, U-Net은 그 latent에서 노이즈를 예측하며, decoder는 마지막 latent를 다시 이미지로 펼친다. 덕분에 고해상도 이미지를 훨씬 적은 계산량으로 생성할 수 있다.</p>

<p>결국 Diffusion을 이해하는 핵심은 세 가지다.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>1. forward process: 우리가 데이터를 노이즈로 망가뜨린다.
2. denoising objective: 모델은 섞인 노이즈 또는 score를 배운다.
3. reverse sampling: 배운 방향장을 따라 노이즈에서 새 샘플을 꺼낸다.
</code></pre></div></div>

<p>밤하늘이 처음에는 검은 면처럼 보이다가 눈이 적응하면 별자리의 구조가 드러나는 것처럼, diffusion도 처음에는 무작위 노이즈에서 시작한다. 다만 그 별자리를 상상으로 그리는 것이 아니라, 데이터가 남긴 확률적 방향을 따라 한 단계씩 찾아간다.</p>

<h2 id="참고한-자료">참고한 자료</h2>

<ul>
  <li>Notion: <a href="https://app.notion.com/p/27ccf77c663380729937dbe792741379">Diffusion</a></li>
  <li>Notion: <a href="https://app.notion.com/p/2d5cf77c6633808c90a0c2bee6e58923">Diffusion &amp; Latent</a></li>
  <li>Notion: <a href="https://app.notion.com/p/2a2cf77c66338046a850e869c7a32a88">VAE(Variational Auto-Encoder)</a></li>
  <li>Notion: <a href="https://app.notion.com/p/29ecf77c663380b991a8f57c90c067de">VAE 인코더와 디코더</a></li>
  <li>Notion: <a href="https://app.notion.com/p/2a5cf77c6633807a9039f73f1f5c69ea">propagation에서 손실을 구하기 위한 비교는 어떻게 하는가?</a></li>
  <li><a href="https://proceedings.neurips.cc/paper/1993/hash/9e3cfc48eccf81a0d57663e129aef3cb-Abstract.html">Autoencoders, Minimum Description Length and Helmholtz Free Energy</a></li>
  <li><a href="https://www.science.org/doi/10.1126/science.1127647">Reducing the Dimensionality of Data with Neural Networks</a></li>
  <li><a href="https://arxiv.org/abs/2006.11239">Denoising Diffusion Probabilistic Models</a></li>
  <li><a href="https://arxiv.org/abs/2010.02502">Denoising Diffusion Implicit Models</a></li>
  <li><a href="https://arxiv.org/abs/2011.13456">Score-Based Generative Modeling through Stochastic Differential Equations</a></li>
  <li><a href="https://arxiv.org/abs/2112.10752">High-Resolution Image Synthesis with Latent Diffusion Models</a></li>
  <li><a href="https://arxiv.org/abs/1312.6114">Auto-Encoding Variational Bayes</a></li>
  <li><a href="https://arxiv.org/abs/2207.12598">Classifier-Free Diffusion Guidance</a></li>
  <li><a href="https://arxiv.org/abs/2303.01469">Consistency Models</a></li>
  <li><a href="https://arxiv.org/abs/2310.04378">Latent Consistency Models: Synthesizing High-Resolution Images with Few-Step Inference</a></li>
  <li><a href="https://huggingface.co/docs/diffusers/en/api/models/autoencoderkl">Hugging Face Diffusers AutoencoderKL documentation</a></li>
</ul>]]></content><author><name>WooHyuk</name></author><category term="develop" /><category term="ai" /><category term="diffusion" /><category term="stable-diffusion" /><category term="vae" /><category term="generative-ai" /><summary type="html"><![CDATA[DDPM의 정방향/역방향 과정, 노이즈 예측 손실, score 관점, classifier-free guidance, AE/VAE, Latent Diffusion의 역할을 정리합니다.]]></summary></entry><entry><title type="html">Loki는 로그를 어떻게 싸게 저장하고 찾는가</title><link href="https://dngur6344.github.io/develop/2026-06-08-loki-log-backend/" rel="alternate" type="text/html" title="Loki는 로그를 어떻게 싸게 저장하고 찾는가" /><published>2026-06-08T00:00:00+09:00</published><updated>2026-06-08T00:00:00+09:00</updated><id>https://dngur6344.github.io/develop/loki-log-backend</id><content type="html" xml:base="https://dngur6344.github.io/develop/2026-06-08-loki-log-backend/"><![CDATA[<p>로그는 늘 많다. 장애가 나면 더 많아지고, 필요한 로그는 그 많은 로그 사이에 아주 작게 숨어 있다.</p>

<p>Loki는 이 문제를 Elasticsearch류 검색 엔진과 다른 방식으로 푼다. 로그 본문 전체를 색인해서 모든 단어를 빠르게 찾는 모델이 아니라, 라벨과 시간으로 로그 stream을 좁히고, 압축된 chunk 안의 로그를 스캔하는 모델이다.</p>

<p>그래서 Loki를 이해할 때 가장 먼저 기억할 문장은 이것이다.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Loki는 로그 본문 전체가 아니라, 로그의 라벨을 중심으로 색인한다.
</code></pre></div></div>

<style>
.obs-visual {
  --obs-bg: linear-gradient(135deg, rgba(47, 33, 24, .94), rgba(8, 12, 24, .96));
  --obs-panel: rgba(255, 250, 242, .075);
  --obs-panel-strong: rgba(255, 250, 242, .12);
  --obs-line: rgba(255, 250, 242, .18);
  --obs-ink: #fffaf2;
  --obs-muted: rgba(255, 250, 242, .72);
  --obs-gold: #d8b16f;
  --obs-blue: #8fb4d9;
  --obs-green: #8fbf9b;
  --obs-red: #d98989;
  margin: 1.2rem 0 1.55rem;
  padding: .95rem;
  border: 1px solid rgba(255, 250, 242, .12);
  border-radius: 8px;
  color: var(--obs-ink);
  background: var(--obs-bg);
  box-shadow: 0 1rem 2.4rem rgba(8, 10, 17, .18);
}

.obs-visual .obs-title {
  margin: 0 0 .7rem;
  color: var(--obs-ink);
  font-size: .78rem;
  font-weight: 700;
}

.obs-visual .obs-grid,
.obs-visual .obs-flow,
.obs-visual .obs-lanes,
.obs-visual .obs-route,
.obs-visual .obs-notes {
  display: grid;
  gap: .65rem;
}

.obs-visual .obs-grid.two {
  grid-template-columns: repeat(2, minmax(0, 1fr));
}

.obs-visual .obs-grid.three,
.obs-visual .obs-lanes {
  grid-template-columns: repeat(3, minmax(0, 1fr));
}

.obs-visual .obs-flow {
  grid-template-columns: repeat(5, minmax(0, 1fr));
}

.obs-visual .obs-route {
  grid-template-columns: 1.05fr .9fr 1.15fr;
  align-items: stretch;
}

.obs-visual .obs-notes {
  grid-template-columns: repeat(4, minmax(0, 1fr));
}

.obs-visual .obs-card,
.obs-visual .obs-step,
.obs-visual .obs-note,
.obs-visual .obs-lane {
  min-width: 0;
  border: 1px solid var(--obs-line);
  border-radius: 6px;
  background: var(--obs-panel);
}

.obs-visual .obs-card,
.obs-visual .obs-note,
.obs-visual .obs-lane {
  padding: .72rem;
}

.obs-visual .obs-step {
  position: relative;
  padding: .62rem;
}

.obs-visual .obs-step:not(:last-child)::after {
  content: "->";
  position: absolute;
  right: -.49rem;
  top: 50%;
  color: rgba(255, 250, 242, .58);
  font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
  font-size: .66rem;
  transform: translate(50%, -50%);
}

.obs-visual b,
.obs-visual strong {
  display: block;
  color: var(--obs-ink);
  font-size: .68rem;
  line-height: 1.35;
}

.obs-visual span,
.obs-visual p {
  display: block;
  margin: .2rem 0 0;
  color: var(--obs-muted);
  font-size: .62rem;
  line-height: 1.45;
}

.obs-visual code {
  color: var(--obs-ink);
  background: rgba(8, 10, 17, .34);
}

.obs-visual .obs-chip-row {
  display: flex;
  flex-wrap: wrap;
  gap: .4rem;
  margin-top: .65rem;
}

.obs-visual .obs-chip {
  padding: .32rem .46rem;
  border: 1px solid rgba(216, 177, 111, .35);
  border-radius: 999px;
  color: var(--obs-ink);
  background: rgba(216, 177, 111, .12);
  font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
  font-size: .56rem;
}

.obs-visual .obs-chip[data-tone="blue"] {
  border-color: rgba(143, 180, 217, .38);
  background: rgba(143, 180, 217, .13);
}

.obs-visual .obs-chip[data-tone="green"] {
  border-color: rgba(143, 191, 155, .38);
  background: rgba(143, 191, 155, .13);
}

.obs-visual .obs-chip[data-tone="red"] {
  border-color: rgba(217, 137, 137, .38);
  background: rgba(217, 137, 137, .12);
}

.obs-table {
  width: 100%;
  border: 1px solid rgba(143, 94, 60, .32);
  border-collapse: separate;
  border-spacing: 0;
  border-radius: 6px;
  overflow: hidden;
  background: rgba(255, 250, 242, .94) !important;
  font-size: .88rem;
}

.obs-table th,
.obs-table td {
  border: 0;
  border-bottom: 1px solid rgba(143, 94, 60, .22);
  color: var(--coffee-ink) !important;
  vertical-align: top;
}

.obs-table th {
  background: rgba(47, 33, 24, .92) !important;
  color: #fffaf2 !important;
  font-weight: 700;
}

.obs-table tbody tr:nth-child(odd) td {
  background: rgba(255, 250, 242, .92) !important;
}

.obs-table tbody tr:nth-child(even) td {
  background: rgba(247, 236, 222, .94) !important;
}

.obs-table tbody tr:last-child td {
  border-bottom: 0;
}

.obs-table td:first-child {
  color: #4d2d1e !important;
  font-weight: 700;
}

body.dark-mode .obs-table {
  background: rgba(9, 13, 22, .9) !important;
  border-color: rgba(231, 212, 189, .24) !important;
  box-shadow: 0 1rem 2rem rgba(0, 0, 0, .22) !important;
}

body.dark-mode .obs-table th {
  color: #fff4e5 !important;
  background: rgba(244, 234, 220, .12) !important;
}

body.dark-mode .obs-table td {
  color: #ead8c3 !important;
  border-color: rgba(231, 212, 189, .18) !important;
}

body.dark-mode .obs-table tbody tr:nth-child(odd) td {
  background: rgba(255, 250, 242, .045) !important;
}

body.dark-mode .obs-table tbody tr:nth-child(even) td {
  background: rgba(255, 250, 242, .074) !important;
}

body.dark-mode .obs-table td:first-child {
  color: #f2c98c !important;
}

body.dark-mode .obs-table code {
  color: #fff4e5 !important;
  background: rgba(255, 250, 242, .08) !important;
}

@media screen and (prefers-color-scheme: dark) {
  body:not(.light-mode) .obs-table {
    background: rgba(9, 13, 22, .9) !important;
    border-color: rgba(231, 212, 189, .24) !important;
  }

  body:not(.light-mode) .obs-table th {
    color: #fff4e5 !important;
    background: rgba(244, 234, 220, .12) !important;
  }

  body:not(.light-mode) .obs-table td {
    color: #ead8c3 !important;
    border-color: rgba(231, 212, 189, .18) !important;
  }

  body:not(.light-mode) .obs-table tbody tr:nth-child(odd) td {
    background: rgba(255, 250, 242, .045) !important;
  }

  body:not(.light-mode) .obs-table tbody tr:nth-child(even) td {
    background: rgba(255, 250, 242, .074) !important;
  }

  body:not(.light-mode) .obs-table td:first-child {
    color: #f2c98c !important;
  }
}

@media screen and (max-width: 760px) {
  .obs-visual {
    padding: .78rem;
  }

  .obs-visual .obs-grid.two,
  .obs-visual .obs-grid.three,
  .obs-visual .obs-lanes,
  .obs-visual .obs-flow,
  .obs-visual .obs-route,
  .obs-visual .obs-notes {
    grid-template-columns: 1fr;
  }

  .obs-visual .obs-step:not(:last-child)::after {
    content: "";
    right: auto;
    left: 50%;
    top: auto;
    bottom: -.47rem;
    width: 1px;
    height: .45rem;
    background: rgba(255, 250, 242, .34);
    transform: translateX(-50%);
  }
}
</style>

<div class="obs-visual">
  <p class="obs-title">Loki의 기본 저장 모델</p>
  <div class="obs-grid two">
    <div class="obs-card">
      <b>Index</b>
      <span>특정 label set의 로그가 어느 chunk에 있는지 알려주는 목차다. Loki 2.8+에서는 TSDB index store가 권장된다.</span>
      <div class="obs-chip-row">
        <span class="obs-chip">service_name</span>
        <span class="obs-chip" data-tone="blue">namespace</span>
        <span class="obs-chip" data-tone="green">cluster</span>
      </div>
    </div>
    <div class="obs-card">
      <b>Chunk</b>
      <span>같은 label set을 가진 log stream의 실제 로그 라인을 시간 범위별로 묶은 압축 컨테이너다.</span>
      <div class="obs-chip-row">
        <span class="obs-chip" data-tone="red">timestamp</span>
        <span class="obs-chip">log line</span>
        <span class="obs-chip" data-tone="blue">structured metadata</span>
      </div>
    </div>
  </div>
</div>

<h2 id="loki는-무엇이-아닌가">Loki는 무엇이 아닌가</h2>

<p>Loki는 APM이 아니다. 요청 하나의 call stack을 보여주거나, 서비스 간 span 관계를 자동으로 그려주는 도구가 아니다. 그 역할은 Pinpoint, Tempo, Jaeger, 상용 APM, 또는 OpenTelemetry trace backend 쪽에 가깝다.</p>

<p>Loki는 로그 저장과 조회를 맡는다. Grafana를 UI로 붙이고, LogQL로 로그를 조회한다. 특히 Kubernetes처럼 많은 컨테이너가 stdout/stderr로 로그를 흘려보내는 환경에서 잘 맞는다.</p>

<p>Elasticsearch 계열과 비교하면 의도적인 포기가 있다. Loki는 모든 로그 본문을 색인하지 않는다. 대신 라벨 색인을 작게 유지하고, 로그 본문은 chunk로 저장한다. 이 덕분에 저장 비용과 색인 비용을 줄일 수 있지만, 쿼리할 때는 라벨과 시간 범위를 잘 좁혀야 한다.</p>

<h2 id="쓰기-경로">쓰기 경로</h2>

<p>Loki의 write path는 보통 다음 순서로 흐른다.</p>

<div class="obs-visual">
  <p class="obs-title">로그가 Loki에 저장되는 길</p>
  <div class="obs-flow">
    <div class="obs-step">
      <b>Spring Boot</b>
      <span>stdout/stderr, JSON log, Logback appender, OTel logs 중 하나로 로그를 낸다.</span>
    </div>
    <div class="obs-step">
      <b>Collector</b>
      <span>Grafana Alloy, OTel Collector, Fluent Bit 등이 로그를 읽고 label을 붙인다.</span>
    </div>
    <div class="obs-step">
      <b>Distributor</b>
      <span>push 요청을 검증하고 stream을 어느 Ingester에 보낼지 정한다.</span>
    </div>
    <div class="obs-step">
      <b>Ingester</b>
      <span>stream별로 memory chunk를 쌓고 WAL로 미flush 데이터 손실을 줄인다.</span>
    </div>
    <div class="obs-step">
      <b>Object Storage</b>
      <span>index와 chunk를 저장한다. 운영 환경은 보통 object storage 기준으로 설계한다.</span>
    </div>
  </div>
</div>

<p>Distributor는 들어온 로그의 timestamp, label, tenant 등을 검증한다. 그 뒤 label set 기준으로 stream을 계산하고, consistent hashing ring을 통해 Ingester를 고른다. 일반적인 운영 구성에서는 replication factor와 quorum write가 함께 쓰인다.</p>

<p>Ingester는 받은 로그를 즉시 object storage에 한 줄씩 쓰지 않는다. stream별로 memory chunk를 만들고, 일정 크기나 시간이 되면 flush한다. flush 전에 죽었을 때 데이터를 잃지 않도록 WAL을 쓴다.</p>

<p>여기서 중요한 점은 WAL이 영구 저장소의 주인공이 아니라는 것이다. WAL은 미flush chunk를 보호하는 복구 장치에 가깝고, 장기 저장은 chunk와 index를 가진 object storage가 맡는다.</p>

<h2 id="읽기-경로">읽기 경로</h2>

<p>읽기는 쓰기보다 더 많은 최적화가 필요하다. 사용자는 Grafana에서 LogQL을 실행하고, Loki는 해당 쿼리를 시간 범위별, label set별로 잘게 나누어 처리한다.</p>

<div class="obs-visual">
  <p class="obs-title">LogQL 쿼리가 결과가 되는 길</p>
  <div class="obs-flow">
    <div class="obs-step">
      <b>Grafana</b>
      <span>사용자가 LogQL과 시간 범위를 지정한다.</span>
    </div>
    <div class="obs-step">
      <b>Query Frontend</b>
      <span>쿼리를 분할하고 캐시, 재시도, 공정성 제어를 돕는다.</span>
    </div>
    <div class="obs-step">
      <b>Querier</b>
      <span>최근 데이터는 Ingester에서, 오래된 데이터는 index/chunk에서 읽는다.</span>
    </div>
    <div class="obs-step">
      <b>Merge</b>
      <span>여러 source의 결과를 시간순으로 합치고 중복을 제거한다.</span>
    </div>
    <div class="obs-step">
      <b>Result</b>
      <span>로그 라인이나 metric query 결과가 Grafana로 돌아간다.</span>
    </div>
  </div>
</div>

<p>기본적인 LogQL은 label selector에서 시작한다.</p>

<pre><code class="language-logql">{service_name="order-api", namespace="prod"} |= "ERROR"
</code></pre>

<p>JSON 로그라면 pipeline stage를 붙일 수 있다.</p>

<pre><code class="language-logql">{service_name="order-api"} | json | status &gt;= 500
</code></pre>

<p>여기서 오해하면 안 되는 점이 있다. Loki가 로그 본문을 색인하지 않는다고 해서 본문 검색이 불가능한 것은 아니다. 본문 검색은 가능하다. 다만 먼저 라벨과 시간으로 읽을 chunk를 줄이고, 그 chunk 안의 로그 라인을 스캔하는 방식이다. 그래서 좋은 LogQL은 항상 적절한 label selector와 좁은 시간 범위에서 시작한다.</p>

<h2 id="라벨-설계가-거의-전부다">라벨 설계가 거의 전부다</h2>

<p>Loki 운영에서 가장 자주 문제가 되는 것은 라벨 카디널리티다.</p>

<p>라벨은 로그 source를 설명하는 낮은 카디널리티 값이어야 한다. 공식 문서도 라벨은 low-cardinality 값을 저장하기 위한 것이고, high-cardinality 데이터는 structured metadata를 쓰라고 안내한다.</p>

<table class="obs-table">
  <thead>
    <tr>
      <th>값의 종류</th>
      <th>권장 위치</th>
      <th>이유</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code>service_name</code>, <code>namespace</code>, <code>cluster</code>, <code>env</code></td>
      <td>Label</td>
      <td>로그 source를 안정적으로 좁히고, 값의 종류가 비교적 적다.</td>
    </tr>
    <tr>
      <td><code>deployment</code>, <code>container_name</code></td>
      <td>상황에 따라 Label</td>
      <td>운영 쿼리에서 자주 쓰이면 유용하지만, 값 증가 폭을 봐야 한다.</td>
    </tr>
    <tr>
      <td><code>traceId</code>, <code>requestId</code>, <code>userId</code>, <code>orderId</code></td>
      <td>본문 또는 Structured Metadata</td>
      <td>요청마다 값이 바뀌는 고카디널리티 값이라 index를 폭발시킬 수 있다.</td>
    </tr>
    <tr>
      <td><code>k8s.pod.name</code>, <code>service.instance.id</code></td>
      <td>신규 구성에서는 Structured Metadata 권장</td>
      <td>공식 문서도 high cardinality 가능성 때문에 새 사용자에게 기본 index label로 권장하지 않는다.</td>
    </tr>
  </tbody>
</table>

<p><code class="language-plaintext highlighter-rouge">traceId</code>를 라벨로 올리고 싶어지는 순간이 많다. 장애 상황에서 특정 요청을 바로 찾고 싶기 때문이다. 하지만 요청마다 다른 값을 라벨로 만들면 stream 수가 폭발한다. Loki는 작은 index로 비용을 줄이는 도구인데, 이런 라벨 설계는 그 장점을 스스로 버리는 셈이다.</p>

<p>더 나은 방식은 trace id를 JSON 필드나 structured metadata로 남기는 것이다. 조회할 때는 먼저 <code class="language-plaintext highlighter-rouge">{service_name="order-api"}</code>처럼 source를 좁힌 다음, <code class="language-plaintext highlighter-rouge">| json | trace_id="..."</code> 같은 pipeline으로 본문 또는 metadata를 필터링한다.</p>

<h2 id="spring-boot-로그는-어떻게-보내는가">Spring Boot 로그는 어떻게 보내는가</h2>

<p>Spring Boot는 starters를 쓰면 기본적으로 Logback을 사용하고, 로그는 console output으로 나간다. 컨테이너와 Kubernetes 환경에서는 이 기본값이 오히려 좋은 출발점이다. 애플리케이션은 stdout/stderr로 로그를 내고, 노드나 Pod 옆의 수집기가 그 로그를 읽어 Loki로 보낸다.</p>

<p>운영 관점에서 권장 순서는 보통 이렇다.</p>

<ol>
  <li><code class="language-plaintext highlighter-rouge">stdout/stderr -&gt; Grafana Alloy -&gt; Loki</code></li>
  <li><code class="language-plaintext highlighter-rouge">stdout/stderr 또는 OTLP logs -&gt; OpenTelemetry Collector -&gt; Loki</code></li>
  <li><code class="language-plaintext highlighter-rouge">Logback appender -&gt; Loki 직접 push</code></li>
</ol>

<p>직접 push 방식은 빠른 PoC에는 편하다. 예를 들어 <code class="language-plaintext highlighter-rouge">loki-logback-appender</code> 같은 third-party appender를 쓰면 애플리케이션이 Loki push API로 바로 로그를 보낼 수 있다. 하지만 이 방식은 애플리케이션 요청 경로 근처에 네트워크 실패, queue 적체, retry, backpressure 문제가 붙는다. 운영에서는 timeout, buffer, drop 정책, 장애 격리를 반드시 정해야 한다.</p>

<p>OTel Collector를 거쳐 Loki로 보낼 때는 공식 Loki 문서의 OTLP HTTP 경로를 따르는 것이 좋다.</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">exporters</span><span class="pi">:</span>
  <span class="na">otlphttp</span><span class="pi">:</span>
    <span class="na">endpoint</span><span class="pi">:</span> <span class="s">http://loki:3100/otlp</span>

<span class="na">service</span><span class="pi">:</span>
  <span class="na">pipelines</span><span class="pi">:</span>
    <span class="na">logs</span><span class="pi">:</span>
      <span class="na">receivers</span><span class="pi">:</span> <span class="pi">[</span><span class="nv">otlp</span><span class="pi">]</span>
      <span class="na">processors</span><span class="pi">:</span> <span class="pi">[</span><span class="nv">batch</span><span class="pi">]</span>
      <span class="na">exporters</span><span class="pi">:</span> <span class="pi">[</span><span class="nv">otlphttp</span><span class="pi">]</span>
</code></pre></div></div>

<p>여기서 Collector 설정의 endpoint는 <code class="language-plaintext highlighter-rouge">http://loki:3100/otlp</code>다. Loki의 실제 로그 ingest API 경로는 그 아래의 <code class="language-plaintext highlighter-rouge">/otlp/v1/logs</code>이지만, Collector exporter 설정에 전체 경로를 직접 쓰는 식으로 혼동하지 않는 편이 안전하다.</p>

<h2 id="promtail은-이제-과거-방식이다">Promtail은 이제 과거 방식이다</h2>

<p>과거 Loki 예제에는 Promtail이 자주 등장한다. 하지만 2026년 6월 8일 기준 공식 문서는 Promtail이 2026년 3월 2일 EOL이라고 명시한다. 상용 지원도 끝났고, 앞으로의 기능 개발은 Grafana Alloy에서 이루어진다.</p>

<p>따라서 새로 구성한다면 Promtail을 기본 선택지로 두지 않는 것이 맞다. 기존 Promtail 운영 환경은 Alloy 또는 다른 지원 클라이언트로 마이그레이션하는 계획을 세워야 한다.</p>

<h2 id="운영-체크리스트">운영 체크리스트</h2>

<p>Loki를 운영할 때는 아래 항목을 먼저 정리한다.</p>

<ul>
  <li>label은 적게 시작한다. 늘리는 것은 쉽지만, 줄이는 것은 운영 데이터와 대시보드에 영향을 준다.</li>
  <li>object storage를 기준으로 장기 저장을 설계한다. filesystem은 로컬 개발이나 작은 PoC에 가깝다.</li>
  <li>retention은 Compactor와 함께 설계한다. bucket lifecycle만 믿으면 Loki의 index와 chunk 관점에서 꼬일 수 있다.</li>
  <li>Java stack trace는 multiline 처리 없이는 여러 로그로 쪼개진다. Alloy <code class="language-plaintext highlighter-rouge">loki.process</code>의 multiline stage나 JSON logging 전략을 검토한다.</li>
  <li>민감정보는 Loki로 보내기 전에 마스킹한다.</li>
  <li>멀티테넌시를 쓰면 <code class="language-plaintext highlighter-rouge">X-Scope-OrgID</code> 처리와 tenant 격리를 명확히 한다.</li>
  <li>대시보드 쿼리는 항상 label selector와 시간 범위를 좁힌다.</li>
  <li>직접 appender 방식은 장애 격리, queue 크기, timeout, retry, drop 정책을 문서화한다.</li>
</ul>

<p>Loki는 모든 로그를 기억하려는 도구가 아니다. 먼저 “어느 별자리에서 온 빛인가”를 라벨로 좁히고, 그 안에서 필요한 로그 라인을 천천히 읽는다. 이 방식은 단순해 보이지만, 대량 로그의 비용을 조용히 낮춘다. 대신 라벨 설계가 흐트러지면 그 단순함은 바로 깨진다.</p>

<h2 id="참고한-자료">참고한 자료</h2>

<ul>
  <li><a href="https://grafana.com/docs/loki/latest/get-started/architecture/">Grafana Loki Architecture</a></li>
  <li><a href="https://grafana.com/docs/loki/latest/get-started/labels/">Grafana Loki Labels</a></li>
  <li><a href="https://grafana.com/docs/loki/latest/send-data/otel/">Grafana Loki OpenTelemetry ingestion</a></li>
  <li><a href="https://grafana.com/docs/loki/latest/send-data/promtail/">Grafana Loki Promtail agent</a></li>
  <li><a href="https://docs.spring.io/spring-boot/reference/features/logging.html">Spring Boot Logging</a></li>
</ul>]]></content><author><name>WooHyuk</name></author><category term="develop" /><category term="monitoring" /><category term="observability" /><category term="loki" /><category term="logging" /><category term="grafana" /><summary type="html"><![CDATA[Loki의 label index, chunk 저장 구조, Distributor와 Ingester의 쓰기 경로, Query Frontend와 Querier의 읽기 경로, Spring Boot 로그 수집 방식을 정리합니다.]]></summary></entry><entry><title type="html">OpenTelemetry는 관측성의 어디를 표준화하는가</title><link href="https://dngur6344.github.io/develop/2026-06-08-opentelemetry-observability-pipeline/" rel="alternate" type="text/html" title="OpenTelemetry는 관측성의 어디를 표준화하는가" /><published>2026-06-08T00:00:00+09:00</published><updated>2026-06-08T00:00:00+09:00</updated><id>https://dngur6344.github.io/develop/opentelemetry-observability-pipeline</id><content type="html" xml:base="https://dngur6344.github.io/develop/2026-06-08-opentelemetry-observability-pipeline/"><![CDATA[<p>모니터링 도구를 붙이다 보면 어느 순간 데이터가 도구에 묶인다.</p>

<p>trace는 APM 제품의 agent가 만들고, metric은 다른 exporter가 만들고, log는 또 다른 수집기가 가져간다. 이름도 다르고, attribute도 다르고, 전송 프로토콜도 다르다. 나중에 backend를 바꾸려 하면 애플리케이션 곳곳에 묶인 설정과 계측 코드가 발목을 잡는다.</p>

<p>OpenTelemetry는 이 지점을 표준화하려는 프로젝트다. 관측성 backend 자체가 아니라, telemetry data를 만들고, 전파하고, 수집하고, 내보내는 공통 언어에 가깝다.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>OpenTelemetry = instrumentation + data model + protocol + collector
</code></pre></div></div>

<style>
.obs-visual {
  --obs-bg: linear-gradient(135deg, rgba(47, 33, 24, .94), rgba(8, 12, 24, .96));
  --obs-panel: rgba(255, 250, 242, .075);
  --obs-panel-strong: rgba(255, 250, 242, .12);
  --obs-line: rgba(255, 250, 242, .18);
  --obs-ink: #fffaf2;
  --obs-muted: rgba(255, 250, 242, .72);
  --obs-gold: #d8b16f;
  --obs-blue: #8fb4d9;
  --obs-green: #8fbf9b;
  --obs-red: #d98989;
  margin: 1.2rem 0 1.55rem;
  padding: .95rem;
  border: 1px solid rgba(255, 250, 242, .12);
  border-radius: 8px;
  color: var(--obs-ink);
  background: var(--obs-bg);
  box-shadow: 0 1rem 2.4rem rgba(8, 10, 17, .18);
}

.obs-visual .obs-title {
  margin: 0 0 .7rem;
  color: var(--obs-ink);
  font-size: .78rem;
  font-weight: 700;
}

.obs-visual .obs-grid,
.obs-visual .obs-flow,
.obs-visual .obs-lanes,
.obs-visual .obs-route,
.obs-visual .obs-notes {
  display: grid;
  gap: .65rem;
}

.obs-visual .obs-grid.two {
  grid-template-columns: repeat(2, minmax(0, 1fr));
}

.obs-visual .obs-grid.three,
.obs-visual .obs-lanes {
  grid-template-columns: repeat(3, minmax(0, 1fr));
}

.obs-visual .obs-flow {
  grid-template-columns: repeat(5, minmax(0, 1fr));
}

.obs-visual .obs-route {
  grid-template-columns: 1.05fr .9fr 1.15fr;
  align-items: stretch;
}

.obs-visual .obs-notes {
  grid-template-columns: repeat(4, minmax(0, 1fr));
}

.obs-visual .obs-card,
.obs-visual .obs-step,
.obs-visual .obs-note,
.obs-visual .obs-lane {
  min-width: 0;
  border: 1px solid var(--obs-line);
  border-radius: 6px;
  background: var(--obs-panel);
}

.obs-visual .obs-card,
.obs-visual .obs-note,
.obs-visual .obs-lane {
  padding: .72rem;
}

.obs-visual .obs-step {
  position: relative;
  padding: .62rem;
}

.obs-visual .obs-step:not(:last-child)::after {
  content: "->";
  position: absolute;
  right: -.49rem;
  top: 50%;
  color: rgba(255, 250, 242, .58);
  font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
  font-size: .66rem;
  transform: translate(50%, -50%);
}

.obs-visual b,
.obs-visual strong {
  display: block;
  color: var(--obs-ink);
  font-size: .68rem;
  line-height: 1.35;
}

.obs-visual span,
.obs-visual p {
  display: block;
  margin: .2rem 0 0;
  color: var(--obs-muted);
  font-size: .62rem;
  line-height: 1.45;
}

.obs-visual code {
  color: var(--obs-ink);
  background: rgba(8, 10, 17, .34);
}

.obs-visual .obs-chip-row {
  display: flex;
  flex-wrap: wrap;
  gap: .4rem;
  margin-top: .65rem;
}

.obs-visual .obs-chip {
  padding: .32rem .46rem;
  border: 1px solid rgba(216, 177, 111, .35);
  border-radius: 999px;
  color: var(--obs-ink);
  background: rgba(216, 177, 111, .12);
  font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
  font-size: .56rem;
}

.obs-visual .obs-chip[data-tone="blue"] {
  border-color: rgba(143, 180, 217, .38);
  background: rgba(143, 180, 217, .13);
}

.obs-visual .obs-chip[data-tone="green"] {
  border-color: rgba(143, 191, 155, .38);
  background: rgba(143, 191, 155, .13);
}

.obs-visual .obs-chip[data-tone="red"] {
  border-color: rgba(217, 137, 137, .38);
  background: rgba(217, 137, 137, .12);
}

.obs-table {
  width: 100%;
  border: 1px solid rgba(143, 94, 60, .32);
  border-collapse: separate;
  border-spacing: 0;
  border-radius: 6px;
  overflow: hidden;
  background: rgba(255, 250, 242, .94) !important;
  font-size: .88rem;
}

.obs-table th,
.obs-table td {
  border: 0;
  border-bottom: 1px solid rgba(143, 94, 60, .22);
  color: var(--coffee-ink) !important;
  vertical-align: top;
}

.obs-table th {
  background: rgba(47, 33, 24, .92) !important;
  color: #fffaf2 !important;
  font-weight: 700;
}

.obs-table tbody tr:nth-child(odd) td {
  background: rgba(255, 250, 242, .92) !important;
}

.obs-table tbody tr:nth-child(even) td {
  background: rgba(247, 236, 222, .94) !important;
}

.obs-table tbody tr:last-child td {
  border-bottom: 0;
}

.obs-table td:first-child {
  color: #4d2d1e !important;
  font-weight: 700;
}

body.dark-mode .obs-table {
  background: rgba(9, 13, 22, .9) !important;
  border-color: rgba(231, 212, 189, .24) !important;
  box-shadow: 0 1rem 2rem rgba(0, 0, 0, .22) !important;
}

body.dark-mode .obs-table th {
  color: #fff4e5 !important;
  background: rgba(244, 234, 220, .12) !important;
}

body.dark-mode .obs-table td {
  color: #ead8c3 !important;
  border-color: rgba(231, 212, 189, .18) !important;
}

body.dark-mode .obs-table tbody tr:nth-child(odd) td {
  background: rgba(255, 250, 242, .045) !important;
}

body.dark-mode .obs-table tbody tr:nth-child(even) td {
  background: rgba(255, 250, 242, .074) !important;
}

body.dark-mode .obs-table td:first-child {
  color: #f2c98c !important;
}

body.dark-mode .obs-table code {
  color: #fff4e5 !important;
  background: rgba(255, 250, 242, .08) !important;
}

@media screen and (prefers-color-scheme: dark) {
  body:not(.light-mode) .obs-table {
    background: rgba(9, 13, 22, .9) !important;
    border-color: rgba(231, 212, 189, .24) !important;
  }

  body:not(.light-mode) .obs-table th {
    color: #fff4e5 !important;
    background: rgba(244, 234, 220, .12) !important;
  }

  body:not(.light-mode) .obs-table td {
    color: #ead8c3 !important;
    border-color: rgba(231, 212, 189, .18) !important;
  }

  body:not(.light-mode) .obs-table tbody tr:nth-child(odd) td {
    background: rgba(255, 250, 242, .045) !important;
  }

  body:not(.light-mode) .obs-table tbody tr:nth-child(even) td {
    background: rgba(255, 250, 242, .074) !important;
  }

  body:not(.light-mode) .obs-table td:first-child {
    color: #f2c98c !important;
  }
}

@media screen and (max-width: 760px) {
  .obs-visual {
    padding: .78rem;
  }

  .obs-visual .obs-grid.two,
  .obs-visual .obs-grid.three,
  .obs-visual .obs-lanes,
  .obs-visual .obs-flow,
  .obs-visual .obs-route,
  .obs-visual .obs-notes {
    grid-template-columns: 1fr;
  }

  .obs-visual .obs-step:not(:last-child)::after {
    content: "";
    right: auto;
    left: 50%;
    top: auto;
    bottom: -.47rem;
    width: 1px;
    height: .45rem;
    background: rgba(255, 250, 242, .34);
    transform: translateX(-50%);
  }
}
</style>

<div class="obs-visual">
  <p class="obs-title">OpenTelemetry가 맡는 구간</p>
  <div class="obs-flow">
    <div class="obs-step">
      <b>Application</b>
      <span>Java Agent, SDK, library instrumentation이 telemetry를 만든다.</span>
    </div>
    <div class="obs-step">
      <b>API / SDK</b>
      <span>trace, metric, log를 같은 개념과 attribute로 표현한다.</span>
    </div>
    <div class="obs-step">
      <b>OTLP</b>
      <span>gRPC 또는 HTTP와 protobuf 기반으로 데이터를 보낸다.</span>
    </div>
    <div class="obs-step">
      <b>Collector</b>
      <span>receiver, processor, exporter pipeline으로 데이터를 다듬는다.</span>
    </div>
    <div class="obs-step">
      <b>Backend</b>
      <span>Tempo, Jaeger, Prometheus, Loki, vendor APM 등이 저장과 시각화를 맡는다.</span>
    </div>
  </div>
</div>

<h2 id="opentelemetry는-backend가-아니다">OpenTelemetry는 backend가 아니다</h2>

<p>공식 문서는 OpenTelemetry를 observability framework and toolkit이라고 설명한다. telemetry data의 generation, export, collection을 돕지만, observability backend 자체는 아니다. 저장과 시각화는 의도적으로 다른 도구에게 맡긴다.</p>

<p>이 구분이 중요하다.</p>

<ul>
  <li>Pinpoint는 Agent, Collector, 저장소, UI를 함께 가진 APM이다.</li>
  <li>Loki는 로그 저장과 조회를 맡는 backend다.</li>
  <li>OpenTelemetry는 이들 앞단에서 telemetry를 표준 형식으로 만들고 흘려보내는 계층이다.</li>
</ul>

<p>그래서 OpenTelemetry를 도입한다고 해서 대시보드가 자동으로 생기지는 않는다. 대신 trace, metric, log를 여러 backend로 보낼 수 있는 공통 파이프라인이 생긴다.</p>

<h2 id="signal-traces-metrics-logs-baggage">Signal: traces, metrics, logs, baggage</h2>

<p>OpenTelemetry를 처음 볼 때는 세 가지 signal만 기억하기 쉽다.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>traces
metrics
logs
</code></pre></div></div>

<p>하지만 개념적으로는 baggage도 중요하다. baggage는 서비스 경계를 넘어 함께 전파되는 key-value context다. 모든 데이터가 backend로 저장되는 관측성 signal이라고 보기는 조심스럽지만, trace/log/metric에 붙을 문맥을 이동시키는 역할을 한다.</p>

<table class="obs-table">
  <thead>
    <tr>
      <th>Signal</th>
      <th>무엇을 말하는가</th>
      <th>주로 묻는 질문</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Trace</td>
      <td>하나의 요청이 여러 서비스와 내부 작업을 지나간 경로다.</td>
      <td>이 요청은 어디에서 느려졌는가?</td>
    </tr>
    <tr>
      <td>Metric</td>
      <td>시간에 따라 집계되는 숫자다. latency, error rate, CPU, queue length 등이 여기에 가깝다.</td>
      <td>시스템 상태가 언제부터 나빠졌는가?</td>
    </tr>
    <tr>
      <td>Log</td>
      <td>특정 시점에 애플리케이션이 남긴 사건 기록이다.</td>
      <td>그 순간 실제로 어떤 메시지와 예외가 남았는가?</td>
    </tr>
    <tr>
      <td>Baggage</td>
      <td>서비스 경계를 넘어 전파되는 key-value context다.</td>
      <td>이 요청에 붙은 공통 문맥을 다음 서비스에서도 볼 수 있는가?</td>
    </tr>
  </tbody>
</table>

<p>Trace 안에서는 span이 기본 단위다. 하나의 HTTP 요청, DB query, message publish, 내부 작업 하나가 span이 될 수 있다. span들은 trace id를 공유하고, parent-child 관계로 이어진다.</p>

<h2 id="java와-spring에서는-어떻게-붙는가">Java와 Spring에서는 어떻게 붙는가</h2>

<p>Java 애플리케이션에서는 크게 두 가지 접근이 있다.</p>

<p>하나는 zero-code instrumentation이다. OpenTelemetry Java Agent를 JVM 옵션으로 붙이면, 지원되는 라이브러리와 프레임워크에 대해 자동 계측이 들어간다.</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>java <span class="se">\</span>
  <span class="nt">-javaagent</span>:/path/to/opentelemetry-javaagent.jar <span class="se">\</span>
  <span class="nt">-Dotel</span>.service.name<span class="o">=</span>order-api <span class="se">\</span>
  <span class="nt">-jar</span> app.jar
</code></pre></div></div>

<p>환경 변수로도 설정할 수 있다.</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nb">export </span><span class="nv">JAVA_TOOL_OPTIONS</span><span class="o">=</span><span class="s2">"-javaagent:/path/to/opentelemetry-javaagent.jar"</span>
<span class="nb">export </span><span class="nv">OTEL_SERVICE_NAME</span><span class="o">=</span><span class="s2">"order-api"</span>
<span class="nb">export </span><span class="nv">OTEL_EXPORTER_OTLP_ENDPOINT</span><span class="o">=</span><span class="s2">"http://otel-collector:4318"</span>
java <span class="nt">-jar</span> app.jar
</code></pre></div></div>

<p>다른 하나는 code-based instrumentation이다. 애플리케이션 코드에서 OpenTelemetry API를 직접 사용해 span, metric, attribute를 기록한다. 자동 계측이 놓치는 비즈니스 구간을 표시하거나, 더 의미 있는 attribute를 붙이고 싶을 때 쓴다.</p>

<p>Spring Boot에서는 Java Agent가 가장 직접적인 출발점인 경우가 많다. 다만 native image, agent 충돌, Spring 설정 파일 기반 관리가 중요하다면 Spring Boot starter 방식도 검토할 수 있다. 핵심은 둘 중 무엇을 쓰든 <code class="language-plaintext highlighter-rouge">service.name</code>과 resource attribute를 명시적으로 정하는 것이다.</p>

<h2 id="resource는-telemetry의-주소다">Resource는 telemetry의 주소다</h2>

<p>OpenTelemetry에서 resource는 telemetry를 만든 주체를 설명한다. 예를 들어 <code class="language-plaintext highlighter-rouge">service.name</code>, <code class="language-plaintext highlighter-rouge">service.namespace</code>, <code class="language-plaintext highlighter-rouge">deployment.environment.name</code>, <code class="language-plaintext highlighter-rouge">k8s.namespace.name</code>, <code class="language-plaintext highlighter-rouge">k8s.cluster.name</code> 같은 값이 여기에 들어간다.</p>

<p>공식 문서는 <code class="language-plaintext highlighter-rouge">service.name</code>을 명시적으로 설정할 것을 권장한다. 설정하지 않으면 SDK가 <code class="language-plaintext highlighter-rouge">unknown_service</code> 같은 기본값을 넣을 수 있다. backend에서 수많은 <code class="language-plaintext highlighter-rouge">unknown_service</code>를 보면, 이미 관측성의 첫 단추가 풀린 것이다.</p>

<div class="obs-visual">
  <p class="obs-title">Resource attribute가 붙는 자리</p>
  <div class="obs-grid three">
    <div class="obs-card">
      <b>Service identity</b>
      <span><code>service.name</code>, <code>service.namespace</code>, <code>service.version</code>은 backend에서 서비스를 구분하는 가장 기본적인 값이다.</span>
    </div>
    <div class="obs-card">
      <b>Runtime identity</b>
      <span>host, process, container, Kubernetes 관련 attribute는 어디서 실행됐는지를 알려준다.</span>
    </div>
    <div class="obs-card">
      <b>Deployment context</b>
      <span>환경, region, cluster 값은 장애 범위를 좁히는 데 도움을 준다.</span>
    </div>
  </div>
</div>

<p>좋은 resource 설계는 backend를 바꿔도 오래 남는다. 반대로 여기서 이름이 흔들리면 trace, metric, log를 서로 연결하는 일이 계속 어려워진다.</p>

<h2 id="collector는-작은-telemetry-라우터다">Collector는 작은 telemetry 라우터다</h2>

<p>OpenTelemetry Collector는 telemetry를 받아서, 처리하고, 내보내는 독립 프로세스다.</p>

<p>Collector 설정은 보통 네 덩어리로 읽는다.</p>

<ul>
  <li><code class="language-plaintext highlighter-rouge">receivers</code>: 데이터를 받는다. 예: OTLP, Prometheus, Kafka, Fluent Forward.</li>
  <li><code class="language-plaintext highlighter-rouge">processors</code>: 데이터를 가공한다. 예: batch, memory_limiter, attributes, tail_sampling.</li>
  <li><code class="language-plaintext highlighter-rouge">exporters</code>: 데이터를 보낸다. 예: OTLP, Prometheus Remote Write, Loki OTLP HTTP, debug.</li>
  <li><code class="language-plaintext highlighter-rouge">service.pipelines</code>: 어떤 receiver, processor, exporter를 실제로 연결할지 정한다.</li>
</ul>

<p>중요한 점은 component를 정의하는 것만으로는 활성화되지 않는다는 것이다. 공식 문서도 receiver는 <code class="language-plaintext highlighter-rouge">service</code> 섹션의 pipeline에 추가되어야 활성화된다고 설명한다.</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">receivers</span><span class="pi">:</span>
  <span class="na">otlp</span><span class="pi">:</span>
    <span class="na">protocols</span><span class="pi">:</span>
      <span class="na">grpc</span><span class="pi">:</span>
        <span class="na">endpoint</span><span class="pi">:</span> <span class="s">0.0.0.0:4317</span>
      <span class="na">http</span><span class="pi">:</span>
        <span class="na">endpoint</span><span class="pi">:</span> <span class="s">0.0.0.0:4318</span>

<span class="na">processors</span><span class="pi">:</span>
  <span class="na">batch</span><span class="pi">:</span>

<span class="na">exporters</span><span class="pi">:</span>
  <span class="na">otlp</span><span class="pi">:</span>
    <span class="na">endpoint</span><span class="pi">:</span> <span class="s">tempo:4317</span>

<span class="na">service</span><span class="pi">:</span>
  <span class="na">pipelines</span><span class="pi">:</span>
    <span class="na">traces</span><span class="pi">:</span>
      <span class="na">receivers</span><span class="pi">:</span> <span class="pi">[</span><span class="nv">otlp</span><span class="pi">]</span>
      <span class="na">processors</span><span class="pi">:</span> <span class="pi">[</span><span class="nv">batch</span><span class="pi">]</span>
      <span class="na">exporters</span><span class="pi">:</span> <span class="pi">[</span><span class="nv">otlp</span><span class="pi">]</span>
</code></pre></div></div>

<h2 id="agent-pattern과-gateway-pattern">Agent pattern과 Gateway pattern</h2>

<p>Collector 배포는 크게 두 가지로 시작할 수 있다.</p>

<div class="obs-visual">
  <p class="obs-title">Collector 배포 패턴</p>
  <div class="obs-grid two">
    <div class="obs-card">
      <b>Agent pattern</b>
      <span>애플리케이션 옆, 또는 같은 노드의 sidecar/DaemonSet으로 Collector를 둔다. 로컬로 telemetry를 받아 전처리한 뒤 backend로 보낸다.</span>
      <div class="obs-chip-row">
        <span class="obs-chip" data-tone="green">sidecar</span>
        <span class="obs-chip" data-tone="green">DaemonSet</span>
        <span class="obs-chip" data-tone="green">local buffer</span>
      </div>
    </div>
    <div class="obs-card">
      <b>Gateway pattern</b>
      <span>서비스들이 중앙 Collector endpoint로 telemetry를 보낸다. 클러스터, 리전, 데이터센터 단위의 공통 관문으로 운영한다.</span>
      <div class="obs-chip-row">
        <span class="obs-chip" data-tone="blue">central endpoint</span>
        <span class="obs-chip" data-tone="blue">policy</span>
        <span class="obs-chip" data-tone="blue">routing</span>
      </div>
    </div>
  </div>
</div>

<p>Agent pattern은 애플리케이션과 가까워 장애 격리와 로컬 전처리에 유리하다. Gateway pattern은 정책과 exporter 설정을 중앙에서 관리하기 쉽다. 실제 운영에서는 Agent-to-Gateway처럼 둘을 섞는 경우도 많다.</p>

<p>tail sampling을 gateway에서 하고 싶다면 한 가지를 더 봐야 한다. tail sampling은 trace 전체 또는 대부분의 span을 본 뒤 sampling 여부를 정한다. 따라서 같은 trace의 span들이 같은 Collector로 모여야 한다. OpenTelemetry 공식 gateway 문서도 이런 경우 trace ID 또는 service-name aware load balancing을 언급한다.</p>

<h2 id="sampling은-비용과-정보의-균형이다">Sampling은 비용과 정보의 균형이다</h2>

<p>모든 trace를 저장하는 것은 가장 단순하지만 가장 비싸다. 그래서 sampling을 설계한다.</p>

<p>Head sampling은 trace 초반에 결정을 내린다. 단순하고 효율적이지만, 나중에 오류가 났는지, 전체 latency가 어땠는지 보고 결정할 수 없다.</p>

<p>Tail sampling은 trace가 끝난 뒤 전체 span을 보고 결정한다. 오류 trace를 항상 남기거나, latency가 긴 trace를 더 많이 남기는 식의 정책을 만들 수 있다. 대신 stateful하고 운영 비용이 크다.</p>

<table class="obs-table">
  <thead>
    <tr>
      <th>방식</th>
      <th>장점</th>
      <th>주의할 점</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Head sampling</td>
      <td>단순하고 빠르며 애플리케이션 또는 Collector pipeline 초반에서 결정할 수 있다.</td>
      <td>전체 trace 내용을 보고 판단할 수 없어 오류 trace를 놓칠 수 있다.</td>
    </tr>
    <tr>
      <td>Tail sampling</td>
      <td>오류, latency, attribute 조건을 보고 더 의미 있는 trace를 남길 수 있다.</td>
      <td>span을 모아야 하므로 stateful하고, 같은 trace가 같은 sampling 지점으로 모이도록 설계해야 한다.</td>
    </tr>
  </tbody>
</table>

<h2 id="pinpoint-loki와-함께-보면">Pinpoint, Loki와 함께 보면</h2>

<p>세 도구를 같이 놓으면 역할이 선명해진다.</p>

<table class="obs-table">
  <thead>
    <tr>
      <th>도구</th>
      <th>주된 역할</th>
      <th>질문</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Pinpoint</td>
      <td>Java Agent 중심의 완성형 APM</td>
      <td>이 요청은 어떤 서비스와 메서드에서 느려졌는가?</td>
    </tr>
    <tr>
      <td>Loki</td>
      <td>라벨 기반 로그 저장과 LogQL 조회</td>
      <td>그 시점에 어떤 로그와 예외가 남았는가?</td>
    </tr>
    <tr>
      <td>OpenTelemetry</td>
      <td>telemetry 생성, 전파, 수집, export의 표준 계층</td>
      <td>도구에 묶이지 않는 방식으로 trace, metric, log를 어떻게 흘려보낼 것인가?</td>
    </tr>
  </tbody>
</table>

<p>OpenTelemetry는 Pinpoint나 Loki를 단순히 대체하지 않는다. 오히려 시스템이 커질수록 이들 사이의 언어를 맞추는 층이 된다. trace id를 로그에 함께 남기고, Loki에서 해당 trace id를 찾고, trace backend에서 전체 요청을 따라가는 식의 연결이 가능해진다.</p>

<h2 id="운영-체크리스트">운영 체크리스트</h2>

<p>OpenTelemetry를 도입할 때는 아래 항목부터 고정한다.</p>

<ul>
  <li><code class="language-plaintext highlighter-rouge">service.name</code>을 반드시 명시한다. <code class="language-plaintext highlighter-rouge">unknown_service</code>가 생기면 나중에 정리가 어렵다.</li>
  <li>resource attribute 이름을 조직 표준으로 정한다.</li>
  <li>자동 계측과 수동 계측의 경계를 정한다. 자동 계측은 넓게, 수동 계측은 비즈니스 의미가 있는 구간에만 둔다.</li>
  <li>Collector pipeline에서 component 정의와 pipeline 활성화를 구분한다.</li>
  <li>OTLP gRPC 4317과 HTTP 4318을 혼동하지 않는다.</li>
  <li>sampling 정책을 비용 절감만으로 정하지 않는다. 장애 분석에 필요한 trace를 남겨야 한다.</li>
  <li>PII나 token 같은 민감정보가 attribute, baggage, log body로 흘러가지 않게 필터링한다.</li>
  <li>backend별 제한을 확인한다. 같은 OTel 데이터라도 Loki, Prometheus, trace backend가 받아들이는 label/attribute/cardinality 모델은 다르다.</li>
</ul>

<p>OpenTelemetry는 별을 직접 보여주는 망원경이라기보다, 별빛을 같은 언어로 모으는 관측 장치에 가깝다. 어디에 저장하고 어떻게 볼지는 다른 도구가 맡는다. 대신 한 번 정돈된 telemetry pipeline은 backend가 바뀌어도 오래 남는다.</p>

<h2 id="참고한-자료">참고한 자료</h2>

<ul>
  <li><a href="https://opentelemetry.io/docs/what-is-opentelemetry/">OpenTelemetry: What is OpenTelemetry?</a></li>
  <li><a href="https://opentelemetry.io/docs/collector/configuration/">OpenTelemetry Collector Configuration</a></li>
  <li><a href="https://opentelemetry.io/docs/specs/otlp/">OpenTelemetry OTLP Specification</a></li>
  <li><a href="https://opentelemetry.io/docs/zero-code/java/agent/getting-started/">OpenTelemetry Java Agent Getting Started</a></li>
  <li><a href="https://opentelemetry.io/docs/concepts/sampling/">OpenTelemetry Sampling</a></li>
  <li><a href="https://opentelemetry.io/docs/concepts/resources/">OpenTelemetry Resources</a></li>
  <li><a href="https://opentelemetry.io/docs/collector/deploy/agent/">OpenTelemetry Collector Agent Pattern</a></li>
  <li><a href="https://opentelemetry.io/docs/collector/deploy/gateway/">OpenTelemetry Collector Gateway Pattern</a></li>
</ul>]]></content><author><name>WooHyuk</name></author><category term="develop" /><category term="monitoring" /><category term="observability" /><category term="opentelemetry" /><category term="tracing" /><category term="collector" /><summary type="html"><![CDATA[OpenTelemetry의 API, SDK, Collector, OTLP, Resource, Sampling, Agent/Gateway 배포 패턴을 중심으로 관측성 파이프라인을 정리합니다.]]></summary></entry><entry><title type="html">Pinpoint는 무엇을 보여주는 APM인가</title><link href="https://dngur6344.github.io/develop/2026-06-08-pinpoint-apm/" rel="alternate" type="text/html" title="Pinpoint는 무엇을 보여주는 APM인가" /><published>2026-06-08T00:00:00+09:00</published><updated>2026-06-08T00:00:00+09:00</updated><id>https://dngur6344.github.io/develop/pinpoint-apm</id><content type="html" xml:base="https://dngur6344.github.io/develop/2026-06-08-pinpoint-apm/"><![CDATA[<p>분산 시스템에서 장애를 만났을 때 가장 먼저 흐려지는 것은 경계다.</p>

<p>요청은 분명 하나였는데, 그 요청은 API 서버를 지나고, 내부 HTTP 호출을 지나고, 메시지 큐와 데이터베이스를 지나며 여러 조각으로 흩어진다. 로그만으로 따라가려면 각 서비스의 시간, 요청 ID, 스레드, 예외를 하나씩 맞춰야 한다.</p>

<p>Pinpoint는 이 흩어진 요청을 하나의 흐름으로 다시 묶어 보여주는 APM이다. 로그 저장소도 아니고, OpenTelemetry 같은 표준 계측 규격도 아니다. 애플리케이션에 붙는 Agent, 데이터를 받는 Collector, 저장소, 그리고 UI를 함께 갖춘 완성형 Application Performance Management 도구에 가깝다.</p>

<style>
.obs-visual {
  --obs-bg: linear-gradient(135deg, rgba(47, 33, 24, .94), rgba(8, 12, 24, .96));
  --obs-panel: rgba(255, 250, 242, .075);
  --obs-panel-strong: rgba(255, 250, 242, .12);
  --obs-line: rgba(255, 250, 242, .18);
  --obs-ink: #fffaf2;
  --obs-muted: rgba(255, 250, 242, .72);
  --obs-gold: #d8b16f;
  --obs-blue: #8fb4d9;
  --obs-green: #8fbf9b;
  --obs-red: #d98989;
  margin: 1.2rem 0 1.55rem;
  padding: .95rem;
  border: 1px solid rgba(255, 250, 242, .12);
  border-radius: 8px;
  color: var(--obs-ink);
  background: var(--obs-bg);
  box-shadow: 0 1rem 2.4rem rgba(8, 10, 17, .18);
}

.obs-visual .obs-title {
  margin: 0 0 .7rem;
  color: var(--obs-ink);
  font-size: .78rem;
  font-weight: 700;
}

.obs-visual .obs-grid,
.obs-visual .obs-flow,
.obs-visual .obs-lanes,
.obs-visual .obs-route,
.obs-visual .obs-notes {
  display: grid;
  gap: .65rem;
}

.obs-visual .obs-grid.two {
  grid-template-columns: repeat(2, minmax(0, 1fr));
}

.obs-visual .obs-grid.three,
.obs-visual .obs-lanes {
  grid-template-columns: repeat(3, minmax(0, 1fr));
}

.obs-visual .obs-flow {
  grid-template-columns: repeat(5, minmax(0, 1fr));
}

.obs-visual .obs-route {
  grid-template-columns: 1.05fr .9fr 1.15fr;
  align-items: stretch;
}

.obs-visual .obs-notes {
  grid-template-columns: repeat(4, minmax(0, 1fr));
}

.obs-visual .obs-card,
.obs-visual .obs-step,
.obs-visual .obs-note,
.obs-visual .obs-lane {
  min-width: 0;
  border: 1px solid var(--obs-line);
  border-radius: 6px;
  background: var(--obs-panel);
}

.obs-visual .obs-card,
.obs-visual .obs-note,
.obs-visual .obs-lane {
  padding: .72rem;
}

.obs-visual .obs-step {
  position: relative;
  padding: .62rem;
}

.obs-visual .obs-step:not(:last-child)::after {
  content: "->";
  position: absolute;
  right: -.49rem;
  top: 50%;
  color: rgba(255, 250, 242, .58);
  font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
  font-size: .66rem;
  transform: translate(50%, -50%);
}

.obs-visual b,
.obs-visual strong {
  display: block;
  color: var(--obs-ink);
  font-size: .68rem;
  line-height: 1.35;
}

.obs-visual span,
.obs-visual p {
  display: block;
  margin: .2rem 0 0;
  color: var(--obs-muted);
  font-size: .62rem;
  line-height: 1.45;
}

.obs-visual code {
  color: var(--obs-ink);
  background: rgba(8, 10, 17, .34);
}

.obs-visual .obs-chip-row {
  display: flex;
  flex-wrap: wrap;
  gap: .4rem;
  margin-top: .65rem;
}

.obs-visual .obs-chip {
  padding: .32rem .46rem;
  border: 1px solid rgba(216, 177, 111, .35);
  border-radius: 999px;
  color: var(--obs-ink);
  background: rgba(216, 177, 111, .12);
  font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
  font-size: .56rem;
}

.obs-visual .obs-chip[data-tone="blue"] {
  border-color: rgba(143, 180, 217, .38);
  background: rgba(143, 180, 217, .13);
}

.obs-visual .obs-chip[data-tone="green"] {
  border-color: rgba(143, 191, 155, .38);
  background: rgba(143, 191, 155, .13);
}

.obs-visual .obs-chip[data-tone="red"] {
  border-color: rgba(217, 137, 137, .38);
  background: rgba(217, 137, 137, .12);
}

.obs-table {
  width: 100%;
  border: 1px solid rgba(143, 94, 60, .32);
  border-collapse: separate;
  border-spacing: 0;
  border-radius: 6px;
  overflow: hidden;
  background: rgba(255, 250, 242, .94) !important;
  font-size: .88rem;
}

.obs-table th,
.obs-table td {
  border: 0;
  border-bottom: 1px solid rgba(143, 94, 60, .22);
  color: var(--coffee-ink) !important;
  vertical-align: top;
}

.obs-table th {
  background: rgba(47, 33, 24, .92) !important;
  color: #fffaf2 !important;
  font-weight: 700;
}

.obs-table tbody tr:nth-child(odd) td {
  background: rgba(255, 250, 242, .92) !important;
}

.obs-table tbody tr:nth-child(even) td {
  background: rgba(247, 236, 222, .94) !important;
}

.obs-table tbody tr:last-child td {
  border-bottom: 0;
}

.obs-table td:first-child {
  color: #4d2d1e !important;
  font-weight: 700;
}

body.dark-mode .obs-table {
  background: rgba(9, 13, 22, .9) !important;
  border-color: rgba(231, 212, 189, .24) !important;
  box-shadow: 0 1rem 2rem rgba(0, 0, 0, .22) !important;
}

body.dark-mode .obs-table th {
  color: #fff4e5 !important;
  background: rgba(244, 234, 220, .12) !important;
}

body.dark-mode .obs-table td {
  color: #ead8c3 !important;
  border-color: rgba(231, 212, 189, .18) !important;
}

body.dark-mode .obs-table tbody tr:nth-child(odd) td {
  background: rgba(255, 250, 242, .045) !important;
}

body.dark-mode .obs-table tbody tr:nth-child(even) td {
  background: rgba(255, 250, 242, .074) !important;
}

body.dark-mode .obs-table td:first-child {
  color: #f2c98c !important;
}

body.dark-mode .obs-table code {
  color: #fff4e5 !important;
  background: rgba(255, 250, 242, .08) !important;
}

@media screen and (prefers-color-scheme: dark) {
  body:not(.light-mode) .obs-table {
    background: rgba(9, 13, 22, .9) !important;
    border-color: rgba(231, 212, 189, .24) !important;
  }

  body:not(.light-mode) .obs-table th {
    color: #fff4e5 !important;
    background: rgba(244, 234, 220, .12) !important;
  }

  body:not(.light-mode) .obs-table td {
    color: #ead8c3 !important;
    border-color: rgba(231, 212, 189, .18) !important;
  }

  body:not(.light-mode) .obs-table tbody tr:nth-child(odd) td {
    background: rgba(255, 250, 242, .045) !important;
  }

  body:not(.light-mode) .obs-table tbody tr:nth-child(even) td {
    background: rgba(255, 250, 242, .074) !important;
  }

  body:not(.light-mode) .obs-table td:first-child {
    color: #f2c98c !important;
  }
}

@media screen and (max-width: 760px) {
  .obs-visual {
    padding: .78rem;
  }

  .obs-visual .obs-grid.two,
  .obs-visual .obs-grid.three,
  .obs-visual .obs-lanes,
  .obs-visual .obs-flow,
  .obs-visual .obs-route,
  .obs-visual .obs-notes {
    grid-template-columns: 1fr;
  }

  .obs-visual .obs-step:not(:last-child)::after {
    content: "";
    right: auto;
    left: 50%;
    top: auto;
    bottom: -.47rem;
    width: 1px;
    height: .45rem;
    background: rgba(255, 250, 242, .34);
    transform: translateX(-50%);
  }
}
</style>

<div class="obs-visual">
  <p class="obs-title">Pinpoint가 바라보는 요청의 흐름</p>
  <div class="obs-flow">
    <div class="obs-step">
      <b>Client</b>
      <span>하나의 사용자 요청이 들어온다.</span>
    </div>
    <div class="obs-step">
      <b>App A + Agent</b>
      <span>root span을 만들고 다음 호출에 trace 정보를 심는다.</span>
    </div>
    <div class="obs-step">
      <b>App B + Agent</b>
      <span>전달받은 값을 부모로 삼아 child span을 만든다.</span>
    </div>
    <div class="obs-step">
      <b>Collector</b>
      <span>Agent가 보낸 span, stat, metadata를 수집한다.</span>
    </div>
    <div class="obs-step">
      <b>Web UI</b>
      <span>ServerMap, CallStack, Inspector로 요청을 다시 그린다.</span>
    </div>
  </div>
</div>

<h2 id="pinpoint의-위치">Pinpoint의 위치</h2>

<p>Pinpoint를 한 문장으로 줄이면 이렇게 말할 수 있다.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Pinpoint = Java Agent 중심의 분산 트랜잭션 추적 APM
</code></pre></div></div>

<p>공식 README도 Pinpoint를 대규모 분산 시스템을 위한 APM으로 설명한다. Java, PHP, Python 애플리케이션을 지원하고, Google Dapper에서 영감을 받은 분산 트랜잭션 추적 모델을 사용한다. 특히 Java 환경에서는 <code class="language-plaintext highlighter-rouge">-javaagent</code>로 애플리케이션에 붙어 코드 수정 없이 요청 흐름을 수집하는 것이 핵심이다.</p>

<p>Pinpoint가 제공하는 가치는 주로 네 가지다.</p>

<ul>
  <li>ServerMap으로 서비스 간 호출 관계를 본다.</li>
  <li>Scatter나 Heatmap으로 느린 요청과 오류 요청을 찾는다.</li>
  <li>CallStack으로 한 요청의 내부 호출 순서를 본다.</li>
  <li>Inspector로 JVM, thread, CPU, memory, GC 같은 애플리케이션 상태를 본다.</li>
</ul>

<p>그래서 Pinpoint는 “어떤 서비스가 느린가”에서 멈추지 않고, “그 요청 안에서 어떤 메서드와 외부 호출이 시간을 썼는가”까지 내려가려는 도구다.</p>

<h2 id="전체-구조">전체 구조</h2>

<p>Pinpoint는 단일 바이너리 하나로 끝나는 도구가 아니다. 공식 설치 문서 기준으로 핵심 구성요소는 HBase, Pinot, Collector, Web, Agent다.</p>

<div class="obs-visual">
  <p class="obs-title">Pinpoint 구성요소</p>
  <div class="obs-route">
    <div class="obs-lane">
      <b>Application Layer</b>
      <span>Java 애플리케이션에 Pinpoint Agent가 붙는다. Agent는 요청, RPC, DB 호출, Redis, Kafka 같은 라이브러리 호출 지점에서 trace 데이터를 만든다.</span>
      <div class="obs-chip-row">
        <span class="obs-chip">-javaagent</span>
        <span class="obs-chip" data-tone="blue">agentId</span>
        <span class="obs-chip" data-tone="blue">applicationName</span>
      </div>
    </div>
    <div class="obs-lane">
      <b>Collection Layer</b>
      <span>Collector가 Agent의 span, stat, metadata를 받는다. gRPC 기준으로 agent, stat, span 수신 포트가 나뉜다.</span>
      <div class="obs-chip-row">
        <span class="obs-chip" data-tone="green">Collector</span>
        <span class="obs-chip" data-tone="green">gRPC</span>
      </div>
    </div>
    <div class="obs-lane">
      <b>Storage &amp; UI Layer</b>
      <span>HBase는 trace와 index의 중심 저장소다. Pinot는 metric과 신규 분석 기능의 축이고, Web UI가 이 둘을 읽어 화면을 만든다.</span>
      <div class="obs-chip-row">
        <span class="obs-chip" data-tone="red">HBase</span>
        <span class="obs-chip" data-tone="red">Pinot</span>
        <span class="obs-chip" data-tone="red">Web</span>
      </div>
    </div>
  </div>
</div>

<p>각 구성요소의 역할은 다음처럼 나눠서 보는 편이 안전하다.</p>

<table class="obs-table">
  <thead>
    <tr>
      <th>구성요소</th>
      <th>역할</th>
      <th>주의할 점</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Agent</td>
      <td>애플리케이션에 붙어 trace, span, metric, metadata를 만든다.</td>
      <td>bytecode instrumentation이므로 지원 라이브러리와 Agent 버전 호환성을 확인해야 한다.</td>
    </tr>
    <tr>
      <td>Collector</td>
      <td>Agent가 보낸 데이터를 받아 저장소로 넘긴다.</td>
      <td>트래픽이 커질수록 Collector 수평 확장, sampling, 저장소 부하를 함께 봐야 한다.</td>
    </tr>
    <tr>
      <td>HBase</td>
      <td>trace, call stack, trace index의 핵심 저장소다.</td>
      <td>TTL, region 분산, compaction, hotspot을 운영 항목으로 관리해야 한다.</td>
    </tr>
    <tr>
      <td>Pinot + Kafka</td>
      <td>metric data storage와 URI Statistics, System Metric, Error Analysis 같은 분석 기능의 축이다.</td>
      <td>Kafka는 core trace의 Agent-to-Collector 큐가 아니라 Pinot stream ingestion 쪽 의존성으로 보는 편이 정확하다.</td>
    </tr>
    <tr>
      <td>Zookeeper</td>
      <td>HBase 연결, Web-Collector-Agent 라우팅, 실시간 통신 조정에 관여한다.</td>
      <td>저장소 자체라기보다 운영 조정 계층에 가깝다.</td>
    </tr>
    <tr>
      <td>Web</td>
      <td>ServerMap, CallStack, Inspector, Scatter/Heatmap 같은 화면을 제공한다.</td>
      <td>Web UI의 응답성은 HBase와 Pinot 상태에 크게 영향을 받는다.</td>
    </tr>
  </tbody>
</table>

<h2 id="java-agent는-어떻게-끼어드는가">Java Agent는 어떻게 끼어드는가</h2>

<p>Pinpoint Java Agent는 JVM 시작 옵션에 붙는다.</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>java <span class="se">\</span>
  <span class="nt">-javaagent</span>:/path/to/pinpoint-agent/pinpoint-bootstrap-3.1.0.jar <span class="se">\</span>
  <span class="nt">-Dpinpoint</span>.agentId<span class="o">=</span>order-api-1 <span class="se">\</span>
  <span class="nt">-Dpinpoint</span>.applicationName<span class="o">=</span>order-api <span class="se">\</span>
  <span class="nt">-jar</span> app.jar
</code></pre></div></div>

<p>여기서 <code class="language-plaintext highlighter-rouge">agentId</code>는 개별 인스턴스를 구분하고, <code class="language-plaintext highlighter-rouge">applicationName</code>은 같은 서비스를 이루는 여러 인스턴스를 묶는다. 컨테이너 환경에서는 배포 때마다 <code class="language-plaintext highlighter-rouge">agentId</code>가 계속 새로 생기면 UI에 과거 인스턴스가 지저분하게 남을 수 있다. 공식 설치 문서도 이런 환경에서는 컨테이너 옵션을 함께 검토하라고 안내한다.</p>

<p>Agent가 애플리케이션 코드를 추적하는 방식은 bytecode instrumentation이다. 클래스가 로딩되는 시점에 Pinpoint가 관심 있는 메서드 주변에 interceptor를 넣고, 호출 전후에 필요한 데이터를 기록한다.</p>

<p>이 방식의 장점은 분명하다.</p>

<ul>
  <li>애플리케이션 코드를 거의 수정하지 않아도 된다.</li>
  <li>Spring MVC, WebFlux, JDBC, Redis, Kafka, HTTP Client 같은 라이브러리 호출을 자동으로 추적할 수 있다.</li>
  <li>요청 하나의 내부 호출 관계를 CallStack으로 자세히 볼 수 있다.</li>
</ul>

<p>하지만 공짜는 아니다. bytecode instrumentation은 애플리케이션 코드 실행 경로에 개입한다. 공식 기술 문서도 profiling 부분에 문제가 생기면 애플리케이션에 영향을 줄 수 있다고 설명한다. 그래서 운영 도입 전에는 지원 라이브러리, Agent 버전, sampling 설정, 성능 영향을 반드시 검증해야 한다.</p>

<h2 id="traceid는-하나의-요청을-묶는-실이다">TraceId는 하나의 요청을 묶는 실이다</h2>

<p>Pinpoint의 trace 모델에서 가장 중요한 단어는 <code class="language-plaintext highlighter-rouge">TransactionId</code>, <code class="language-plaintext highlighter-rouge">SpanId</code>, <code class="language-plaintext highlighter-rouge">ParentSpanId</code>다.</p>

<p>공식 문서 기준으로 Pinpoint의 <code class="language-plaintext highlighter-rouge">TraceId</code>는 이 세 값의 묶음이다. 여기서 <code class="language-plaintext highlighter-rouge">TransactionId</code>는 Dapper나 일반적인 tracing 문맥의 trace id에 가까운 값이고, <code class="language-plaintext highlighter-rouge">SpanId</code>와 <code class="language-plaintext highlighter-rouge">ParentSpanId</code>가 RPC 간 부모-자식 관계를 만든다.</p>

<div class="obs-visual">
  <p class="obs-title">TransactionId와 Span 관계</p>
  <div class="obs-grid three">
    <div class="obs-card">
      <b>Root span</b>
      <span>사용자 요청이 처음 들어온 서비스에서 만들어진다. <code>ParentSpanId = -1</code>이면 root로 볼 수 있다.</span>
    </div>
    <div class="obs-card">
      <b>Child span</b>
      <span>다음 서비스로 RPC를 보낼 때 새 <code>SpanId</code>가 만들어지고, 이전 span이 parent가 된다.</span>
    </div>
    <div class="obs-card">
      <b>Transaction</b>
      <span>여러 span이 같은 <code>TransactionId</code>를 공유하면서 하나의 사용자 요청으로 묶인다.</span>
    </div>
  </div>
  <div class="obs-chip-row">
    <span class="obs-chip">TX_ID = order-api^time^seq</span>
    <span class="obs-chip" data-tone="blue">SPAN_ID = 10</span>
    <span class="obs-chip" data-tone="green">PARENT_SPAN_ID = -1</span>
  </div>
</div>

<p>예를 들어 <code class="language-plaintext highlighter-rouge">order-api</code>가 <code class="language-plaintext highlighter-rouge">payment-api</code>를 호출하면 <code class="language-plaintext highlighter-rouge">order-api</code>의 Agent는 outbound 호출에 trace 정보를 넣는다. <code class="language-plaintext highlighter-rouge">payment-api</code>의 Agent는 그 정보를 읽고 같은 <code class="language-plaintext highlighter-rouge">TransactionId</code>를 가진 child span을 만든다. 이렇게 하면 UI에서는 “주문 요청 하나가 결제 서비스와 DB 호출에서 어디까지 갔는지”를 한 장의 call tree로 볼 수 있다.</p>

<h2 id="hbase와-pinot를-구분해서-보자">HBase와 Pinot를 구분해서 보자</h2>

<p>Pinpoint 저장 구조에서 자주 헷갈리는 지점은 HBase, Kafka, Pinot의 관계다.</p>

<p>핵심 trace와 call stack은 HBase 축으로 이해하는 것이 맞다. Collector와 Web은 HBase를 trace 저장 백엔드로 사용하고, HBase schema에는 trace와 application trace index 성격의 테이블들이 있다.</p>

<p>반면 Pinot는 metric data storage에 가깝다. System Metric, URI Statistics, Error Analysis, New Inspector 같은 기능을 위해 Kafka stream ingestion과 Pinot table이 필요하다.</p>

<p>따라서 “Kafka가 Agent와 Collector 사이에서 모든 trace를 중계한다”고 이해하면 부정확하다. Agent는 Collector로 데이터를 보내고, Kafka는 주로 Pinot 기반 분석 기능을 위한 stream ingestion 축으로 설명하는 편이 공식 문서와 맞다.</p>

<h2 id="opentelemetry와의-관계">OpenTelemetry와의 관계</h2>

<p>OpenTelemetry는 observability backend가 아니다. 공식 문서의 설명처럼 telemetry data를 생성, export, collection하기 위한 vendor-agnostic framework이자 toolkit이다. 저장과 시각화는 Jaeger, Prometheus, Loki, Tempo, 상용 APM 같은 다른 도구가 담당한다.</p>

<p>Pinpoint는 이와 다르다. Pinpoint는 자체 Agent, Collector, 저장소, Web UI를 가진 APM이다.</p>

<p>다만 둘이 완전히 단절된 것도 아니다. Pinpoint v3.1.0 공식 릴리스에는 OpenTelemetry Metric Collection이 들어갔다. 그래서 현재 기준으로는 “Pinpoint는 OTel과 관계가 없다”고 쓰기보다, “Pinpoint는 OTel 표준 계측 계층과 성격이 다르며, v3.1.0 릴리스에서 명확히 강조된 연결점은 OTLP metric 수집이다”라고 쓰는 편이 정확하다. 반대로 Pinpoint를 일반적인 OTLP trace/log backend처럼 설명하는 것은 조심해야 한다.</p>

<h2 id="언제-pinpoint가-잘-맞는가">언제 Pinpoint가 잘 맞는가</h2>

<p>Pinpoint는 이런 상황에서 특히 잘 맞는다.</p>

<ul>
  <li>Java/Spring 기반 서비스가 많다.</li>
  <li>분산 요청의 call stack과 병목 지점을 UI에서 바로 보고 싶다.</li>
  <li>코드 수정 없이 APM을 빠르게 붙이고 싶다.</li>
  <li>서비스 간 topology, active thread, scatter chart, inspector를 한 화면에서 보고 싶다.</li>
</ul>

<p>반대로 이런 경우에는 도입 전에 더 신중해야 한다.</p>

<ul>
  <li>이미 OpenTelemetry 중심으로 표준화된 telemetry pipeline이 있다.</li>
  <li>여러 언어와 여러 backend로 이식 가능한 계측 표준이 더 중요하다.</li>
  <li>HBase, Pinot, Kafka까지 운영할 여력이 부족하다.</li>
  <li>Agent의 bytecode instrumentation 리스크를 받아들이기 어렵다.</li>
</ul>

<h2 id="운영-체크리스트">운영 체크리스트</h2>

<p>Pinpoint를 운영에 올릴 때는 기능보다 먼저 아래 항목을 봐야 한다.</p>

<ul>
  <li>Agent와 Collector 버전을 맞춘다. 공식 README 기준 <code class="language-plaintext highlighter-rouge">3.1.x</code> Agent는 <code class="language-plaintext highlighter-rouge">3.1.x</code> Collector와 맞춰야 한다.</li>
  <li>Pinpoint v3.1.x README 기준 Collector, Web, Batch는 Java 17이 필요하고, Agent는 Java 8부터 25까지를 지원한다.</li>
  <li>HBase는 2.x, Pinot는 1.3.0 호환 기준을 먼저 확인한다.</li>
  <li>Agent sampling을 정한다. 전수 수집은 네트워크, Collector, HBase, Pinot 비용을 빠르게 키운다.</li>
  <li>HBase TTL과 compaction, region 분산, hotspot을 모니터링한다.</li>
  <li>Pinot/Kafka가 필요한 기능과 필요 없는 기능을 구분한다.</li>
  <li>컨테이너 환경에서는 <code class="language-plaintext highlighter-rouge">agentId</code> 전략을 정한다.</li>
  <li>장애 시 Agent를 제거하거나 비활성화하는 rollback 절차를 준비한다.</li>
</ul>

<p>Pinpoint는 조용히 지나가는 요청에 작은 표식을 남긴다. 그 표식들이 Collector와 저장소를 지나 UI에 모이면, 시스템은 더 이상 검은 상자가 아니다. 다만 그만큼 운영해야 할 구성요소도 늘어난다. Pinpoint를 선택한다는 것은 편한 APM 화면뿐 아니라, 그 화면을 만드는 trace 저장소와 Agent 생태계까지 함께 책임진다는 뜻이다.</p>

<h2 id="참고한-자료">참고한 자료</h2>

<ul>
  <li><a href="https://github.com/pinpoint-apm/pinpoint">Pinpoint 공식 GitHub</a></li>
  <li><a href="https://pinpoint-apm.gitbook.io/pinpoint/getting-started/installation">Pinpoint Installation Guide</a></li>
  <li><a href="https://pinpoint-apm.gitbook.io/pinpoint/want-a-quick-tour/techdetail">Pinpoint Tech Details</a></li>
  <li><a href="https://github.com/pinpoint-apm/pinpoint/releases/tag/v3.1.0">Pinpoint v3.1.0 Release</a></li>
  <li><a href="https://opentelemetry.io/docs/what-is-opentelemetry/">OpenTelemetry: What is OpenTelemetry?</a></li>
</ul>]]></content><author><name>WooHyuk</name></author><category term="develop" /><category term="monitoring" /><category term="observability" /><category term="pinpoint" /><category term="apm" /><category term="tracing" /><summary type="html"><![CDATA[Pinpoint의 Agent, Collector, HBase, Pinot, Web UI가 어떤 역할을 하는지, Java Agent가 분산 트랜잭션을 어떻게 추적하는지 정리합니다.]]></summary></entry><entry><title type="html">PostgreSQL XID Wraparound와 Autovacuum</title><link href="https://dngur6344.github.io/develop/2026-05-31-postgresql-xid-wraparound/" rel="alternate" type="text/html" title="PostgreSQL XID Wraparound와 Autovacuum" /><published>2026-05-31T00:00:00+09:00</published><updated>2026-05-31T00:00:00+09:00</updated><id>https://dngur6344.github.io/develop/postgresql-xid-wraparound</id><content type="html" xml:base="https://dngur6344.github.io/develop/2026-05-31-postgresql-xid-wraparound/"><![CDATA[<p>PostgreSQL을 운영하다 보면 가끔 이런 로그를 만난다.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>autovacuum: VACUUM public.some_table (to prevent wraparound)
</code></pre></div></div>

<p>문구만 보면 평소 autovacuum과 비슷해 보인다. 하지만 이 작업은 단순히 dead tuple을 치우는 청소에 가깝지 않다. PostgreSQL이 MVCC의 시간 감각을 잃지 않도록, 아주 오래된 transaction ID를 안전한 상태로 정리하는 생존 작업에 가깝다.</p>

<p>이 글은 세 가지 질문을 기준으로 정리한다.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>왜 transaction ID가 한 바퀴 도는 것이 위험한가
VACUUM FREEZE와 relfrozenxid는 무엇을 안전하게 만드는가
운영에서는 어떤 지표와 습관으로 이 일을 미리 제어해야 하는가
</code></pre></div></div>

<style>
.wrap-visual {
  --wrap-panel: rgba(255, 250, 242, .075);
  --wrap-line: rgba(255, 250, 242, .18);
  --wrap-ink: #fffaf2;
  --wrap-muted: rgba(255, 250, 242, .72);
  --wrap-warn: #d8b16f;
  --wrap-danger: #d98989;
  --wrap-safe: #8fbf9b;
  --wrap-blue: #8fb4d9;
}

.wrap-visual .wrap-grid,
.wrap-visual .wrap-flow,
.wrap-visual .wrap-lanes {
  display: grid;
  gap: .65rem;
}

.wrap-visual .wrap-grid {
  grid-template-columns: repeat(2, minmax(0, 1fr));
}

.wrap-visual .wrap-flow {
  grid-template-columns: repeat(5, minmax(0, 1fr));
}

.wrap-visual .wrap-lanes {
  grid-template-columns: repeat(3, minmax(0, 1fr));
}

.wrap-visual .wrap-card,
.wrap-visual .wrap-step,
.wrap-visual .wrap-lane {
  min-width: 0;
  border: 1px solid var(--wrap-line);
  border-radius: 6px;
  background: var(--wrap-panel);
}

.wrap-visual .wrap-card,
.wrap-visual .wrap-lane {
  padding: .74rem;
}

.wrap-visual .wrap-step {
  position: relative;
  padding: .62rem;
}

.wrap-visual .wrap-step:not(:last-child)::after {
  content: "->";
  position: absolute;
  right: -.5rem;
  top: 50%;
  color: rgba(255, 250, 242, .58);
  font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
  font-size: .66rem;
  transform: translate(50%, -50%);
}

.wrap-visual .wrap-card b,
.wrap-visual .wrap-step b,
.wrap-visual .wrap-lane b {
  display: block;
  color: var(--wrap-ink);
  font-size: .72rem;
}

.wrap-visual .wrap-card span,
.wrap-visual .wrap-step span,
.wrap-visual .wrap-lane span {
  display: block;
  margin-top: .22rem;
  color: var(--wrap-muted);
  font-size: .64rem;
  line-height: 1.45;
}

.wrap-visual .wrap-ring {
  display: grid;
  grid-template-columns: repeat(4, minmax(0, 1fr));
  gap: .5rem;
  margin-top: .65rem;
}

.wrap-visual .wrap-chip {
  padding: .46rem .38rem;
  border: 1px solid rgba(143, 180, 217, .32);
  border-radius: 5px;
  color: var(--wrap-ink);
  background: rgba(143, 180, 217, .1);
  font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
  font-size: .58rem;
  line-height: 1.2;
  text-align: center;
}

.wrap-visual .wrap-chip[data-kind="warn"] {
  border-color: rgba(216, 177, 111, .68);
  background: rgba(216, 177, 111, .14);
}

.wrap-visual .wrap-chip[data-kind="danger"] {
  border-color: rgba(217, 137, 137, .68);
  background: rgba(217, 137, 137, .12);
}

.wrap-visual .wrap-chip[data-kind="safe"] {
  border-color: rgba(143, 191, 155, .68);
  background: rgba(143, 191, 155, .12);
}

.wrap-table {
  width: 100%;
  border: 1px solid rgba(143, 94, 60, .32);
  border-collapse: separate;
  border-spacing: 0;
  border-radius: 6px;
  overflow: hidden;
  background: rgba(255, 250, 242, .88) !important;
  font-size: .88rem;
}

.wrap-table th,
.wrap-table td {
  border: 0;
  border-bottom: 1px solid rgba(143, 94, 60, .22);
  color: var(--coffee-ink) !important;
  vertical-align: top;
}

.wrap-table th {
  background: rgba(47, 33, 24, .92) !important;
  color: #fffaf2 !important;
  font-weight: 700;
}

.wrap-table tbody tr:nth-child(odd) td {
  background: rgba(255, 250, 242, .9) !important;
}

.wrap-table tbody tr:nth-child(even) td {
  background: rgba(247, 236, 222, .92) !important;
}

.wrap-table tbody tr:last-child td {
  border-bottom: 0;
}

.wrap-table td:first-child {
  color: #4d2d1e !important;
  font-weight: 700;
}

body.dark-mode .wrap-table {
  background: rgba(9, 13, 22, .86) !important;
  border-color: rgba(231, 212, 189, .24) !important;
  box-shadow: 0 1rem 2rem rgba(0, 0, 0, .22) !important;
}

body.dark-mode .wrap-table th {
  color: #fff4e5 !important;
  background: rgba(244, 234, 220, .12) !important;
}

body.dark-mode .wrap-table td {
  color: #ead8c3 !important;
  border-color: rgba(231, 212, 189, .18) !important;
}

body.dark-mode .wrap-table tbody tr:nth-child(odd) td {
  background: rgba(255, 250, 242, .045) !important;
}

body.dark-mode .wrap-table tbody tr:nth-child(even) td {
  background: rgba(255, 250, 242, .074) !important;
}

body.dark-mode .wrap-table td:first-child {
  color: #f2c98c !important;
}

body.dark-mode .wrap-table code {
  color: #fff4e5 !important;
  background: rgba(255, 250, 242, .08) !important;
}

@media screen and (prefers-color-scheme: dark) {
  body:not(.light-mode) .wrap-table {
    background: rgba(9, 13, 22, .86) !important;
    border-color: rgba(231, 212, 189, .24) !important;
    box-shadow: 0 1rem 2rem rgba(0, 0, 0, .22) !important;
  }

  body:not(.light-mode) .wrap-table th {
    color: #fff4e5 !important;
    background: rgba(244, 234, 220, .12) !important;
  }

  body:not(.light-mode) .wrap-table td {
    color: #ead8c3 !important;
    border-color: rgba(231, 212, 189, .18) !important;
  }

  body:not(.light-mode) .wrap-table tbody tr:nth-child(odd) td {
    background: rgba(255, 250, 242, .045) !important;
  }

  body:not(.light-mode) .wrap-table tbody tr:nth-child(even) td {
    background: rgba(255, 250, 242, .074) !important;
  }

  body:not(.light-mode) .wrap-table td:first-child {
    color: #f2c98c !important;
  }

  body:not(.light-mode) .wrap-table code {
    color: #fff4e5 !important;
    background: rgba(255, 250, 242, .08) !important;
  }
}

body.light-mode .wrap-table {
  background: rgba(255, 250, 242, .88) !important;
  border-color: rgba(143, 94, 60, .32) !important;
  box-shadow: none !important;
}

body.light-mode .wrap-table th {
  background: rgba(47, 33, 24, .92) !important;
  color: #fffaf2 !important;
}

body.light-mode .wrap-table td {
  color: var(--coffee-ink) !important;
  border-color: rgba(143, 94, 60, .22) !important;
}

body.light-mode .wrap-table tbody tr:nth-child(odd) td {
  background: rgba(255, 250, 242, .9) !important;
}

body.light-mode .wrap-table tbody tr:nth-child(even) td {
  background: rgba(247, 236, 222, .92) !important;
}

body.light-mode .wrap-table td:first-child {
  color: #4d2d1e !important;
}

@media screen and (max-width: 56rem) {
  .wrap-visual .wrap-grid,
  .wrap-visual .wrap-flow,
  .wrap-visual .wrap-lanes,
  .wrap-visual .wrap-ring {
    grid-template-columns: 1fr;
  }

  .wrap-visual .wrap-step:not(:last-child)::after {
    content: "";
    left: 50%;
    right: auto;
    top: auto;
    bottom: -.45rem;
    width: 1px;
    height: .45rem;
    background: rgba(255, 250, 242, .3);
    transform: none;
  }
}
</style>

<h2 id="postgresql의-row는-시간표를-들고-있다">PostgreSQL의 row는 시간표를 들고 있다</h2>

<p>PostgreSQL은 MVCC, 즉 multi-version concurrency control을 사용한다. 어떤 트랜잭션이 row를 읽을 때, 지금 저장된 값 하나만 보는 것이 아니라 “내 스냅샷에서 보이는 row version이 무엇인가”를 판단한다.</p>

<p>그 판단에 중요한 값이 row version의 <code class="language-plaintext highlighter-rouge">xmin</code>과 <code class="language-plaintext highlighter-rouge">xmax</code>다.</p>

<table class="wrap-table">
  <thead>
    <tr>
      <th>필드</th>
      <th>의미</th>
      <th>읽기 판단에서의 역할</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code>xmin</code></td>
      <td>이 row version을 만든 transaction ID</td>
      <td>내 스냅샷에서 이 생성 트랜잭션이 보이는 과거인지 판단한다.</td>
    </tr>
    <tr>
      <td><code>xmax</code></td>
      <td>이 row version을 삭제하거나 대체한 transaction ID</td>
      <td>내 스냅샷에서 이 row version이 이미 죽은 버전인지 판단한다.</td>
    </tr>
  </tbody>
</table>

<p>그래서 PostgreSQL에서 <code class="language-plaintext highlighter-rouge">UPDATE</code>는 보통 제자리 덮어쓰기가 아니다. 기존 row version의 <code class="language-plaintext highlighter-rouge">xmax</code>를 채워 과거 버전으로 만들고, 현재 transaction ID를 <code class="language-plaintext highlighter-rouge">xmin</code>으로 가진 새 row version을 만든다.</p>

<div class="gc-visual wrap-visual" role="img" aria-label="PostgreSQL MVCC에서 UPDATE는 기존 row version을 죽이고 새 row version을 만든다">
  <div class="gc-visual__header">
    <strong>UPDATE는 row version을 새로 만든다</strong>
    <span>freeze 여부와 무관하게, PostgreSQL의 일반 UPDATE는 기존 버전을 남기고 새 버전을 만든다.</span>
  </div>
  <div class="wrap-flow">
    <div class="wrap-step"><b>old tuple</b><span>xmin = 100<br />xmax = NULL</span></div>
    <div class="wrap-step"><b>UPDATE</b><span>transaction 200</span></div>
    <div class="wrap-step"><b>old tuple</b><span>xmin = 100<br />xmax = 200</span></div>
    <div class="wrap-step"><b>new tuple</b><span>xmin = 200<br />xmax = NULL</span></div>
    <div class="wrap-step"><b>VACUUM</b><span>아무도 old tuple을 보지 않으면 정리 가능</span></div>
  </div>
</div>

<p>이 방식 덕분에 읽기와 쓰기는 덜 충돌한다. 읽는 쪽은 자기 스냅샷에 맞는 과거 버전을 볼 수 있고, 쓰는 쪽은 새 버전을 만들 수 있다. 대신 오래된 버전이 계속 남으므로, 언젠가는 vacuum이 필요하다.</p>

<h2 id="xid-wraparound가-왜-문제가-되는가">XID wraparound가 왜 문제가 되는가</h2>

<p>PostgreSQL의 일반 transaction ID, 즉 XID는 32비트 공간을 사용한다. 그래서 충분히 오래 운영되고 transaction이 계속 발생하면 숫자는 언젠가 한 바퀴 돈다.</p>

<p>여기서 헷갈리기 쉬운 부분이 있다.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>XID 공간 자체는 32비트라 약 40억 개다.
하지만 MVCC 비교에서 안전하게 과거/미래를 구분하는 창은 양쪽 약 20억 개다.
</code></pre></div></div>

<p>PostgreSQL 공식 문서는 일반 XID 공간을 원형 공간으로 설명한다. 어떤 XID를 기준으로 약 20억 개는 더 오래된 XID이고, 다른 약 20억 개는 더 새로운 XID다. 그래서 row version이 만들어진 뒤 약 20억 transaction 이상 제대로 정리되지 않으면, 원래는 과거였던 row version이 갑자기 미래의 row처럼 해석될 수 있다.</p>

<p>그 결과는 단순 성능 저하가 아니다. 데이터는 물리적으로 남아 있어도 MVCC visibility 판단에서 보이지 않는 것처럼 해석될 수 있다. PostgreSQL 문서가 이 상황을 매우 강하게 경고하는 이유가 여기에 있다.</p>

<div class="gc-visual wrap-visual" role="img" aria-label="Transaction ID는 원형 공간이라 너무 오래된 unfrozen row는 wraparound 후 미래처럼 보일 수 있다">
  <div class="gc-visual__header">
    <strong>XID는 직선보다 원에 가깝다</strong>
    <span>문제는 숫자가 다시 0으로 오는 것 자체가 아니라, 오래된 row version의 의미가 뒤집히는 것이다.</span>
  </div>
  <div class="wrap-ring">
    <div class="wrap-chip" data-kind="safe">과거<br />visible</div>
    <div class="wrap-chip">현재<br />current XID</div>
    <div class="wrap-chip" data-kind="warn">미래<br />not visible</div>
    <div class="wrap-chip" data-kind="danger">wrap 후<br />의미 뒤집힘</div>
  </div>
</div>

<p>따라서 wraparound 대응의 핵심은 XID가 증가하지 못하게 막는 것이 아니다. XID는 계속 증가하고, 언젠가 돈다. 핵심은 너무 오래된 row version을 일반 XID 비교 대상에서 빼내는 것이다.</p>

<h2 id="freeze는-row를-없애지-않는다">Freeze는 row를 없애지 않는다</h2>

<p>VACUUM은 충분히 오래된 row version을 frozen 상태로 만든다. frozen row version은 모든 현재와 미래의 정상 트랜잭션에서 이미 과거의 row로 취급된다.</p>

<p>중요한 점은 이것이다.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>freeze는 row를 조회 불가능하게 만드는 작업이 아니다.
오히려 그 row version을 항상 과거로 안전하게 확정하는 작업이다.
</code></pre></div></div>

<p>공식 문서 기준으로 frozen row version은 <code class="language-plaintext highlighter-rouge">FrozenTransactionId</code>로 삽입 XID를 가진 것처럼 취급된다. 다만 최신 PostgreSQL에서는 예전처럼 실제 <code class="language-plaintext highlighter-rouge">xmin</code>을 항상 <code class="language-plaintext highlighter-rouge">FrozenTransactionId</code> 값으로 물리 치환한다고 단정하면 안 된다. PostgreSQL 9.4 이후에는 원래 <code class="language-plaintext highlighter-rouge">xmin</code>을 보존하고 tuple flag bit로 frozen 상태를 표시할 수 있다. 그러니 글이나 코드에서 “반드시 <code class="language-plaintext highlighter-rouge">xmin = 2</code>로 바뀐다”고 설명하는 것은 부정확하다.</p>

<p>개념적으로만 이렇게 이해하면 안전하다.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>일반 row version:
  xmin을 스냅샷과 비교해야 visibility를 판단할 수 있다.

frozen row version:
  이미 충분히 오래된 과거로 확정되어 XID 비교 부담에서 벗어난다.
</code></pre></div></div>

<p>frozen row를 <code class="language-plaintext highlighter-rouge">UPDATE</code>하면 freeze가 풀려서 같은 row가 제자리 수정되는 것이 아니다. PostgreSQL의 일반 MVCC 규칙대로 기존 frozen tuple은 과거 버전으로 남고, 현재 transaction ID를 가진 새 tuple이 만들어진다.</p>

<h2 id="relfrozenxid는-테이블의-안전-기준선이다"><code class="language-plaintext highlighter-rouge">relfrozenxid</code>는 테이블의 안전 기준선이다</h2>

<p>PostgreSQL은 테이블마다 <code class="language-plaintext highlighter-rouge">pg_class.relfrozenxid</code>를 관리한다. 이 값은 대략 다음 의미를 가진다.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>이 테이블에서 relfrozenxid보다 오래된 transaction ID는
이미 frozen 처리되어 안전하다고 볼 수 있다.
</code></pre></div></div>

<p>정확히는 최근 <code class="language-plaintext highlighter-rouge">relfrozenxid</code>를 앞으로 당긴 VACUUM이 끝났을 때, 테이블에 남아 있는 가장 오래된 unfrozen XID를 추적하는 값이다. 그래서 PostgreSQL은 <code class="language-plaintext highlighter-rouge">age(relfrozenxid)</code>를 보며 어떤 테이블이 얼마나 오래 freeze 관리를 받지 못했는지 판단한다.</p>

<div class="gc-visual wrap-visual" role="img" aria-label="current XID가 증가하면 relfrozenxid가 움직이지 않은 테이블의 age가 커진다">
  <div class="gc-visual__header">
    <strong>테이블은 움직이지 않아도 늙는다</strong>
    <span>테이블에 INSERT가 없어도 클러스터의 current XID는 계속 흐르고, relfrozenxid가 멈춰 있으면 age는 커진다.</span>
  </div>
  <div class="wrap-grid">
    <div class="wrap-card"><b>current XID</b><span>클러스터의 transaction 흐름을 따라 계속 증가한다.</span></div>
    <div class="wrap-card"><b>relfrozenxid</b><span>그 테이블이 어디까지 freeze되어 안전한지 나타낸다.</span></div>
    <div class="wrap-card"><b>age(relfrozenxid)</b><span>current XID와 relfrozenxid 사이의 거리다.</span></div>
    <div class="wrap-card"><b>위험 신호</b><span>age가 커지면 anti-wraparound vacuum 대상이 된다.</span></div>
  </div>
</div>

<p>이 때문에 “이 테이블은 이제 SELECT만 하는데 왜 autovacuum이 도는가?”라는 질문이 생긴다. 이유는 단순하다. 테이블이 바뀌지 않아도, 클러스터 전체의 XID는 다른 트랜잭션 때문에 계속 흐른다. 과거 파티션이나 히스토리 테이블도 freeze를 끝내지 않았다면 계속 늙는다.</p>

<h2 id="일반-autovacuum과-anti-wraparound-autovacuum은-목적이-다르다">일반 autovacuum과 anti-wraparound autovacuum은 목적이 다르다</h2>

<p>둘 다 autovacuum worker가 수행할 수 있고, 둘 다 VACUUM의 세계 안에 있다. 하지만 운영자가 느끼는 의미는 다르다.</p>

<table class="wrap-table">
  <thead>
    <tr>
      <th>구분</th>
      <th>일반 autovacuum</th>
      <th>to prevent wraparound</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>주요 트리거</td>
      <td>UPDATE/DELETE로 dead tuple이 쌓임</td>
      <td><code>relfrozenxid</code> age가 위험 수준에 가까워짐</td>
    </tr>
    <tr>
      <td>목적</td>
      <td>공간 재사용, bloat 완화, 통계와 visibility map 관리</td>
      <td>XID wraparound로 인한 MVCC 의미 붕괴 방지</td>
    </tr>
    <tr>
      <td>스캔 범위</td>
      <td>주로 dead tuple이 있을 법한 페이지 중심</td>
      <td>unfrozen XID/MXID가 있을 수 있는 페이지까지 보는 aggressive scan</td>
    </tr>
    <tr>
      <td>부하 성격</td>
      <td>자주 조금씩 돌도록 튜닝하는 것이 목표</td>
      <td>늦게 발견되면 대용량 테이블에서 I/O와 CPU가 집중될 수 있음</td>
    </tr>
    <tr>
      <td>중단 가능성</td>
      <td>충돌 상황에서 더 쉽게 양보할 수 있음</td>
      <td>일반 autovacuum처럼 자동으로 쉽게 취소되지 않으며 강제 중단은 위험하다</td>
    </tr>
  </tbody>
</table>

<p>여기서 “테이블 전체를 반드시 한 번도 빠짐없이 읽는다”라고 단정하면 조금 과하다. 공식 문서 기준으로 aggressive vacuum은 dead tuple이 있을 법한 페이지만 보는 것이 아니라, unfrozen XID나 MXID가 있을 수 있는 페이지를 방문한다. 이미 all-frozen으로 표시된 페이지는 건너뛸 수 있다.</p>

<p>그래도 운영 체감은 “테이블 전체에 가까운 무거운 스캔”으로 다가올 때가 많다. 특히 대용량 히스토리 테이블이 오래 freeze되지 않았다면, 어느 날 갑자기 특정 테이블의 autovacuum이 CPU와 I/O를 크게 먹는 것처럼 보일 수 있다.</p>

<h2 id="왜-파티셔닝만으로는-해결되지-않는가">왜 파티셔닝만으로는 해결되지 않는가</h2>

<p>파티셔닝은 중요하다. 하지만 파티셔닝 자체가 wraparound 관리를 자동으로 끝내지는 않는다.</p>

<p>각 파티션은 실질적으로 별도 relation이고, 각자 <code class="language-plaintext highlighter-rouge">relfrozenxid</code>를 가진다. 월별 파티션을 만들었고 2024년 1월 파티션이 더 이상 쓰이지 않더라도, 그 파티션이 충분히 freeze되지 않았다면 <code class="language-plaintext highlighter-rouge">age(relfrozenxid)</code>는 계속 커진다.</p>

<p>그래서 오래된 파티션 전략은 둘 중 하나로 명확해야 한다.</p>

<ul>
  <li>보관이 필요 없으면 정책에 따라 <code class="language-plaintext highlighter-rouge">DROP</code> 또는 archive 후 제거한다.</li>
  <li>보관해야 하면 트래픽 낮은 시간에 계획적으로 <code class="language-plaintext highlighter-rouge">VACUUM (FREEZE)</code>를 수행해 all-frozen 상태에 가깝게 만든다.</li>
</ul>

<p>이렇게 해두면 미래의 aggressive vacuum 부담이 줄어든다. 특히 all-frozen page가 잘 유지되는 과거 파티션은 다음 vacuum에서 건너뛸 수 있는 여지가 커진다. 반대로 과거 파티션에도 업데이트나 row lock이 계속 발생하면 all-frozen 상태가 깨질 수 있고, 다음 vacuum 비용이 다시 생긴다.</p>

<h2 id="운영에서-먼저-볼-지표">운영에서 먼저 볼 지표</h2>

<p>가장 먼저 볼 것은 database와 relation의 freeze age다.</p>

<div class="language-sql highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">SELECT</span>
    <span class="n">datname</span><span class="p">,</span>
    <span class="n">age</span><span class="p">(</span><span class="n">datfrozenxid</span><span class="p">)</span> <span class="k">AS</span> <span class="n">xid_age</span>
<span class="k">FROM</span> <span class="n">pg_database</span>
<span class="k">WHERE</span> <span class="n">datallowconn</span>
<span class="k">ORDER</span> <span class="k">BY</span> <span class="n">xid_age</span> <span class="k">DESC</span><span class="p">;</span>
</code></pre></div></div>

<p>테이블 단위로는 TOAST table까지 같이 보는 편이 낫다. PostgreSQL 공식 문서도 relation과 TOAST relation의 age를 함께 확인하는 예시를 제시한다.</p>

<div class="language-sql highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">SELECT</span>
    <span class="k">c</span><span class="p">.</span><span class="n">oid</span><span class="p">::</span><span class="n">regclass</span> <span class="k">AS</span> <span class="k">table_name</span><span class="p">,</span>
    <span class="n">greatest</span><span class="p">(</span><span class="n">age</span><span class="p">(</span><span class="k">c</span><span class="p">.</span><span class="n">relfrozenxid</span><span class="p">),</span> <span class="n">age</span><span class="p">(</span><span class="n">t</span><span class="p">.</span><span class="n">relfrozenxid</span><span class="p">))</span> <span class="k">AS</span> <span class="n">xid_age</span><span class="p">,</span>
    <span class="k">c</span><span class="p">.</span><span class="n">relfrozenxid</span><span class="p">,</span>
    <span class="n">t</span><span class="p">.</span><span class="n">relfrozenxid</span> <span class="k">AS</span> <span class="n">toast_relfrozenxid</span>
<span class="k">FROM</span> <span class="n">pg_class</span> <span class="k">c</span>
<span class="k">LEFT</span> <span class="k">JOIN</span> <span class="n">pg_class</span> <span class="n">t</span> <span class="k">ON</span> <span class="k">c</span><span class="p">.</span><span class="n">reltoastrelid</span> <span class="o">=</span> <span class="n">t</span><span class="p">.</span><span class="n">oid</span>
<span class="k">WHERE</span> <span class="k">c</span><span class="p">.</span><span class="n">relkind</span> <span class="k">IN</span> <span class="p">(</span><span class="s1">'r'</span><span class="p">,</span> <span class="s1">'m'</span><span class="p">)</span>
<span class="k">ORDER</span> <span class="k">BY</span> <span class="n">xid_age</span> <span class="k">DESC</span>
<span class="k">LIMIT</span> <span class="mi">20</span><span class="p">;</span>
</code></pre></div></div>

<p>장기 트랜잭션도 같이 봐야 한다. 오래 열린 snapshot은 vacuum이 old row version을 안전하게 정리하거나 freeze하는 일을 어렵게 만든다.</p>

<div class="language-sql highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">SELECT</span>
    <span class="n">pid</span><span class="p">,</span>
    <span class="k">state</span><span class="p">,</span>
    <span class="n">xact_start</span><span class="p">,</span>
    <span class="n">now</span><span class="p">()</span> <span class="o">-</span> <span class="n">xact_start</span> <span class="k">AS</span> <span class="n">xact_age</span><span class="p">,</span>
    <span class="n">query</span>
<span class="k">FROM</span> <span class="n">pg_stat_activity</span>
<span class="k">WHERE</span> <span class="n">xact_start</span> <span class="k">IS</span> <span class="k">NOT</span> <span class="k">NULL</span>
<span class="k">ORDER</span> <span class="k">BY</span> <span class="n">xact_start</span><span class="p">;</span>
</code></pre></div></div>

<p>prepared transaction과 replication slot도 확인 대상이다. 특히 replication slot의 <code class="language-plaintext highlighter-rouge">xmin</code>이나 <code class="language-plaintext highlighter-rouge">catalog_xmin</code>이 오래 붙잡혀 있으면 vacuum 진행이 밀릴 수 있다.</p>

<div class="language-sql highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">SELECT</span>
    <span class="n">gid</span><span class="p">,</span>
    <span class="n">prepared</span><span class="p">,</span>
    <span class="n">now</span><span class="p">()</span> <span class="o">-</span> <span class="n">prepared</span> <span class="k">AS</span> <span class="n">prepared_age</span><span class="p">,</span>
    <span class="k">owner</span><span class="p">,</span>
    <span class="k">database</span>
<span class="k">FROM</span> <span class="n">pg_prepared_xacts</span>
<span class="k">ORDER</span> <span class="k">BY</span> <span class="n">prepared</span><span class="p">;</span>
</code></pre></div></div>

<div class="language-sql highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">SELECT</span>
    <span class="n">slot_name</span><span class="p">,</span>
    <span class="n">active</span><span class="p">,</span>
    <span class="n">xmin</span><span class="p">,</span>
    <span class="n">catalog_xmin</span><span class="p">,</span>
    <span class="n">restart_lsn</span>
<span class="k">FROM</span> <span class="n">pg_replication_slots</span><span class="p">;</span>
</code></pre></div></div>

<h2 id="튜닝은-autovacuum을-끄는-일이-아니다">튜닝은 autovacuum을 끄는 일이 아니다</h2>

<p>가장 위험한 대응은 “autovacuum이 부하를 주니 꺼버리자”다. PostgreSQL은 wraparound 방지를 위해 autovacuum이 비활성화되어 있어도 필요한 autovacuum을 실행할 수 있다. 그만큼 이 작업은 선택 기능이 아니라 안전장치다.</p>

<p>운영에서 할 일은 autovacuum을 끄는 것이 아니라, 너무 늦게 응급 모드로 몰리지 않도록 평소에 작게 나누어 일하게 만드는 것이다.</p>

<p>대용량 테이블에서는 기본 scale factor가 너무 보수적일 수 있다. 예를 들어 10억 row 테이블에서 <code class="language-plaintext highlighter-rouge">autovacuum_vacuum_scale_factor</code>가 크면, vacuum이 시작되기 전에 너무 많은 dead tuple이나 unfrozen page가 쌓일 수 있다.</p>

<p>상황에 따라 테이블별 storage parameter를 조정한다.</p>

<div class="language-sql highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">ALTER</span> <span class="k">TABLE</span> <span class="n">big_event_history</span> <span class="k">SET</span> <span class="p">(</span>
    <span class="n">autovacuum_vacuum_scale_factor</span> <span class="o">=</span> <span class="mi">0</span><span class="p">.</span><span class="mi">01</span><span class="p">,</span>
    <span class="n">autovacuum_vacuum_threshold</span> <span class="o">=</span> <span class="mi">50000</span>
<span class="p">);</span>
</code></pre></div></div>

<p>정적 대용량 테이블이나 닫힌 파티션은 계획적으로 freeze한다.</p>

<div class="language-sql highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">VACUUM</span> <span class="p">(</span><span class="k">FREEZE</span><span class="p">,</span> <span class="k">VERBOSE</span><span class="p">)</span> <span class="n">event_history_2025_01</span><span class="p">;</span>
</code></pre></div></div>

<p>이 작업도 무겁다. 그래서 운영 중 갑자기 마주치는 것이 아니라, 배치 창이나 트래픽이 낮은 시간에 분산해서 수행하는 편이 낫다.</p>

<h2 id="vacuum-full은-답이-아닐-때가-많다"><code class="language-plaintext highlighter-rouge">VACUUM FULL</code>은 답이 아닐 때가 많다</h2>

<p>wraparound 문제를 보고 <code class="language-plaintext highlighter-rouge">VACUUM FULL</code>을 떠올리기 쉽지만, 둘은 목적이 다르다.</p>

<p>일반 <code class="language-plaintext highlighter-rouge">VACUUM</code>은 dead row version을 정리하고 내부 공간을 재사용 가능하게 만들지만, 대부분의 경우 디스크 파일 크기를 즉시 OS에 반환하지 않는다. <code class="language-plaintext highlighter-rouge">VACUUM FULL</code>은 테이블을 새로 써서 파일 크기를 줄일 수 있지만, 더 무겁고 <code class="language-plaintext highlighter-rouge">ACCESS EXCLUSIVE</code> lock이 필요하다.</p>

<p>wraparound 방지의 핵심은 디스크 파일을 줄이는 것이 아니라 오래된 row version을 frozen 상태로 만들어 XID 비교 위험에서 빼내는 것이다. 따라서 bloat 정리와 wraparound 예방을 섞어서 생각하면 잘못된 결정을 하기 쉽다.</p>

<h2 id="실무-체크리스트">실무 체크리스트</h2>

<p>운영에서 반복해서 봐야 할 질문은 다음이다.</p>

<ul>
  <li><code class="language-plaintext highlighter-rouge">age(datfrozenxid)</code>가 높은 database가 있는가?</li>
  <li><code class="language-plaintext highlighter-rouge">age(relfrozenxid)</code>가 높은 대형 table이나 TOAST table이 있는가?</li>
  <li><code class="language-plaintext highlighter-rouge">autovacuum_freeze_max_age</code>에 가까워지는 relation이 있는가?</li>
  <li><code class="language-plaintext highlighter-rouge">idle in transaction</code> 상태의 오래된 세션이 있는가?</li>
  <li>장기 batch transaction이 snapshot을 오래 붙잡고 있지 않은가?</li>
  <li>prepared transaction이 방치되어 있지 않은가?</li>
  <li>replication slot이 오래된 <code class="language-plaintext highlighter-rouge">xmin</code>이나 <code class="language-plaintext highlighter-rouge">catalog_xmin</code>을 붙잡고 있지 않은가?</li>
  <li>닫힌 파티션에 대해 <code class="language-plaintext highlighter-rouge">VACUUM (FREEZE)</code> 또는 drop/archive 정책이 있는가?</li>
  <li>대용량 hot table의 autovacuum threshold와 scale factor가 기본값에만 묶여 있지 않은가?</li>
  <li>autovacuum 로그를 남겨 어떤 테이블에서 오래 걸리는지 볼 수 있는가?</li>
</ul>

<h2 id="한-문장으로-정리하면">한 문장으로 정리하면</h2>

<p>PostgreSQL의 wraparound 관리는 XID가 도는 것을 막는 일이 아니다. XID는 원형 공간에서 계속 흐른다. 중요한 것은 오래된 row version을 frozen 상태로 확정해, 한 바퀴 돈 뒤에도 과거가 미래처럼 보이지 않게 만드는 것이다.</p>

<p><code class="language-plaintext highlighter-rouge">autovacuum: ... (to prevent wraparound)</code>는 “청소가 조금 늦었다”는 신호가 아니라 “MVCC의 시간표를 안전하게 다시 고정해야 한다”는 신호에 가깝다. 이 로그를 줄이는 가장 좋은 방법은 autovacuum을 피하는 것이 아니라, 테이블의 나이를 평소에 보고, 대형 테이블과 닫힌 파티션이 너무 늙기 전에 작게 관리하는 것이다.</p>

<h2 id="참고한-자료">참고한 자료</h2>

<ul>
  <li><a href="https://www.postgresql.org/docs/current/routine-vacuuming.html">PostgreSQL Documentation: Routine Vacuuming</a></li>
  <li><a href="https://www.postgresql.org/docs/current/runtime-config-vacuum.html">PostgreSQL Documentation: Vacuuming Configuration</a></li>
  <li><a href="https://www.postgresql.org/docs/current/mvcc-intro.html">PostgreSQL Documentation: MVCC Introduction</a></li>
  <li><a href="https://www.postgresql.org/docs/current/catalog-pg-class.html">PostgreSQL Documentation: pg_class</a></li>
</ul>]]></content><author><name>WooHyuk</name></author><category term="develop" /><category term="postgresql" /><category term="mvcc" /><category term="vacuum" /><category term="autovacuum" /><category term="database" /><summary type="html"><![CDATA[PostgreSQL MVCC에서 transaction ID wraparound가 왜 위험한지, freeze와 relfrozenxid가 어떤 역할을 하는지, autovacuum to prevent wraparound를 운영에서 어떻게 바라봐야 하는지 정리합니다.]]></summary></entry><entry><title type="html">Redis를 캐시로만 보면 놓치는 것들</title><link href="https://dngur6344.github.io/develop/2026-05-28-redis-spring-lettuce/" rel="alternate" type="text/html" title="Redis를 캐시로만 보면 놓치는 것들" /><published>2026-05-28T00:00:00+09:00</published><updated>2026-05-28T00:00:00+09:00</updated><id>https://dngur6344.github.io/develop/redis-spring-lettuce</id><content type="html" xml:base="https://dngur6344.github.io/develop/2026-05-28-redis-spring-lettuce/"><![CDATA[<p>Redis를 처음 만나면 보통 이렇게 기억한다.</p>

<blockquote>
  <p>메모리에 올려두는 빠른 cache.</p>
</blockquote>

<p>틀린 말은 아니다. 하지만 Redis를 cache로만 보면 중요한 부분을 많이 놓친다. Redis는 단순 key-value cache라기보다, 메모리 위에서 동작하는 data structure server에 가깝다. 문자열 하나를 저장하는 것뿐 아니라 Hash, List, Set, Sorted Set, Stream 같은 자료구조를 명령어 단위로 조작한다.</p>

<p>그래서 Redis를 잘 쓴다는 말은 두 가지를 같이 이해한다는 뜻이다.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>데이터를 어떻게 빨리 읽을 것인가
데이터가 많아지고 장애가 날 때 어떻게 버틸 것인가
</code></pre></div></div>

<style>
.redis-visual {
  --redis-panel: rgba(255, 250, 242, .075);
  --redis-line: rgba(255, 250, 242, .18);
  --redis-ink: #fffaf2;
  --redis-muted: rgba(255, 250, 242, .7);
  --redis-red: #d98989;
  --redis-gold: #d8b16f;
  --redis-green: #8fbf9b;
  --redis-blue: #8fb4d9;
}

.redis-visual .redis-grid,
.redis-visual .redis-flow,
.redis-visual .redis-lanes,
.redis-visual .redis-note-grid {
  display: grid;
  gap: .65rem;
}

.redis-visual .redis-grid {
  grid-template-columns: repeat(2, minmax(0, 1fr));
}

.redis-visual .redis-lanes {
  grid-template-columns: repeat(3, minmax(0, 1fr));
}

.redis-visual .redis-flow {
  grid-template-columns: repeat(5, minmax(0, 1fr));
}

.redis-visual .redis-note-grid {
  grid-template-columns: repeat(4, minmax(0, 1fr));
}

.redis-visual .redis-card,
.redis-visual .redis-step,
.redis-visual .redis-note,
.redis-visual .redis-lane {
  min-width: 0;
  border: 1px solid var(--redis-line);
  border-radius: 6px;
  background: var(--redis-panel);
}

.redis-visual .redis-card,
.redis-visual .redis-note,
.redis-visual .redis-lane {
  padding: .72rem;
}

.redis-visual .redis-card-title,
.redis-visual .redis-step b,
.redis-visual .redis-note b,
.redis-visual .redis-lane b {
  display: block;
  color: var(--redis-ink);
}

.redis-visual .redis-card-title {
  margin-bottom: .48rem;
  font-size: .74rem;
}

.redis-visual .redis-card p,
.redis-visual .redis-step span,
.redis-visual .redis-note span,
.redis-visual .redis-lane span {
  display: block;
  margin: 0;
  color: var(--redis-muted);
  font-size: .64rem;
  line-height: 1.45;
}

.redis-visual .redis-step {
  position: relative;
  padding: .58rem .62rem;
}

.redis-visual .redis-step:not(:last-child)::after {
  content: "->";
  position: absolute;
  right: -.49rem;
  top: 50%;
  color: rgba(255, 250, 242, .58);
  font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
  font-size: .66rem;
  transform: translate(50%, -50%);
}

.redis-visual .redis-step b {
  font-size: .66rem;
}

.redis-visual .redis-step span {
  margin-top: .14rem;
}

.redis-visual .redis-path {
  margin: 0;
  padding: 0;
  list-style: none;
}

.redis-visual .redis-path li {
  display: grid;
  grid-template-columns: auto minmax(0, 1fr);
  gap: .5rem;
  min-width: 0;
  padding: .52rem .56rem;
  border: 1px solid rgba(255, 250, 242, .14);
  border-radius: 6px;
  background: rgba(8, 10, 17, .24);
}

.redis-visual .redis-path li + li {
  margin-top: .42rem;
}

.redis-visual .redis-path b {
  width: 1.35rem;
  height: 1.35rem;
  border: 1px solid rgba(255, 250, 242, .16);
  border-radius: 50%;
  color: var(--redis-ink);
  background: rgba(255, 250, 242, .08);
  font-size: .68rem;
  line-height: 1.35rem;
  text-align: center;
}

.redis-visual .redis-path strong,
.redis-visual .redis-path span {
  display: block;
}

.redis-visual .redis-path strong {
  color: var(--redis-ink);
  font-size: .68rem;
}

.redis-visual .redis-path span {
  color: var(--redis-muted);
  font-size: .62rem;
  line-height: 1.45;
}

.redis-visual code {
  color: var(--redis-ink);
  background: rgba(8, 10, 17, .32);
}

.redis-visual .redis-chip-row {
  display: grid;
  grid-template-columns: repeat(4, minmax(0, 1fr));
  gap: .4rem;
  margin-top: .55rem;
}

.redis-visual .redis-chip {
  padding: .35rem .3rem;
  border: 1px solid rgba(143, 180, 217, .35);
  border-radius: 5px;
  color: var(--redis-ink);
  background: rgba(143, 180, 217, .1);
  font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
  font-size: .58rem;
  line-height: 1.22;
  text-align: center;
}

.redis-visual .redis-chip[data-kind="hot"] {
  border-color: rgba(216, 177, 111, .68);
  background: rgba(216, 177, 111, .14);
}

.redis-visual .redis-chip[data-kind="danger"] {
  border-color: rgba(217, 137, 137, .68);
  background: rgba(217, 137, 137, .12);
}

.redis-compare-table {
  width: 100%;
  border: 1px solid rgba(143, 94, 60, .32);
  border-collapse: separate;
  border-spacing: 0;
  border-radius: 6px;
  overflow: hidden;
  background: rgba(255, 250, 242, .88) !important;
  font-size: .88rem;
}

.redis-compare-table th,
.redis-compare-table td {
  border: 0;
  border-bottom: 1px solid rgba(143, 94, 60, .22);
  color: var(--coffee-ink) !important;
  vertical-align: top;
}

.redis-compare-table th {
  background: rgba(47, 33, 24, .92) !important;
  color: #fffaf2 !important;
  font-weight: 700;
}

.redis-compare-table tbody tr:nth-child(odd) td {
  background: rgba(255, 250, 242, .9) !important;
}

.redis-compare-table tbody tr:nth-child(even) td {
  background: rgba(247, 236, 222, .92) !important;
}

.redis-compare-table tbody tr:last-child td {
  border-bottom: 0;
}

.redis-compare-table td:first-child {
  color: #4d2d1e !important;
  font-weight: 700;
}

body.dark-mode .redis-compare-table {
  background: rgba(9, 13, 22, .86) !important;
  border-color: rgba(231, 212, 189, .24) !important;
  box-shadow: 0 1rem 2rem rgba(0, 0, 0, .22) !important;
}

body.dark-mode .redis-compare-table th {
  color: #fff4e5 !important;
  background: rgba(244, 234, 220, .12) !important;
}

body.dark-mode .redis-compare-table td {
  color: #ead8c3 !important;
  border-color: rgba(231, 212, 189, .18) !important;
}

body.dark-mode .redis-compare-table tbody tr:nth-child(odd) td {
  background: rgba(255, 250, 242, .045) !important;
}

body.dark-mode .redis-compare-table tbody tr:nth-child(even) td {
  background: rgba(255, 250, 242, .074) !important;
}

@media screen and (prefers-color-scheme: dark) {
  body:not(.light-mode) .redis-compare-table {
    background: rgba(9, 13, 22, .86) !important;
    border-color: rgba(231, 212, 189, .24) !important;
    box-shadow: 0 1rem 2rem rgba(0, 0, 0, .22) !important;
  }

  body:not(.light-mode) .redis-compare-table th {
    color: #fff4e5 !important;
    background: rgba(244, 234, 220, .12) !important;
  }

  body:not(.light-mode) .redis-compare-table td {
    color: #ead8c3 !important;
    border-color: rgba(231, 212, 189, .18) !important;
  }

  body:not(.light-mode) .redis-compare-table tbody tr:nth-child(odd) td {
    background: rgba(255, 250, 242, .045) !important;
  }

  body:not(.light-mode) .redis-compare-table tbody tr:nth-child(even) td {
    background: rgba(255, 250, 242, .074) !important;
  }
}

body.light-mode .redis-compare-table {
  background: rgba(255, 250, 242, .88) !important;
  border-color: rgba(143, 94, 60, .32) !important;
  box-shadow: none !important;
}

body.light-mode .redis-compare-table th {
  background: rgba(47, 33, 24, .92) !important;
  color: #fffaf2 !important;
}

body.light-mode .redis-compare-table td {
  color: var(--coffee-ink) !important;
  border-color: rgba(143, 94, 60, .22) !important;
}

body.light-mode .redis-compare-table tbody tr:nth-child(odd) td {
  background: rgba(255, 250, 242, .9) !important;
}

body.light-mode .redis-compare-table tbody tr:nth-child(even) td {
  background: rgba(247, 236, 222, .92) !important;
}

body.light-mode .redis-compare-table td:first-child {
  color: #4d2d1e !important;
}

@media screen and (max-width: 56rem) {
  .redis-visual .redis-grid,
  .redis-visual .redis-flow,
  .redis-visual .redis-lanes,
  .redis-visual .redis-note-grid {
    grid-template-columns: 1fr;
  }

  .redis-visual .redis-step:not(:last-child)::after {
    content: "";
    left: 50%;
    right: auto;
    top: auto;
    bottom: -.45rem;
    width: 1px;
    height: .45rem;
    background: rgba(255, 250, 242, .3);
    transform: none;
  }

  .redis-visual .redis-chip-row {
    grid-template-columns: repeat(2, minmax(0, 1fr));
  }
}
</style>

<div class="gc-visual redis-visual" role="img" aria-label="Redis는 cache, session store, counter, lock, rate limit, event queue 등 여러 용도로 쓰이는 in-memory data structure server다">
  <div class="gc-visual__header">
    <strong>Redis를 쓰는 자리</strong>
    <span>빠른 cache 하나로 시작하지만, 실제 운영에서는 TTL, memory, persistence, topology까지 같이 설계해야 한다.</span>
  </div>
  <div class="redis-note-grid">
    <div class="redis-note"><b>Cache</b><span>DB 부하를 줄이고 read latency를 낮춘다.</span></div>
    <div class="redis-note"><b>Session</b><span>TTL이 있는 사용자 상태를 여러 서버가 공유한다.</span></div>
    <div class="redis-note"><b>Counter</b><span>조회수, rate limit, idempotency marker에 쓴다.</span></div>
    <div class="redis-note"><b>Queue</b><span>Pub/Sub, List, Stream으로 이벤트 흐름을 만든다.</span></div>
  </div>
</div>

<h2 id="redis는-자료구조-서버다">Redis는 자료구조 서버다</h2>

<p>Redis의 기본 단위는 key다. 하지만 value는 단순 문자열만이 아니다.</p>

<table class="redis-compare-table">
  <thead>
    <tr>
      <th>자료구조</th>
      <th>잘 맞는 용도</th>
      <th>조심할 점</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>String</td>
      <td>cache value, counter, token, flag</td>
      <td>큰 JSON을 통째로 저장하면 부분 갱신과 네트워크 비용이 커진다.</td>
    </tr>
    <tr>
      <td>Hash</td>
      <td>객체의 field 단위 저장, profile, 설정값</td>
      <td>field가 지나치게 많으면 한 key 안의 큰 collection이 된다.</td>
    </tr>
    <tr>
      <td>List</td>
      <td>간단한 FIFO/LIFO queue, blocking pop</td>
      <td>ack/retry/consumer group이 필요하면 Stream이 낫다.</td>
    </tr>
    <tr>
      <td>Set</td>
      <td>중복 없는 membership, tag, unique user set</td>
      <td>큰 set 연산은 single thread를 오래 붙잡을 수 있다.</td>
    </tr>
    <tr>
      <td>Sorted Set</td>
      <td>ranking, score 기반 range query, delayed job</td>
      <td>score 갱신이 잦고 크기가 크면 메모리와 CPU를 같이 쓴다.</td>
    </tr>
    <tr>
      <td>Stream</td>
      <td>append-only event log, consumer group</td>
      <td>trim, pending entries, retry 정책을 같이 설계해야 한다.</td>
    </tr>
  </tbody>
</table>

<p>Redis가 빠른 이유는 대부분의 명령을 메모리에서 처리하고, event loop 중심으로 단순하게 실행하기 때문이다. 하지만 이 말은 반대로, 오래 걸리는 명령 하나가 다른 요청들을 밀어낼 수 있다는 뜻이기도 하다.</p>

<p>그래서 운영 Redis에서 제일 먼저 피해야 할 습관은 “큰 key를 만들고, 큰 명령을 아무렇지 않게 호출하는 것”이다.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>위험한 냄새가 나는 명령:
KEYS *
FLUSHALL
FLUSHDB
SAVE
큰 collection에 대한 전체 조회
큰 key에 대한 DEL
</code></pre></div></div>

<p>공식 문서도 <code class="language-plaintext highlighter-rouge">KEYS</code>는 production에서 극도로 조심해야 하고, 일반 애플리케이션 코드에서는 <code class="language-plaintext highlighter-rouge">SCAN</code>이나 별도 index set을 고려하라고 설명한다.</p>

<h2 id="ttl은-collection-내부가-아니라-key에-붙는다">TTL은 collection 내부가 아니라 key에 붙는다</h2>

<p>Redis의 expire는 key 단위다.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>SET user:1:name "dngur"
EXPIRE user:1:name 60
</code></pre></div></div>

<p>이 key는 60초 뒤 만료될 수 있다. 하지만 Hash의 field 하나, List의 item 하나, Set의 member 하나에 개별 TTL이 붙는 것은 아니다.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>HSET user:1 name "dngur" age 20
EXPIRE user:1 60
</code></pre></div></div>

<p>이 경우 TTL은 <code class="language-plaintext highlighter-rouge">user:1</code>이라는 Hash key 전체에 적용된다. field별 TTL이 필요하다면 key를 더 잘게 나누거나, Sorted Set에 만료 timestamp를 score로 넣고 별도 정리 작업을 두는 식으로 설계를 바꿔야 한다.</p>

<p>TTL은 cache freshness를 다루는 도구이고, eviction은 memory pressure를 다루는 도구다. 둘은 비슷해 보이지만 목적이 다르다.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>TTL:
  시간이 지나면 사라져도 되는 데이터

eviction:
  maxmemory를 넘었을 때 무엇을 버릴지 정하는 정책
</code></pre></div></div>

<h2 id="maxmemory와-eviction-policy">maxmemory와 eviction policy</h2>

<p>Redis가 <code class="language-plaintext highlighter-rouge">maxmemory</code>에 도달하면, 설정된 <code class="language-plaintext highlighter-rouge">maxmemory-policy</code>에 따라 key를 제거하거나 쓰기를 거부한다.</p>

<div class="gc-visual redis-visual" role="img" aria-label="Redis eviction policy는 maxmemory 초과 시 allkeys 또는 volatile key 집합에서 LRU, LFU, random, TTL 기준으로 제거 대상을 고른다">
  <div class="gc-visual__header">
    <strong>Eviction은 TTL과 다르다</strong>
    <span>TTL은 시간이 기준이고, eviction은 메모리 압박이 기준이다.</span>
  </div>
  <div class="redis-lanes">
    <div class="redis-lane">
      <b>allkeys-*</b>
      <span>전체 keyspace에서 제거 후보를 고른다. 순수 cache라면 보통 이쪽이 단순하다.</span>
      <div class="redis-chip-row">
        <span class="redis-chip">allkeys-lru</span>
        <span class="redis-chip">allkeys-lfu</span>
        <span class="redis-chip">allkeys-random</span>
        <span class="redis-chip" data-kind="hot">cache only</span>
      </div>
    </div>
    <div class="redis-lane">
      <b>volatile-*</b>
      <span>TTL이 있는 key만 제거 후보가 된다. 영구 key와 임시 key를 섞는 설계에서 쓴다.</span>
      <div class="redis-chip-row">
        <span class="redis-chip">volatile-lru</span>
        <span class="redis-chip">volatile-lfu</span>
        <span class="redis-chip">volatile-ttl</span>
        <span class="redis-chip">volatile-random</span>
      </div>
    </div>
    <div class="redis-lane">
      <b>noeviction</b>
      <span>메모리를 넘으면 쓰기 명령이 실패한다. 데이터 손실보다 write failure가 낫다면 선택한다.</span>
      <div class="redis-chip-row">
        <span class="redis-chip" data-kind="danger">write error</span>
        <span class="redis-chip">read ok</span>
        <span class="redis-chip">strict memory</span>
        <span class="redis-chip">monitoring</span>
      </div>
    </div>
  </div>
</div>

<p>순수 cache라면 <code class="language-plaintext highlighter-rouge">allkeys-lru</code>나 <code class="language-plaintext highlighter-rouge">allkeys-lfu</code>가 단순하다. TTL이 없는 cache key도 제거 후보가 되기 때문이다. 반대로 중요한 영구 key와 임시 key가 같은 Redis에 섞여 있다면 <code class="language-plaintext highlighter-rouge">volatile-*</code> 정책이 더 안전해 보일 수 있다. 다만 TTL 없는 key는 제거 후보가 아니므로, 메모리 압박이 왔을 때 정책이 기대대로 동작하는지 반드시 확인해야 한다.</p>

<p><code class="language-plaintext highlighter-rouge">noeviction</code>은 key를 버리지 않는다. 대신 쓰기 명령이 에러를 반환할 수 있다. 캐시로 쓰는 Redis라면 장애 모드가 더 거칠어질 수 있고, source of truth처럼 쓰는 Redis라면 오히려 명시적 실패가 더 안전할 수 있다.</p>

<p>여기서 중요한 운영 감각이 하나 있다.</p>

<blockquote>
  <p>Redis eviction은 정확한 전역 LRU/LFU가 아니라 샘플링 기반 근사 알고리즘이다.</p>
</blockquote>

<p>그래서 <code class="language-plaintext highlighter-rouge">maxmemory-samples</code>를 키우면 더 정확한 후보를 고를 수 있지만, 그만큼 CPU 비용도 늘어난다.</p>

<h2 id="persistence는-cache인지-data인지-먼저-정해야-한다">Persistence는 cache인지 data인지 먼저 정해야 한다</h2>

<p>Redis는 in-memory로 동작하지만 디스크 persistence 옵션을 갖고 있다.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>RDB = 특정 시점 snapshot
AOF = write command log
No persistence = 재시작하면 사라져도 되는 cache
RDB + AOF = 둘을 함께 사용
</code></pre></div></div>

<p>RDB는 point-in-time snapshot이다. 파일이 작고 재시작이 빠른 편이지만, snapshot 사이에 장애가 나면 최근 데이터가 사라질 수 있다. AOF는 write operation을 log로 남기고 재시작 시 replay한다. 더 촘촘한 복구가 가능하지만 파일 크기, fsync 정책, rewrite 비용을 같이 봐야 한다.</p>

<p>Redis를 cache로만 쓴다면 persistence를 끄는 선택도 자연스럽다. 재시작 후 cache miss가 늘 뿐, 원본 DB에서 다시 채울 수 있기 때문이다.</p>

<p>반대로 session, idempotency key, rate limit counter, queue처럼 Redis 안의 데이터가 서비스 의미를 갖는다면 persistence와 replication을 반드시 같이 봐야 한다. “Redis가 빠르다”와 “Redis에 저장한 데이터가 반드시 안전하다”는 다른 문장이다.</p>

<h2 id="sentinel과-cluster는-해결하는-문제가-다르다">Sentinel과 Cluster는 해결하는 문제가 다르다</h2>

<p>Sentinel과 Cluster는 둘 다 고가용성과 관련이 있지만, 같은 기능이 아니다.</p>

<table class="redis-compare-table">
  <thead>
    <tr>
      <th>구분</th>
      <th>Sentinel</th>
      <th>Cluster</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>주요 목적</td>
      <td>Master 장애 감지와 failover</td>
      <td>Sharding과 failover</td>
    </tr>
    <tr>
      <td>데이터 분산</td>
      <td>기본적으로 하나의 master dataset</td>
      <td>16384 hash slot으로 keyspace 분산</td>
    </tr>
    <tr>
      <td>클라이언트 요구</td>
      <td>Sentinel을 통해 현재 master 발견</td>
      <td>Cluster-aware client가 slot redirect를 처리해야 함</td>
    </tr>
    <tr>
      <td>장애 판단</td>
      <td>SDOWN, ODOWN, quorum 개념</td>
      <td>노드 간 gossip, majority, replica promotion</td>
    </tr>
    <tr>
      <td>주의점</td>
      <td>scale-out이 아니라 failover 중심</td>
      <td>multi-key command는 같은 slot 조건을 고려해야 함</td>
    </tr>
  </tbody>
</table>

<p>Sentinel은 master를 감시하고, 충분한 Sentinel이 장애를 인정하면 replica 중 하나를 master로 승격한다. Redis 문서의 표현을 빌리면 SDOWN은 특정 Sentinel이 주관적으로 판단한 down 상태이고, ODOWN은 quorum 이상이 동의한 객관적 down 상태다.</p>

<p>Cluster는 keyspace를 16384개 hash slot으로 나눈다.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>HASH_SLOT = CRC16(key) mod 16384
</code></pre></div></div>

<p>각 master node는 slot의 일부를 담당한다. client가 잘못된 node에 요청하면 Redis는 올바른 node를 알려주는 redirect를 반환하고, cluster-aware client는 이를 따라가야 한다.</p>

<p>Cluster에서는 key를 묶어 같은 slot으로 보내야 할 때 hash tag를 쓴다.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>cart:{user-1}
cart-item:{user-1}:a
cart-item:{user-1}:b
</code></pre></div></div>

<p>중괄호 안의 <code class="language-plaintext highlighter-rouge">user-1</code>만 hash slot 계산에 쓰이므로, 관련 key를 같은 slot에 둘 수 있다. multi-key command나 transaction-like 흐름을 Redis Cluster에서 쓸 때 자주 필요한 감각이다.</p>

<h2 id="redis를-queue로-쓸-때는-보장-수준을-골라야-한다">Redis를 queue로 쓸 때는 보장 수준을 골라야 한다</h2>

<p>Redis로 event queue를 만들 수 있다. 하지만 어떤 자료구조를 쓰는지에 따라 보장 수준이 완전히 달라진다.</p>

<div class="gc-visual redis-visual" role="img" aria-label="Redis에서 메시징을 구현하는 방식은 Pub/Sub, List, Stream이 있고 각각 저장 여부와 delivery 보장이 다르다">
  <div class="gc-visual__header">
    <strong>Redis messaging 선택지</strong>
    <span>가벼운 알림인지, 작업 큐인지, 재처리 가능한 이벤트 로그인지에 따라 선택이 달라진다.</span>
  </div>
  <div class="redis-lanes">
    <div class="redis-lane">
      <b>Pub/Sub</b>
      <span>구독 중인 client에게 push한다. 메시지는 저장되지 않고 at-most-once에 가깝다.</span>
      <div class="redis-chip-row">
        <span class="redis-chip">PUBLISH</span>
        <span class="redis-chip">SUBSCRIBE</span>
        <span class="redis-chip" data-kind="danger">no replay</span>
        <span class="redis-chip">notification</span>
      </div>
    </div>
    <div class="redis-lane">
      <b>List</b>
      <span><code>LPUSH</code>/<code>BRPOP</code>으로 간단한 queue를 만든다. ack와 재처리는 직접 설계해야 한다.</span>
      <div class="redis-chip-row">
        <span class="redis-chip">LPUSH</span>
        <span class="redis-chip">BRPOP</span>
        <span class="redis-chip">BLPOP</span>
        <span class="redis-chip">simple queue</span>
      </div>
    </div>
    <div class="redis-lane">
      <b>Stream</b>
      <span>append-only log와 consumer group을 제공한다. <code>XACK</code> 기반 처리 확인이 가능하다.</span>
      <div class="redis-chip-row">
        <span class="redis-chip">XADD</span>
        <span class="redis-chip">XGROUP</span>
        <span class="redis-chip">XREADGROUP</span>
        <span class="redis-chip" data-kind="hot">XACK</span>
      </div>
    </div>
  </div>
</div>

<p>Pub/Sub은 가장 가볍다. 하지만 Redis 공식 문서 기준으로 Pub/Sub은 at-most-once delivery semantics를 갖는다. subscriber가 연결되어 있지 않거나 처리 중 실패하면 메시지를 다시 받을 방법이 없다. 모니터링 알림, live notification처럼 유실이 치명적이지 않은 곳에 어울린다.</p>

<p>List는 간단한 작업 큐를 만들기 쉽다.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>producer:
  LPUSH jobs payload

consumer:
  BRPOP jobs 5
</code></pre></div></div>

<p>여기서 <code class="language-plaintext highlighter-rouge">BLPOP</code>/<code class="language-plaintext highlighter-rouge">BRPOP</code>의 timeout <code class="language-plaintext highlighter-rouge">0</code>은 즉시 반환이 아니라 무기한 대기다. 일정 시간마다 loop를 돌며 shutdown signal이나 health 상태를 확인하고 싶다면 0보다 작은 적절한 timeout을 두는 편이 운영하기 쉽다.</p>

<p>List는 “한 메시지를 한 consumer가 가져간다”는 queue에는 충분할 수 있다. 하지만 소비 후 ack, 실패 시 재처리, pending 상태 추적이 필요하면 직접 구현할 것이 많아진다.</p>

<p>Stream은 이 빈틈을 많이 채운다.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>XADD order-events * orderId 123 status paid
XGROUP CREATE order-events order-workers $
XREADGROUP GROUP order-workers worker-1 COUNT 10 BLOCK 1000 STREAMS order-events &gt;
XACK order-events order-workers 1680000000000-0
</code></pre></div></div>

<p>Stream은 append-only log에 가깝고, consumer group을 통해 여러 consumer가 같은 stream을 나누어 읽을 수 있다. <code class="language-plaintext highlighter-rouge">XACK</code>로 처리 완료를 표시하고, pending entries를 추적할 수 있다. 대신 stream이 무한히 커지지 않도록 <code class="language-plaintext highlighter-rouge">XTRIM</code>이나 maxlen 정책을 함께 설계해야 한다.</p>

<h2 id="spring-boot에서-redis를-붙이는-기본-흐름">Spring Boot에서 Redis를 붙이는 기본 흐름</h2>

<p>Spring Boot에서는 보통 <code class="language-plaintext highlighter-rouge">spring-boot-starter-data-redis</code>를 추가하면 Spring Data Redis가 Redis 접근 추상화를 제공한다. 기본 client는 Lettuce가 흔히 쓰인다.</p>

<div class="language-kotlin highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nf">dependencies</span> <span class="p">{</span>
    <span class="nf">implementation</span><span class="p">(</span><span class="s">"org.springframework.boot:spring-boot-starter-data-redis"</span><span class="p">)</span>
    <span class="nf">implementation</span><span class="p">(</span><span class="s">"org.apache.commons:commons-pool2"</span><span class="p">)</span>
<span class="p">}</span>
</code></pre></div></div>

<p>가장 단순한 설정은 property로 시작한다.</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">spring</span><span class="pi">:</span>
  <span class="na">data</span><span class="pi">:</span>
    <span class="na">redis</span><span class="pi">:</span>
      <span class="na">host</span><span class="pi">:</span> <span class="s">localhost</span>
      <span class="na">port</span><span class="pi">:</span> <span class="m">6379</span>
      <span class="na">connect-timeout</span><span class="pi">:</span> <span class="s">500ms</span>
      <span class="na">timeout</span><span class="pi">:</span> <span class="s">1s</span>
      <span class="na">lettuce</span><span class="pi">:</span>
        <span class="na">pool</span><span class="pi">:</span>
          <span class="na">max-active</span><span class="pi">:</span> <span class="m">16</span>
          <span class="na">max-idle</span><span class="pi">:</span> <span class="m">8</span>
          <span class="na">min-idle</span><span class="pi">:</span> <span class="m">0</span>
          <span class="na">max-wait</span><span class="pi">:</span> <span class="s">500ms</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">StringRedisTemplate</code>은 문자열 중심으로 쓸 때 편하다.</p>

<div class="language-java highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nd">@Service</span>
<span class="kd">public</span> <span class="kd">class</span> <span class="nc">LoginAttemptStore</span> <span class="o">{</span>

    <span class="kd">private</span> <span class="kd">final</span> <span class="nc">StringRedisTemplate</span> <span class="n">redis</span><span class="o">;</span>

    <span class="kd">public</span> <span class="nf">LoginAttemptStore</span><span class="o">(</span><span class="nc">StringRedisTemplate</span> <span class="n">redis</span><span class="o">)</span> <span class="o">{</span>
        <span class="k">this</span><span class="o">.</span><span class="na">redis</span> <span class="o">=</span> <span class="n">redis</span><span class="o">;</span>
    <span class="o">}</span>

    <span class="kd">public</span> <span class="kt">long</span> <span class="nf">increase</span><span class="o">(</span><span class="nc">String</span> <span class="n">userId</span><span class="o">)</span> <span class="o">{</span>
        <span class="nc">String</span> <span class="n">key</span> <span class="o">=</span> <span class="s">"login:fail:"</span> <span class="o">+</span> <span class="n">userId</span><span class="o">;</span>
        <span class="nc">Long</span> <span class="n">count</span> <span class="o">=</span> <span class="n">redis</span><span class="o">.</span><span class="na">opsForValue</span><span class="o">().</span><span class="na">increment</span><span class="o">(</span><span class="n">key</span><span class="o">);</span>
        <span class="n">redis</span><span class="o">.</span><span class="na">expire</span><span class="o">(</span><span class="n">key</span><span class="o">,</span> <span class="nc">Duration</span><span class="o">.</span><span class="na">ofMinutes</span><span class="o">(</span><span class="mi">10</span><span class="o">));</span>
        <span class="k">return</span> <span class="n">count</span> <span class="o">==</span> <span class="kc">null</span> <span class="o">?</span> <span class="mi">0</span> <span class="o">:</span> <span class="n">count</span><span class="o">;</span>
    <span class="o">}</span>
<span class="o">}</span>
</code></pre></div></div>

<p>객체를 저장한다면 serializer를 명시하는 편이 안전하다.</p>

<div class="language-java highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nd">@Bean</span>
<span class="nc">RedisTemplate</span><span class="o">&lt;</span><span class="nc">String</span><span class="o">,</span> <span class="nc">Object</span><span class="o">&gt;</span> <span class="nf">redisTemplate</span><span class="o">(</span><span class="nc">RedisConnectionFactory</span> <span class="n">connectionFactory</span><span class="o">)</span> <span class="o">{</span>
    <span class="nc">RedisTemplate</span><span class="o">&lt;</span><span class="nc">String</span><span class="o">,</span> <span class="nc">Object</span><span class="o">&gt;</span> <span class="n">template</span> <span class="o">=</span> <span class="k">new</span> <span class="nc">RedisTemplate</span><span class="o">&lt;&gt;();</span>
    <span class="n">template</span><span class="o">.</span><span class="na">setConnectionFactory</span><span class="o">(</span><span class="n">connectionFactory</span><span class="o">);</span>
    <span class="n">template</span><span class="o">.</span><span class="na">setKeySerializer</span><span class="o">(</span><span class="k">new</span> <span class="nc">StringRedisSerializer</span><span class="o">());</span>
    <span class="n">template</span><span class="o">.</span><span class="na">setHashKeySerializer</span><span class="o">(</span><span class="k">new</span> <span class="nc">StringRedisSerializer</span><span class="o">());</span>
    <span class="n">template</span><span class="o">.</span><span class="na">setValueSerializer</span><span class="o">(</span><span class="k">new</span> <span class="nc">GenericJackson2JsonRedisSerializer</span><span class="o">());</span>
    <span class="n">template</span><span class="o">.</span><span class="na">setHashValueSerializer</span><span class="o">(</span><span class="k">new</span> <span class="nc">GenericJackson2JsonRedisSerializer</span><span class="o">());</span>
    <span class="n">template</span><span class="o">.</span><span class="na">afterPropertiesSet</span><span class="o">();</span>
    <span class="k">return</span> <span class="n">template</span><span class="o">;</span>
<span class="o">}</span>
</code></pre></div></div>

<p>serializer를 암묵적으로 두면 나중에 타입 변경, class package 변경, 다국어 client 접근, 운영 redis-cli 확인에서 비용이 커진다. key는 읽을 수 있는 문자열로 두고, value는 JSON인지 binary인지 명확히 정해두는 편이 좋다.</p>

<h2 id="spring-cache로-쓸-때">Spring Cache로 쓸 때</h2>

<p>Spring Cache를 Redis 위에 올리면 <code class="language-plaintext highlighter-rouge">@Cacheable</code>로 cache-aside 패턴을 쉽게 만들 수 있다.</p>

<div class="language-java highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nd">@Service</span>
<span class="kd">public</span> <span class="kd">class</span> <span class="nc">ProductService</span> <span class="o">{</span>

    <span class="nd">@Cacheable</span><span class="o">(</span><span class="n">cacheNames</span> <span class="o">=</span> <span class="s">"product"</span><span class="o">,</span> <span class="n">key</span> <span class="o">=</span> <span class="s">"#id"</span><span class="o">)</span>
    <span class="kd">public</span> <span class="nc">Product</span> <span class="nf">getProduct</span><span class="o">(</span><span class="nc">Long</span> <span class="n">id</span><span class="o">)</span> <span class="o">{</span>
        <span class="k">return</span> <span class="n">productRepository</span><span class="o">.</span><span class="na">findById</span><span class="o">(</span><span class="n">id</span><span class="o">)</span>
            <span class="o">.</span><span class="na">orElseThrow</span><span class="o">();</span>
    <span class="o">}</span>
<span class="o">}</span>
</code></pre></div></div>

<p>CacheManager에는 TTL과 serializer를 같이 넣는다.</p>

<div class="language-java highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nd">@Bean</span>
<span class="nc">RedisCacheManager</span> <span class="nf">redisCacheManager</span><span class="o">(</span><span class="nc">RedisConnectionFactory</span> <span class="n">connectionFactory</span><span class="o">)</span> <span class="o">{</span>
    <span class="nc">RedisCacheConfiguration</span> <span class="n">config</span> <span class="o">=</span> <span class="nc">RedisCacheConfiguration</span><span class="o">.</span><span class="na">defaultCacheConfig</span><span class="o">()</span>
        <span class="o">.</span><span class="na">entryTtl</span><span class="o">(</span><span class="nc">Duration</span><span class="o">.</span><span class="na">ofMinutes</span><span class="o">(</span><span class="mi">10</span><span class="o">))</span>
        <span class="o">.</span><span class="na">disableCachingNullValues</span><span class="o">()</span>
        <span class="o">.</span><span class="na">serializeKeysWith</span><span class="o">(</span>
            <span class="nc">RedisSerializationContext</span><span class="o">.</span><span class="na">SerializationPair</span><span class="o">.</span><span class="na">fromSerializer</span><span class="o">(</span><span class="k">new</span> <span class="nc">StringRedisSerializer</span><span class="o">())</span>
        <span class="o">)</span>
        <span class="o">.</span><span class="na">serializeValuesWith</span><span class="o">(</span>
            <span class="nc">RedisSerializationContext</span><span class="o">.</span><span class="na">SerializationPair</span><span class="o">.</span><span class="na">fromSerializer</span><span class="o">(</span><span class="k">new</span> <span class="nc">GenericJackson2JsonRedisSerializer</span><span class="o">())</span>
        <span class="o">);</span>

    <span class="k">return</span> <span class="nc">RedisCacheManager</span><span class="o">.</span><span class="na">builder</span><span class="o">(</span><span class="n">connectionFactory</span><span class="o">)</span>
        <span class="o">.</span><span class="na">cacheDefaults</span><span class="o">(</span><span class="n">config</span><span class="o">)</span>
        <span class="o">.</span><span class="na">withCacheConfiguration</span><span class="o">(</span>
            <span class="s">"product"</span><span class="o">,</span>
            <span class="n">config</span><span class="o">.</span><span class="na">entryTtl</span><span class="o">(</span><span class="nc">Duration</span><span class="o">.</span><span class="na">ofMinutes</span><span class="o">(</span><span class="mi">5</span><span class="o">))</span>
        <span class="o">)</span>
        <span class="o">.</span><span class="na">build</span><span class="o">();</span>
<span class="o">}</span>
</code></pre></div></div>

<p>운영 cache에서 TTL은 거의 필수다. 영구 cache는 언젠가 source of truth와 어긋난다. TTL을 너무 길게 잡으면 stale data가 오래 남고, 너무 짧게 잡으면 DB를 보호하지 못한다.</p>

<p>여기에 cache stampede도 고려해야 한다. 같은 key가 동시에 만료되면 많은 요청이 한꺼번에 DB로 몰릴 수 있다. 해결책은 상황에 따라 다르다.</p>

<ul>
  <li>TTL에 작은 jitter를 섞는다.</li>
  <li>hot key는 refresh-ahead로 미리 갱신한다.</li>
  <li>miss 시 분산 lock이나 single-flight로 DB 조회를 합친다.</li>
  <li>null caching을 쓸지 명확히 정한다.</li>
</ul>

<h2 id="lettuce를-이해해야-하는-이유">Lettuce를 이해해야 하는 이유</h2>

<p>Lettuce는 Netty 기반 Redis client다. Spring Data Redis는 Lettuce를 통해 non-blocking I/O 기반 client를 제공하지만, Spring MVC에서 <code class="language-plaintext highlighter-rouge">RedisTemplate</code>을 동기 방식으로 쓰면 애플리케이션 코드 관점에서는 동기 호출처럼 느껴진다.</p>

<p>중요한 지점은 connection이다.</p>

<p>Spring Data Redis의 <code class="language-plaintext highlighter-rouge">LettuceConnectionFactory</code>는 기본적으로 여러 <code class="language-plaintext highlighter-rouge">LettuceConnection</code>이 하나의 thread-safe native connection을 공유할 수 있다. 하지만 <code class="language-plaintext highlighter-rouge">LettuceConnection</code> 자체와 clustered variant는 thread-safe가 아니므로, 인스턴스를 여러 스레드에서 직접 공유하면 안 된다. 보통은 <code class="language-plaintext highlighter-rouge">RedisTemplate</code>이 connection 획득과 반환을 관리하므로 직접 만질 일이 적다.</p>

<p>공식 API 문서 기준으로 <code class="language-plaintext highlighter-rouge">shareNativeConnection</code>이 <code class="language-plaintext highlighter-rouge">true</code>이면 일반 작업은 shared native connection을 사용하고, blocking이나 transaction 작업은 별도 connection provider를 통해 connection을 고른다. <code class="language-plaintext highlighter-rouge">shareNativeConnection</code>을 <code class="language-plaintext highlighter-rouge">false</code>로 끄면 모든 작업이 새 connection 또는 pool connection을 사용한다.</p>

<div class="gc-visual redis-visual" role="img" aria-label="Spring Boot에서 RedisTemplate은 LettuceConnectionFactory를 통해 Lettuce native connection을 사용하고 Redis로 명령을 보낸다">
  <div class="gc-visual__header">
    <strong>Spring Boot와 Lettuce 연결 흐름</strong>
    <span>Template은 편하지만, 실제 병목은 timeout, connection, Redis command, key 설계에서 자주 생긴다.</span>
  </div>
  <div class="redis-flow">
    <div class="redis-step"><b>1</b><span>Service</span></div>
    <div class="redis-step"><b>2</b><span>RedisTemplate</span></div>
    <div class="redis-step"><b>3</b><span>LettuceConnectionFactory</span></div>
    <div class="redis-step"><b>4</b><span>Lettuce / Netty</span></div>
    <div class="redis-step"><b>5</b><span>Redis Server</span></div>
  </div>
</div>

<h2 id="sharenativeconnection과-connection-pool의-관계"><code class="language-plaintext highlighter-rouge">shareNativeConnection</code>과 connection pool의 관계</h2>

<p>Spring Boot에서 Lettuce pool 설정을 넣으면 모든 Redis 명령이 pool에서 connection을 빌려 쓸 것처럼 느껴지지만, 기본 동작은 조금 다르다. 핵심은 <code class="language-plaintext highlighter-rouge">LettuceConnectionFactory</code>의 <code class="language-plaintext highlighter-rouge">shareNativeConnection</code> 값이다.</p>

<p>기본값은 <code class="language-plaintext highlighter-rouge">true</code>다. 이때 <code class="language-plaintext highlighter-rouge">RedisTemplate</code>이 <code class="language-plaintext highlighter-rouge">GET</code>, <code class="language-plaintext highlighter-rouge">SET</code>, <code class="language-plaintext highlighter-rouge">HGET</code>, <code class="language-plaintext highlighter-rouge">INCR</code> 같은 일반 명령을 실행하면 매번 새로운 TCP connection을 빌리는 것이 아니라, factory 내부의 shared native connection을 함께 사용한다. Lettuce의 native connection인 <code class="language-plaintext highlighter-rouge">StatefulRedisConnection</code>은 thread-safe하고 command multiplexing을 지원하므로, 여러 application thread가 같은 native connection을 통해 명령을 보낼 수 있다.</p>

<p>다만 Spring Data Redis의 <code class="language-plaintext highlighter-rouge">LettuceConnection</code> 객체 자체는 thread-safe하지 않다. 그래서 <code class="language-plaintext highlighter-rouge">LettuceConnection</code> 인스턴스를 직접 필드에 저장해 여러 스레드에서 공유하는 방식은 피해야 한다. 일반적인 <code class="language-plaintext highlighter-rouge">RedisTemplate</code> 사용에서는 template이 connection 획득과 반환을 감싸기 때문에 이 차이를 직접 의식할 일이 적다.</p>

<div class="gc-visual redis-visual" role="img" aria-label="shareNativeConnection 값에 따른 Lettuce connection 사용 경로 비교">
  <div class="gc-visual__header">
    <strong>shareNativeConnection 동작 흐름</strong>
    <span>pool을 켰다는 사실보다, 일반 명령이 shared connection을 쓰는지 pool을 쓰는지가 더 중요하다.</span>
  </div>
  <div class="redis-grid">
    <div class="redis-card">
      <strong class="redis-card-title">기본값: true</strong>
      <p>GET/SET 같은 일반 명령은 shared native connection으로 간다.</p>
    </div>
    <div class="redis-card">
      <strong class="redis-card-title">blocking / tx</strong>
      <p>BLPOP, transaction 등은 별도 connection provider 경로를 탄다.</p>
    </div>
    <div class="redis-card">
      <strong class="redis-card-title">pool + true</strong>
      <p>pool은 주로 blocking/transaction connection에 사용된다.</p>
    </div>
    <div class="redis-card">
      <strong class="redis-card-title">pool + false</strong>
      <p>일반 명령까지 pool에서 connection을 빌려 실행한다.</p>
    </div>
  </div>
</div>

<p>정리하면 다음처럼 보면 된다.</p>

<table class="redis-compare-table">
  <thead>
    <tr>
      <th>설정</th>
      <th>일반 명령</th>
      <th>Blocking / transaction</th>
      <th>의미</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code>shareNativeConnection=true</code>, pool 없음</td>
      <td>shared native connection</td>
      <td>별도 provider connection</td>
      <td>대부분의 일반 cache 작업에 충분한 기본 모델</td>
    </tr>
    <tr>
      <td><code>shareNativeConnection=true</code>, pool 있음</td>
      <td>shared native connection</td>
      <td>pool connection</td>
      <td>pool 설정을 해도 일반 GET/SET이 곧바로 pool을 타지는 않음</td>
    </tr>
    <tr>
      <td><code>shareNativeConnection=false</code>, pool 있음</td>
      <td>pool connection</td>
      <td>pool connection</td>
      <td>모든 작업이 pool에서 connection을 빌리는 방식</td>
    </tr>
    <tr>
      <td><code>shareNativeConnection=false</code>, pool 없음</td>
      <td>새 connection 또는 전용 connection</td>
      <td>새 connection 또는 전용 connection</td>
      <td>connection 생성 비용이 커질 수 있어 보통 권장하기 어렵다</td>
    </tr>
  </tbody>
</table>

<p>따라서 <code class="language-plaintext highlighter-rouge">spring.data.redis.lettuce.pool.*</code> 값을 설정했다고 해서 애플리케이션의 모든 Redis 요청이 <code class="language-plaintext highlighter-rouge">max-active</code> 개수만큼 분산되는 것은 아니다. <code class="language-plaintext highlighter-rouge">shareNativeConnection=true</code>라면 <code class="language-plaintext highlighter-rouge">CLIENT LIST</code>에서 일반 명령용 shared connection이 중심으로 보이고, blocking이나 transaction을 사용할 때 pool connection이 추가로 보이는 식으로 이해하는 편이 맞다.</p>

<p><code class="language-plaintext highlighter-rouge">setShareNativeConnection(false)</code>는 이런 기본 공유 모델을 끄는 설정이다. 예를 들어 모든 동기 Redis 작업을 pool에서 빌린 connection으로 격리하고 싶다면 factory를 직접 만들고 값을 꺼야 한다.</p>

<div class="language-java highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nd">@Bean</span>
<span class="nc">LettuceConnectionFactory</span> <span class="nf">pooledDedicatedRedisConnectionFactory</span><span class="o">()</span> <span class="o">{</span>
    <span class="nc">RedisStandaloneConfiguration</span> <span class="n">server</span> <span class="o">=</span> <span class="k">new</span> <span class="nc">RedisStandaloneConfiguration</span><span class="o">(</span><span class="s">"localhost"</span><span class="o">,</span> <span class="mi">6379</span><span class="o">);</span>

    <span class="nc">GenericObjectPoolConfig</span><span class="o">&lt;?&gt;</span> <span class="n">pool</span> <span class="o">=</span> <span class="k">new</span> <span class="nc">GenericObjectPoolConfig</span><span class="o">&lt;&gt;();</span>
    <span class="n">pool</span><span class="o">.</span><span class="na">setMaxTotal</span><span class="o">(</span><span class="mi">16</span><span class="o">);</span>
    <span class="n">pool</span><span class="o">.</span><span class="na">setMaxIdle</span><span class="o">(</span><span class="mi">8</span><span class="o">);</span>
    <span class="n">pool</span><span class="o">.</span><span class="na">setMinIdle</span><span class="o">(</span><span class="mi">0</span><span class="o">);</span>
    <span class="n">pool</span><span class="o">.</span><span class="na">setMaxWait</span><span class="o">(</span><span class="nc">Duration</span><span class="o">.</span><span class="na">ofMillis</span><span class="o">(</span><span class="mi">500</span><span class="o">));</span>

    <span class="nc">LettuceClientConfiguration</span> <span class="n">client</span> <span class="o">=</span> <span class="nc">LettucePoolingClientConfiguration</span><span class="o">.</span><span class="na">builder</span><span class="o">()</span>
        <span class="o">.</span><span class="na">poolConfig</span><span class="o">(</span><span class="n">pool</span><span class="o">)</span>
        <span class="o">.</span><span class="na">commandTimeout</span><span class="o">(</span><span class="nc">Duration</span><span class="o">.</span><span class="na">ofSeconds</span><span class="o">(</span><span class="mi">1</span><span class="o">))</span>
        <span class="o">.</span><span class="na">build</span><span class="o">();</span>

    <span class="nc">LettuceConnectionFactory</span> <span class="n">factory</span> <span class="o">=</span> <span class="k">new</span> <span class="nc">LettuceConnectionFactory</span><span class="o">(</span><span class="n">server</span><span class="o">,</span> <span class="n">client</span><span class="o">);</span>
    <span class="n">factory</span><span class="o">.</span><span class="na">setShareNativeConnection</span><span class="o">(</span><span class="kc">false</span><span class="o">);</span>
    <span class="k">return</span> <span class="n">factory</span><span class="o">;</span>
<span class="o">}</span>
</code></pre></div></div>

<p>하지만 이 설정을 성능 향상 버튼처럼 생각하면 안 된다. Redis server는 기본적으로 명령을 빠르게 순차 처리하고, Lettuce connection은 thread-safe하게 multiplexing할 수 있다. 일반적인 cache read/write에서는 connection을 늘리는 것보다 key 설계, command latency, payload 크기, slow command 제거, timeout 설정이 더 큰 영향을 준다.</p>

<p><code class="language-plaintext highlighter-rouge">shareNativeConnection=false</code>가 의미 있는 경우는 보통 다음 쪽에 가깝다.</p>

<ul>
  <li>connection 단위 상태가 섞이면 안 되는 작업을 명확히 격리하고 싶다.</li>
  <li>transaction, blocking command, batch성 작업이 섞여 일반 cache 요청과 분리하고 싶다.</li>
  <li>shared connection에서 특정 장애나 지연이 전체 요청에 영향을 주는지 검증하기 위해 실험적으로 비교하고 싶다.</li>
  <li>운영 정책상 connection 수, pool wait, pool exhaustion을 명시적으로 관찰하고 제어하고 싶다.</li>
</ul>

<p>반대로 단순히 “Redis가 느리니 pool을 늘리자”는 접근은 위험하다. Redis slowlog에 비싼 명령이 찍히거나, network latency가 튀거나, 큰 value 직렬화가 병목인 상황에서는 pool을 늘려도 병목 위치가 바뀌지 않는다. pool을 키우면 Redis server 입장에서는 더 많은 client connection과 더 많은 동시 대기열을 감당해야 하므로 장애가 더 늦게 드러날 수도 있다.</p>

<p>shared native connection은 <code class="language-plaintext highlighter-rouge">LettuceConnection</code>이 닫는 대상이 아니기 때문에, <code class="language-plaintext highlighter-rouge">getConnection()</code> 때마다 기본으로 검증되지 않는다. 연결 검증이 꼭 필요하면 <code class="language-plaintext highlighter-rouge">setValidateConnection(true)</code>를 켤 수 있지만, 검증 비용이 추가될 수 있으므로 장애 증상과 운영 환경을 보고 선택해야 한다.</p>

<h2 id="lettuce-설정-예시">Lettuce 설정 예시</h2>

<p>기본 property로 충분하지 않다면 <code class="language-plaintext highlighter-rouge">LettuceConnectionFactory</code>를 명시적으로 구성한다.</p>

<div class="language-java highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nd">@Configuration</span>
<span class="kd">public</span> <span class="kd">class</span> <span class="nc">RedisConfig</span> <span class="o">{</span>

    <span class="nd">@Bean</span>
    <span class="nc">LettuceConnectionFactory</span> <span class="nf">redisConnectionFactory</span><span class="o">()</span> <span class="o">{</span>
        <span class="nc">RedisStandaloneConfiguration</span> <span class="n">server</span> <span class="o">=</span> <span class="k">new</span> <span class="nc">RedisStandaloneConfiguration</span><span class="o">(</span><span class="s">"localhost"</span><span class="o">,</span> <span class="mi">6379</span><span class="o">);</span>

        <span class="nc">LettuceClientConfiguration</span> <span class="n">client</span> <span class="o">=</span> <span class="nc">LettuceClientConfiguration</span><span class="o">.</span><span class="na">builder</span><span class="o">()</span>
            <span class="o">.</span><span class="na">commandTimeout</span><span class="o">(</span><span class="nc">Duration</span><span class="o">.</span><span class="na">ofSeconds</span><span class="o">(</span><span class="mi">1</span><span class="o">))</span>
            <span class="o">.</span><span class="na">shutdownTimeout</span><span class="o">(</span><span class="nc">Duration</span><span class="o">.</span><span class="na">ofMillis</span><span class="o">(</span><span class="mi">100</span><span class="o">))</span>
            <span class="o">.</span><span class="na">clientOptions</span><span class="o">(</span><span class="nc">ClientOptions</span><span class="o">.</span><span class="na">builder</span><span class="o">()</span>
                <span class="o">.</span><span class="na">autoReconnect</span><span class="o">(</span><span class="kc">true</span><span class="o">)</span>
                <span class="o">.</span><span class="na">build</span><span class="o">())</span>
            <span class="o">.</span><span class="na">build</span><span class="o">();</span>

        <span class="k">return</span> <span class="k">new</span> <span class="nf">LettuceConnectionFactory</span><span class="o">(</span><span class="n">server</span><span class="o">,</span> <span class="n">client</span><span class="o">);</span>
    <span class="o">}</span>
<span class="o">}</span>
</code></pre></div></div>

<p>pool이 필요하면 <code class="language-plaintext highlighter-rouge">commons-pool2</code>와 함께 pooling configuration을 쓴다.</p>

<div class="language-java highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nd">@Bean</span>
<span class="nc">LettuceConnectionFactory</span> <span class="nf">pooledRedisConnectionFactory</span><span class="o">()</span> <span class="o">{</span>
    <span class="nc">RedisStandaloneConfiguration</span> <span class="n">server</span> <span class="o">=</span> <span class="k">new</span> <span class="nc">RedisStandaloneConfiguration</span><span class="o">(</span><span class="s">"localhost"</span><span class="o">,</span> <span class="mi">6379</span><span class="o">);</span>

    <span class="nc">GenericObjectPoolConfig</span><span class="o">&lt;?&gt;</span> <span class="n">pool</span> <span class="o">=</span> <span class="k">new</span> <span class="nc">GenericObjectPoolConfig</span><span class="o">&lt;&gt;();</span>
    <span class="n">pool</span><span class="o">.</span><span class="na">setMaxTotal</span><span class="o">(</span><span class="mi">16</span><span class="o">);</span>
    <span class="n">pool</span><span class="o">.</span><span class="na">setMaxIdle</span><span class="o">(</span><span class="mi">8</span><span class="o">);</span>
    <span class="n">pool</span><span class="o">.</span><span class="na">setMinIdle</span><span class="o">(</span><span class="mi">0</span><span class="o">);</span>
    <span class="n">pool</span><span class="o">.</span><span class="na">setMaxWait</span><span class="o">(</span><span class="nc">Duration</span><span class="o">.</span><span class="na">ofMillis</span><span class="o">(</span><span class="mi">500</span><span class="o">));</span>

    <span class="nc">LettuceClientConfiguration</span> <span class="n">client</span> <span class="o">=</span> <span class="nc">LettucePoolingClientConfiguration</span><span class="o">.</span><span class="na">builder</span><span class="o">()</span>
        <span class="o">.</span><span class="na">poolConfig</span><span class="o">(</span><span class="n">pool</span><span class="o">)</span>
        <span class="o">.</span><span class="na">commandTimeout</span><span class="o">(</span><span class="nc">Duration</span><span class="o">.</span><span class="na">ofSeconds</span><span class="o">(</span><span class="mi">1</span><span class="o">))</span>
        <span class="o">.</span><span class="na">build</span><span class="o">();</span>

    <span class="k">return</span> <span class="k">new</span> <span class="nf">LettuceConnectionFactory</span><span class="o">(</span><span class="n">server</span><span class="o">,</span> <span class="n">client</span><span class="o">);</span>
<span class="o">}</span>
</code></pre></div></div>

<p>pool을 늘린다고 Redis 처리량이 무조건 늘지는 않는다. Redis server는 명령을 매우 빠르게 처리하지만, single-thread event loop 특성상 비싼 명령이 섞이면 queueing이 생긴다. connection pool은 client 쪽 대기와 격리를 도와줄 수 있지만, Redis server의 CPU, command latency, network, slowlog가 병목이면 pool만 늘려서는 해결되지 않는다.</p>

<h2 id="lettuce-사용-시-유의사항">Lettuce 사용 시 유의사항</h2>

<h3 id="1-timeout은-짧고-명확하게-둔다">1. timeout은 짧고 명확하게 둔다</h3>

<p>Redis는 빠른 저장소로 쓰는 경우가 많다. 그러면 timeout도 그 기대에 맞아야 한다. 요청 전체 SLA가 200ms인데 Redis command timeout이 60초라면, 장애 시 애플리케이션 thread가 너무 오래 붙잡힌다.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>connect timeout:
  Redis에 새 연결을 맺는 시간

command timeout:
  GET, SET 같은 명령이 끝나길 기다리는 시간

pool max-wait:
  pool에서 connection을 빌릴 때 기다리는 시간
</code></pre></div></div>

<p>이 셋은 서로 다르다. timeout 로그를 볼 때도 “connect가 느린지, pool이 고갈됐는지, Redis command가 느린지”를 분리해서 봐야 한다.</p>

<h3 id="2-blocking-command는-connection을-분리한다">2. blocking command는 connection을 분리한다</h3>

<p><code class="language-plaintext highlighter-rouge">BLPOP</code>, <code class="language-plaintext highlighter-rouge">BRPOP</code>, Pub/Sub subscribe 같은 작업은 connection을 오래 점유한다. 일반 cache GET/SET과 같은 connection을 공유하면 다른 명령이 밀릴 수 있다.</p>

<p>Spring Data Redis의 <code class="language-plaintext highlighter-rouge">shareNativeConnection</code> 기본 동작은 일반 작업과 blocking/transaction 작업을 구분하려고 한다. 하지만 직접 connection을 다루거나, custom factory/pool을 구성한다면 blocking 작업 전용 connection 또는 별도 <code class="language-plaintext highlighter-rouge">RedisTemplate</code>을 두는 편이 명확하다.</p>

<h3 id="3-큰-key-삭제는-unlink를-고려한다">3. 큰 key 삭제는 <code class="language-plaintext highlighter-rouge">UNLINK</code>를 고려한다</h3>

<p>큰 List, Set, Hash를 <code class="language-plaintext highlighter-rouge">DEL</code>로 지우면 메모리 해제가 main thread에서 부담이 될 수 있다. Redis에는 key를 keyspace에서 분리하고 실제 메모리 회수를 background에서 처리하는 <code class="language-plaintext highlighter-rouge">UNLINK</code>가 있다. 큰 key 정리 작업이라면 <code class="language-plaintext highlighter-rouge">DEL</code>과 <code class="language-plaintext highlighter-rouge">UNLINK</code>의 차이를 이해하고 선택해야 한다.</p>

<h3 id="4-cluster에서는-key-naming이-routing이다">4. Cluster에서는 key naming이 routing이다</h3>

<p>Redis Cluster에서는 key가 hash slot을 결정한다. 관련 key를 같은 slot에 두어야 한다면 hash tag를 써야 한다.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>order:{123}:summary
order:{123}:items
order:{123}:payment
</code></pre></div></div>

<p>이렇게 하면 <code class="language-plaintext highlighter-rouge">{123}</code> 부분만 hash slot 계산에 쓰여 같은 slot으로 간다. 반대로 아무 생각 없이 key를 만들면 multi-key command가 cluster에서 실패하거나 redirect가 늘어날 수 있다.</p>

<h3 id="5-cache-miss도-장애-모드다">5. cache miss도 장애 모드다</h3>

<p>Redis cache 장애는 Redis가 죽었을 때만 생기지 않는다. eviction, TTL 만료, deploy 후 cold cache, hot key 만료가 모두 DB로 전이될 수 있다.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Redis hit:
  빠른 응답

Redis miss:
  DB query
  serialization
  Redis write-back
  동시 요청이면 stampede 가능
</code></pre></div></div>

<p>Redis를 붙였으면 hit ratio, command latency, used memory, evicted keys, expired keys, connected clients, slowlog, replication lag를 같이 봐야 한다.</p>

<h2 id="운영-체크리스트">운영 체크리스트</h2>

<p>Redis를 붙이기 전에 다음 질문을 먼저 정리해두면 장애 때 덜 흔들린다.</p>

<ul>
  <li>이 Redis는 cache인가, session store인가, queue인가, source of truth에 가까운가?</li>
  <li>key마다 TTL이 있는가?</li>
  <li><code class="language-plaintext highlighter-rouge">maxmemory</code>와 <code class="language-plaintext highlighter-rouge">maxmemory-policy</code>는 무엇인가?</li>
  <li>eviction이 발생해도 서비스 의미가 깨지지 않는가?</li>
  <li>RDB/AOF persistence를 켤 것인가?</li>
  <li>Sentinel/Cluster 중 어떤 topology가 필요한가?</li>
  <li>큰 collection key가 생기지 않는가?</li>
  <li><code class="language-plaintext highlighter-rouge">KEYS</code>, 큰 <code class="language-plaintext highlighter-rouge">DEL</code>, <code class="language-plaintext highlighter-rouge">FLUSH*</code>, <code class="language-plaintext highlighter-rouge">SAVE</code> 같은 명령이 운영 경로에 없는가?</li>
  <li>Spring Boot에서 command timeout, connect timeout, pool wait timeout이 분리되어 있는가?</li>
  <li>blocking Redis 작업이 일반 cache 작업과 connection을 공유하지 않는가?</li>
  <li>serializer와 key naming 규칙이 문서화되어 있는가?</li>
</ul>

<h2 id="한-문장으로-정리하면">한 문장으로 정리하면</h2>

<p>Redis는 빠른 cache이기도 하지만, 실제로는 TTL, eviction, persistence, topology, client connection까지 함께 설계해야 하는 in-memory data structure server다.</p>

<p>Spring Boot에서 Lettuce를 쓸 때도 핵심은 같다. <code class="language-plaintext highlighter-rouge">RedisTemplate</code>은 Redis를 쉽게 쓰게 해주지만, timeout과 connection 공유, blocking command, serializer, key naming을 흐릿하게 만들면 장애는 훨씬 늦게 드러난다.</p>

<p>Redis를 잘 쓰는 기준은 “빨랐다”가 아니라 “느려지고, 메모리가 차고, master가 바뀌고, key가 사라져도 어떤 일이 일어나는지 알고 있다”에 가깝다.</p>

<h2 id="참고한-자료">참고한 자료</h2>

<ul>
  <li><a href="https://redis.io/docs/latest/develop/reference/eviction/">Redis key eviction</a></li>
  <li><a href="https://redis.io/docs/latest/operate/oss_and_stack/management/persistence">Redis persistence</a></li>
  <li><a href="https://redis.io/docs/latest/operate/oss_and_stack/reference/cluster-spec/">Redis Cluster specification</a></li>
  <li><a href="https://redis.io/docs/latest/operate/oss_and_stack/management/sentinel/">Redis Sentinel</a></li>
  <li><a href="https://redis.io/docs/latest/develop/pubsub/">Redis Pub/Sub</a></li>
  <li><a href="https://redis.io/docs/latest/develop/data-types/streams/">Redis Streams</a></li>
  <li><a href="https://redis.io/docs/latest/commands/xreadgroup">Redis XREADGROUP</a></li>
  <li><a href="https://redis.io/commands/blpop/">Redis BLPOP</a></li>
  <li><a href="https://redis.io/docs/latest/commands/keys/">Redis KEYS</a></li>
  <li><a href="https://docs.spring.io/spring-data/redis/reference/redis/drivers.html">Spring Data Redis drivers</a></li>
  <li><a href="https://docs.spring.io/spring-boot/3.5/api/java/org/springframework/boot/autoconfigure/data/redis/RedisAutoConfiguration.html">Spring Boot RedisAutoConfiguration</a></li>
  <li><a href="https://docs.spring.io/spring-data/redis/reference/3.5/api/java/org/springframework/data/redis/connection/lettuce/LettuceConnectionFactory.html">Spring Data Redis LettuceConnectionFactory API</a></li>
  <li><a href="https://redis.io/docs/latest/develop/clients/lettuce/produsage/">Lettuce production usage</a></li>
  <li><a href="https://redis.github.io/lettuce/user-guide/connecting-redis/">Lettuce connecting Redis</a></li>
  <li><a href="https://redis.github.io/lettuce/advanced-usage/connection-pooling/">Lettuce connection pooling</a></li>
</ul>]]></content><author><name>WooHyuk</name></author><category term="develop" /><category term="redis" /><category term="cache" /><category term="spring-boot" /><category term="lettuce" /><category term="nosql" /><category term="message-queue" /><summary type="html"><![CDATA[Redis의 자료구조, TTL과 eviction, Sentinel과 Cluster, Pub/Sub/List/Stream 큐 모델, Spring Boot에서 Lettuce를 사용할 때의 설정과 주의사항을 정리합니다.]]></summary></entry><entry><title type="html">io_uring은 epoll과 무엇이 다를까</title><link href="https://dngur6344.github.io/develop/2026-05-26-io-uring/" rel="alternate" type="text/html" title="io_uring은 epoll과 무엇이 다를까" /><published>2026-05-26T00:00:00+09:00</published><updated>2026-05-26T00:00:00+09:00</updated><id>https://dngur6344.github.io/develop/io-uring</id><content type="html" xml:base="https://dngur6344.github.io/develop/2026-05-26-io-uring/"><![CDATA[<p>Linux 서버 I/O를 공부하다 보면 <code class="language-plaintext highlighter-rouge">epoll</code>은 비교적 빨리 감이 온다.</p>

<blockquote>
  <p>많은 fd 중 지금 읽거나 쓸 수 있는 fd를 알려준다.</p>
</blockquote>

<p>그런데 <code class="language-plaintext highlighter-rouge">io_uring</code>은 조금 다르다. 단순히 “더 빠른 epoll”이라고 이해하면 중요한 부분을 놓치기 쉽다.</p>

<p>가장 큰 차이는 알림의 성격이다.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>epoll    = 준비 상태 알림, readiness notification
io_uring = 완료 결과 알림, completion notification
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">epoll</code>은 커널이 “이 fd는 지금 read/write 가능해 보인다”고 알려주는 모델이다. 그 뒤 실제 <code class="language-plaintext highlighter-rouge">read()</code>와 <code class="language-plaintext highlighter-rouge">write()</code>는 애플리케이션이 다시 호출해야 한다.</p>

<p><code class="language-plaintext highlighter-rouge">io_uring</code>은 애플리케이션이 “이 작업을 해줘”라고 커널에 제출하고, 커널이 작업을 끝낸 뒤 “완료됐고 결과는 이렇다”고 알려주는 모델에 가깝다.</p>

<style>
.uring-visual {
  --uring-panel: rgba(255, 250, 242, .075);
  --uring-panel-strong: rgba(255, 250, 242, .12);
  --uring-line: rgba(255, 250, 242, .18);
  --uring-ink: #fffaf2;
  --uring-muted: rgba(255, 250, 242, .7);
  --uring-gold: #d8b16f;
  --uring-blue: #8fb4d9;
  --uring-green: #8fbf9b;
  --uring-red: #d98989;
}

.uring-visual .uring-grid,
.uring-visual .uring-lanes,
.uring-visual .uring-flow,
.uring-visual .uring-note-grid {
  display: grid;
  gap: .65rem;
}

.uring-visual .uring-grid {
  grid-template-columns: repeat(2, minmax(0, 1fr));
}

.uring-visual .uring-lanes {
  grid-template-columns: repeat(3, minmax(0, 1fr));
}

.uring-visual .uring-flow {
  grid-template-columns: repeat(5, minmax(0, 1fr));
}

.uring-visual .uring-note-grid {
  grid-template-columns: repeat(4, minmax(0, 1fr));
}

.uring-visual .uring-card,
.uring-visual .uring-step,
.uring-visual .uring-note {
  min-width: 0;
  border: 1px solid var(--uring-line);
  border-radius: 6px;
  background: var(--uring-panel);
}

.uring-visual .uring-card,
.uring-visual .uring-note {
  padding: .72rem;
}

.uring-visual .uring-card-title,
.uring-visual .uring-step b,
.uring-visual .uring-note b {
  display: block;
  color: var(--uring-ink);
}

.uring-visual .uring-card-title {
  margin-bottom: .48rem;
  font-size: .74rem;
}

.uring-visual .uring-card p,
.uring-visual .uring-step span,
.uring-visual .uring-note span {
  display: block;
  margin: 0;
  color: var(--uring-muted);
  font-size: .64rem;
  line-height: 1.45;
}

.uring-visual .uring-step {
  position: relative;
  padding: .58rem .62rem;
}

.uring-visual .uring-step:not(:last-child)::after {
  content: "->";
  position: absolute;
  right: -.49rem;
  top: 50%;
  color: rgba(255, 250, 242, .58);
  font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
  font-size: .66rem;
  transform: translate(50%, -50%);
}

.uring-visual .uring-step b {
  font-size: .66rem;
}

.uring-visual .uring-step span {
  margin-top: .14rem;
}

.uring-visual .uring-path {
  margin: 0;
  padding: 0;
  list-style: none;
}

.uring-visual .uring-path li {
  display: grid;
  grid-template-columns: auto minmax(0, 1fr);
  gap: .5rem;
  min-width: 0;
  padding: .52rem .56rem;
  border: 1px solid rgba(255, 250, 242, .14);
  border-radius: 6px;
  background: rgba(8, 10, 17, .24);
}

.uring-visual .uring-path li + li {
  margin-top: .42rem;
}

.uring-visual .uring-path b {
  width: 1.35rem;
  height: 1.35rem;
  border: 1px solid rgba(255, 250, 242, .16);
  border-radius: 50%;
  color: var(--uring-ink);
  background: rgba(255, 250, 242, .08);
  font-size: .68rem;
  line-height: 1.35rem;
  text-align: center;
}

.uring-visual .uring-path strong,
.uring-visual .uring-path span {
  display: block;
}

.uring-visual .uring-path strong {
  color: var(--uring-ink);
  font-size: .68rem;
}

.uring-visual .uring-path span {
  color: var(--uring-muted);
  font-size: .62rem;
  line-height: 1.45;
}

.uring-visual code {
  color: var(--uring-ink);
  background: rgba(8, 10, 17, .32);
}

.uring-visual .uring-chip-row {
  display: grid;
  grid-template-columns: repeat(3, minmax(0, 1fr));
  gap: .4rem;
  margin-top: .55rem;
}

.uring-visual .uring-chip {
  padding: .35rem .3rem;
  border: 1px solid rgba(143, 180, 217, .35);
  border-radius: 5px;
  color: var(--uring-ink);
  background: rgba(143, 180, 217, .1);
  font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
  font-size: .58rem;
  line-height: 1.22;
  text-align: center;
}

.uring-compare-table {
  width: 100%;
  border: 1px solid rgba(143, 94, 60, .32);
  border-collapse: separate;
  border-spacing: 0;
  border-radius: 6px;
  overflow: hidden;
  background: rgba(255, 250, 242, .88) !important;
  font-size: .88rem;
}

.uring-compare-table th,
.uring-compare-table td {
  border: 0;
  border-bottom: 1px solid rgba(143, 94, 60, .22);
  color: var(--coffee-ink) !important;
  vertical-align: top;
}

.uring-compare-table th {
  background: rgba(47, 33, 24, .92) !important;
  color: #fffaf2 !important;
  font-weight: 700;
}

.uring-compare-table tbody tr:nth-child(odd) td {
  background: rgba(255, 250, 242, .9) !important;
}

.uring-compare-table tbody tr:nth-child(even) td {
  background: rgba(247, 236, 222, .92) !important;
}

.uring-compare-table tbody tr:last-child td {
  border-bottom: 0;
}

.uring-compare-table td:first-child {
  color: #4d2d1e !important;
  font-weight: 700;
}

body.dark-mode .uring-compare-table {
  background: rgba(9, 13, 22, .86) !important;
  border-color: rgba(231, 212, 189, .24) !important;
  box-shadow: 0 1rem 2rem rgba(0, 0, 0, .22) !important;
}

body.dark-mode .uring-compare-table th {
  color: #fff4e5 !important;
  background: rgba(244, 234, 220, .12) !important;
}

body.dark-mode .uring-compare-table td {
  color: #ead8c3 !important;
  border-color: rgba(231, 212, 189, .18) !important;
}

body.dark-mode .uring-compare-table tbody tr:nth-child(odd) td {
  background: rgba(255, 250, 242, .045) !important;
}

body.dark-mode .uring-compare-table tbody tr:nth-child(even) td {
  background: rgba(255, 250, 242, .074) !important;
}

@media screen and (prefers-color-scheme: dark) {
  body:not(.light-mode) .uring-compare-table {
    background: rgba(9, 13, 22, .86) !important;
    border-color: rgba(231, 212, 189, .24) !important;
    box-shadow: 0 1rem 2rem rgba(0, 0, 0, .22) !important;
  }

  body:not(.light-mode) .uring-compare-table th {
    color: #fff4e5 !important;
    background: rgba(244, 234, 220, .12) !important;
  }

  body:not(.light-mode) .uring-compare-table td {
    color: #ead8c3 !important;
    border-color: rgba(231, 212, 189, .18) !important;
  }

  body:not(.light-mode) .uring-compare-table tbody tr:nth-child(odd) td {
    background: rgba(255, 250, 242, .045) !important;
  }

  body:not(.light-mode) .uring-compare-table tbody tr:nth-child(even) td {
    background: rgba(255, 250, 242, .074) !important;
  }
}

body.light-mode .uring-compare-table {
  background: rgba(255, 250, 242, .88) !important;
  border-color: rgba(143, 94, 60, .32) !important;
  box-shadow: none !important;
}

body.light-mode .uring-compare-table th {
  background: rgba(47, 33, 24, .92) !important;
  color: #fffaf2 !important;
}

body.light-mode .uring-compare-table td {
  color: var(--coffee-ink) !important;
  border-color: rgba(143, 94, 60, .22) !important;
}

body.light-mode .uring-compare-table tbody tr:nth-child(odd) td {
  background: rgba(255, 250, 242, .9) !important;
}

body.light-mode .uring-compare-table tbody tr:nth-child(even) td {
  background: rgba(247, 236, 222, .92) !important;
}

body.light-mode .uring-compare-table td:first-child {
  color: #4d2d1e !important;
}

@media screen and (max-width: 56rem) {
  .uring-visual .uring-grid,
  .uring-visual .uring-lanes,
  .uring-visual .uring-flow,
  .uring-visual .uring-note-grid {
    grid-template-columns: 1fr;
  }

  .uring-visual .uring-step:not(:last-child)::after {
    content: "";
    left: 50%;
    right: auto;
    top: auto;
    bottom: -.45rem;
    width: 1px;
    height: .45rem;
    background: rgba(255, 250, 242, .3);
    transform: none;
  }

  .uring-visual .uring-chip-row {
    grid-template-columns: repeat(2, minmax(0, 1fr));
  }
}
</style>

<div class="gc-visual uring-visual" role="img" aria-label="epoll은 fd 준비 상태를 알려주고, io_uring은 제출한 I/O 작업의 완료 결과를 알려준다">
  <div class="gc-visual__header">
    <strong>readiness와 completion의 차이</strong>
    <span>같은 비동기 서버라도, 어떤 질문을 커널에 던지는지가 다르다.</span>
  </div>
  <div class="uring-grid">
    <div class="uring-card">
      <b class="uring-card-title">epoll</b>
      <ul class="uring-path">
        <li><b>1</b><span><strong>fd 등록</strong><span><code>epoll_ctl()</code>로 관심 fd를 등록한다.</span></span></li>
        <li><b>2</b><span><strong>준비 대기</strong><span><code>epoll_wait()</code>가 readable/writable fd를 돌려준다.</span></span></li>
        <li><b>3</b><span><strong>앱이 I/O 호출</strong><span>애플리케이션이 <code>read()</code>/<code>write()</code>를 직접 호출한다.</span></span></li>
      </ul>
    </div>
    <div class="uring-card">
      <b class="uring-card-title">io_uring</b>
      <ul class="uring-path">
        <li><b>1</b><span><strong>작업 작성</strong><span>SQE에 <code>read</code>, <code>recv</code>, <code>send</code>, <code>accept</code> 같은 작업을 적는다.</span></span></li>
        <li><b>2</b><span><strong>커널에 제출</strong><span><code>io_uring_enter()</code>로 제출하거나 polling 모드가 가져가게 한다.</span></span></li>
        <li><b>3</b><span><strong>완료 수거</strong><span>CQE의 <code>res</code>로 성공 바이트 수나 에러를 확인한다.</span></span></li>
      </ul>
    </div>
  </div>
</div>

<h2 id="epoll은-io를-대신-해주지-않는다">epoll은 I/O를 대신 해주지 않는다</h2>

<p><code class="language-plaintext highlighter-rouge">epoll</code>은 fd를 감시하는 메커니즘이다. 커널 안의 epoll 인스턴스에는 관심 목록, interest list가 있고, 그중 이벤트가 발생한 fd는 ready list로 올라온다.</p>

<p>흐름은 대략 이렇다.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>socket fd 생성
-&gt; epoll_ctl()로 interest list에 등록
-&gt; epoll_wait()로 ready event 대기
-&gt; readable 이벤트 수신
-&gt; 애플리케이션이 read(fd, buf) 호출
-&gt; 커널 버퍼에서 유저 버퍼로 데이터 복사
</code></pre></div></div>

<p>중요한 점은 <code class="language-plaintext highlighter-rouge">epoll_wait()</code>가 데이터를 읽어주는 것이 아니라는 점이다. <code class="language-plaintext highlighter-rouge">epoll_wait()</code>는 “이 fd에 읽을 수 있는 이벤트가 있다”는 정보를 반환한다. 실제 I/O는 그 다음 <code class="language-plaintext highlighter-rouge">read()</code>나 <code class="language-plaintext highlighter-rouge">write()</code> syscall에서 일어난다.</p>

<p>그래서 epoll 기반 이벤트 루프는 보통 이런 모습이 된다.</p>

<div class="language-c highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">while</span> <span class="p">(</span><span class="n">running</span><span class="p">)</span> <span class="p">{</span>
    <span class="n">events</span> <span class="o">=</span> <span class="n">epoll_wait</span><span class="p">(</span><span class="n">epfd</span><span class="p">,</span> <span class="n">events</span><span class="p">,</span> <span class="n">maxevents</span><span class="p">,</span> <span class="n">timeout</span><span class="p">);</span>

    <span class="k">for</span> <span class="p">(</span><span class="n">event</span> <span class="n">in</span> <span class="n">events</span><span class="p">)</span> <span class="p">{</span>
        <span class="k">if</span> <span class="p">(</span><span class="n">event</span><span class="p">.</span><span class="n">readable</span><span class="p">)</span> <span class="p">{</span>
            <span class="n">n</span> <span class="o">=</span> <span class="n">read</span><span class="p">(</span><span class="n">event</span><span class="p">.</span><span class="n">fd</span><span class="p">,</span> <span class="n">buf</span><span class="p">,</span> <span class="k">sizeof</span><span class="p">(</span><span class="n">buf</span><span class="p">));</span>
            <span class="n">handle</span><span class="p">(</span><span class="n">buf</span><span class="p">,</span> <span class="n">n</span><span class="p">);</span>
        <span class="p">}</span>
    <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<p>이 모델은 네트워크 소켓과 잘 맞는다. Tomcat NIO, Netty epoll transport, Redis, Nginx 같은 구조가 이런 readiness 모델 위에서 오래 검증되어 왔다.</p>

<p>다만 readiness는 “가능해 보인다”에 가깝다. edge-triggered 모드에서는 특히 읽을 수 있는 만큼 drain해야 하고, 실제 <code class="language-plaintext highlighter-rouge">read()</code> 결과가 짧게 오거나 <code class="language-plaintext highlighter-rouge">EAGAIN</code>으로 끝날 수 있다.</p>

<h2 id="io_uring은-작업을-제출하고-완료를-받는다">io_uring은 작업을 제출하고 완료를 받는다</h2>

<p><code class="language-plaintext highlighter-rouge">io_uring</code>은 Linux 전용 비동기 I/O 인터페이스다. 이름처럼 userspace와 kernel space가 공유하는 ring buffer를 중심으로 동작한다.</p>

<p>두 개의 큐가 핵심이다.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Submission Queue, SQ: 애플리케이션 -&gt; 커널
Completion Queue, CQ: 커널 -&gt; 애플리케이션
</code></pre></div></div>

<p>애플리케이션은 SQE, submission queue entry에 작업을 쓴다.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>opcode    = READ / WRITE / ACCEPT / RECV / SEND / TIMEOUT ...
fd        = 대상 파일 또는 소켓
addr      = 유저 버퍼 주소
len       = 길이
user_data = 완료를 다시 매칭하기 위한 식별자
</code></pre></div></div>

<p>커널은 작업을 처리하고 CQE, completion queue entry에 결과를 남긴다.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>user_data = 원래 요청 식별자
res       = 결과값, 예: 읽은 바이트 수 또는 음수 에러 코드
flags     = 부가 정보
</code></pre></div></div>

<p>즉 질문이 바뀐다.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>epoll:
  "이 fd 지금 읽을 수 있어?"

io_uring:
  "이 fd에서 이 버퍼로 읽어줘. 끝나면 알려줘."
</code></pre></div></div>

<div class="gc-visual uring-visual" role="img" aria-label="io_uring은 userspace가 Submission Queue에 SQE를 쓰고, kernel이 완료 결과를 Completion Queue의 CQE로 돌려주는 구조다">
  <div class="gc-visual__header">
    <strong>SQ/CQ ring 구조</strong>
    <span>요청 메타데이터와 완료 메타데이터가 공유 ring을 지나가며, syscall 경계를 batch로 줄일 수 있다.</span>
  </div>
  <div class="uring-exchange">
    <div class="uring-exchange__stage">
      <b>사용자 코드</b>
      <span><code>recv</code>, <code>read</code>, <code>send</code> 작업을 준비한다.</span>
    </div>
    <span class="uring-exchange__arrow" aria-hidden="true">→</span>
    <div class="uring-exchange__stage" data-kind="submit">
      <b>SQ / SQE</b>
      <span>작업 종류, fd, buffer, <code>user_data</code>를 공유 큐에 기록한다.</span>
    </div>
    <span class="uring-exchange__arrow" aria-hidden="true">→</span>
    <div class="uring-exchange__stage" data-kind="kernel">
      <b>커널 I/O</b>
      <span>SQE를 가져가 실제 I/O를 수행한다.</span>
    </div>
    <span class="uring-exchange__arrow" aria-hidden="true">→</span>
    <div class="uring-exchange__stage" data-kind="complete">
      <b>CQ / CQE</b>
      <span>결과 <code>res</code>와 요청 식별자 <code>user_data</code>를 기록한다.</span>
    </div>
    <div class="uring-exchange__return">사용자 코드는 CQE를 수거해 원래 요청의 상태 머신을 이어간다.</div>
  </div>
</div>

<h2 id="syscall을-줄인다는-말의-의미">syscall을 줄인다는 말의 의미</h2>

<p><code class="language-plaintext highlighter-rouge">io_uring</code>이 빠르다고 말할 때 자주 나오는 이유가 syscall 감소다. 이것도 조금 정확히 봐야 한다.</p>

<p><code class="language-plaintext highlighter-rouge">epoll</code> 기반 루프는 대략 이렇게 syscall이 나뉜다.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>epoll_wait()
read()
write()
epoll_wait()
read()
write()
</code></pre></div></div>

<p>반면 <code class="language-plaintext highlighter-rouge">io_uring</code>은 여러 작업을 SQ에 적어 두고, <code class="language-plaintext highlighter-rouge">io_uring_enter()</code> 한 번으로 여러 요청을 제출하거나 완료를 기다릴 수 있다.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>SQ에 여러 recv/send/read/write SQE 적재
-&gt; io_uring_enter()
-&gt; CQ에서 여러 완료 확인
</code></pre></div></div>

<p>모든 syscall이 사라지는 것은 아니다. 커널에 새 제출이 있다는 사실을 알려야 하고, 완료를 기다려야 할 수도 있다. 다만 요청과 완료의 메타데이터가 공유 ring을 통해 오가기 때문에, 여러 I/O를 batch로 묶을 여지가 생긴다. <code class="language-plaintext highlighter-rouge">SQPOLL</code>, fixed buffer, registered file 같은 고급 옵션을 쓰면 특정 경로의 비용을 더 줄일 수도 있다.</p>

<h2 id="네트워크-io에서는-무엇이-달라질까">네트워크 I/O에서는 무엇이 달라질까</h2>

<p>epoll 기반 네트워크 서버의 read 흐름은 보통 이렇게 보인다.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>NIC
-&gt; kernel TCP receive buffer
-&gt; epoll_wait(): socket readable
-&gt; application read(fd, user_buffer)
-&gt; kernel buffer에서 user buffer로 복사
-&gt; HTTP 파싱 / handler 호출
</code></pre></div></div>

<p>io_uring 기반이라면 읽기 요청을 미리 제출해 둘 수 있다.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>recv SQE 제출
-&gt; 커널이 socket recv 수행
-&gt; CQE 도착
-&gt; res = 읽은 바이트 수
-&gt; handler 호출
-&gt; 다음 recv SQE 제출
</code></pre></div></div>

<p>여기서도 일반적인 socket receive는 여전히 kernel TCP buffer에서 user buffer로 복사가 일어난다. <code class="language-plaintext highlighter-rouge">io_uring</code>이라는 이름만으로 자동 zero-copy가 되는 것은 아니다.</p>

<p>최신 커널에는 io_uring 기반 zero-copy receive 같은 기능도 있다. Linux kernel 문서 기준으로 ZC Rx는 네트워크 수신 경로에서 kernel-to-user copy를 제거하여 packet data를 userspace memory로 직접 받을 수 있게 하는 기능이다. 하지만 이건 일반적인 <code class="language-plaintext highlighter-rouge">io_uring</code> 사용 전체에 자동 적용되는 기본 동작이 아니다. NIC 조건, API 조건, 애플리케이션 구조가 맞아야 하는 별도 고급 기능으로 보는 편이 안전하다.</p>

<h2 id="파일-io에서-의미가-더-커지는-이유">파일 I/O에서 의미가 더 커지는 이유</h2>

<p>네트워크 소켓은 non-blocking readiness 모델과 잘 맞는다. 그래서 epoll만으로도 매우 빠른 서버를 만들 수 있다.</p>

<p>문제는 일반 파일 I/O다.</p>

<p>일반 파일은 네트워크 소켓처럼 “나중에 readable이 된다”는 readiness 모델과 잘 맞지 않는다. Linux <code class="language-plaintext highlighter-rouge">epoll_ctl(2)</code> 문서도 대상 fd가 epoll을 지원하지 않으면 <code class="language-plaintext highlighter-rouge">EPERM</code>을 반환할 수 있고, 그 예로 regular file이나 directory를 든다. 설령 poll/select 계열에서 일반 파일이 ready처럼 취급되는 상황을 생각하더라도, 실제 read는 page cache miss나 디스크 I/O 때문에 애플리케이션 스레드를 오래 붙잡을 수 있다.</p>

<p>그래서 전통적인 서버 구조에서는 이런 식의 분리가 흔했다.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>network socket = epoll event loop
file I/O       = worker thread pool
</code></pre></div></div>

<p>예를 들어 파일을 읽어서 소켓으로 보내야 하는 서버라면, 이벤트 루프 스레드가 직접 파일 read를 하면 블로킹 위험이 있다. 그래서 파일 read를 worker pool에 넘기고, 완료 후 다시 socket write를 진행하는 구조가 나온다.</p>

<p><code class="language-plaintext highlighter-rouge">io_uring</code>은 파일 read/write 자체를 비동기로 제출할 수 있어서 이 지점에서 의미가 커진다.</p>

<div class="gc-visual uring-visual" role="img" aria-label="io_uring은 파일 read와 socket send를 같은 completion 기반 흐름으로 연결할 수 있다">
  <div class="gc-visual__header">
    <strong>파일과 네트워크를 같은 완료 모델로 묶기</strong>
    <span>파일 서버, 프록시, 로그 수집기처럼 디스크와 네트워크를 함께 쓰는 시스템에서 구조 차이가 커진다.</span>
  </div>
  <div class="uring-flow">
    <div class="uring-step"><b>1</b><span>file read SQE</span></div>
    <div class="uring-step"><b>2</b><span>read 완료 CQE</span></div>
    <div class="uring-step"><b>3</b><span>응답 구성</span></div>
    <div class="uring-step"><b>4</b><span>socket send SQE</span></div>
    <div class="uring-step"><b>5</b><span>send 완료 CQE</span></div>
  </div>
</div>

<h2 id="완료-순서는-제출-순서와-다를-수-있다">완료 순서는 제출 순서와 다를 수 있다</h2>

<p><code class="language-plaintext highlighter-rouge">io_uring</code>을 이해할 때 중요한 감각이 하나 더 있다. 여러 요청을 한 번에 제출하면, 완료 순서가 제출 순서와 같다고 가정하면 안 된다.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>submit A: file read
submit B: socket recv
submit C: timeout

completion:
  B 완료
  C 완료
  A 완료
</code></pre></div></div>

<p>그래서 SQE의 <code class="language-plaintext highlighter-rouge">user_data</code>가 중요하다. 요청을 제출할 때 애플리케이션이 식별자를 넣어 두고, CQE가 돌아왔을 때 그 <code class="language-plaintext highlighter-rouge">user_data</code>로 어떤 요청의 완료인지 매칭해야 한다.</p>

<p>이 점은 epoll의 fd 이벤트 처리와 코드 구조를 다르게 만든다. io_uring 기반 코드는 보통 “완료 이벤트를 받아 다음 작업을 제출하는 상태머신”처럼 흐른다.</p>

<h2 id="accept도-completion-스타일로-바뀐다">accept도 completion 스타일로 바뀐다</h2>

<p>epoll 기반 accept는 이런 식이다.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>listen socket을 epoll에 등록
-&gt; EPOLLIN 발생
-&gt; accept() 호출
-&gt; client fd 획득
-&gt; client fd를 다시 epoll에 등록
</code></pre></div></div>

<p>io_uring 기반 accept는 이렇게 볼 수 있다.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>accept SQE 제출
-&gt; 새 연결이 들어오면 accept 완료 CQE 발생
-&gt; CQE 결과로 client fd 획득
-&gt; client fd에 recv SQE 제출
-&gt; 다음 accept SQE 다시 제출
</code></pre></div></div>

<p>이 차이는 “이벤트가 왔으니 내가 syscall을 호출한다”와 “작업을 걸어두고 완료를 받는다”의 차이다.</p>

<h2 id="epoll과-io_uring-비교">epoll과 io_uring 비교</h2>

<table class="uring-compare-table">
  <thead>
    <tr>
      <th>항목</th>
      <th>epoll</th>
      <th>io_uring</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>모델</td>
      <td>readiness notification</td>
      <td>completion notification</td>
    </tr>
    <tr>
      <td>핵심 질문</td>
      <td>“이 fd 지금 준비됐나?”</td>
      <td>“내가 제출한 I/O가 끝났나?”</td>
    </tr>
    <tr>
      <td>I/O 실행</td>
      <td>애플리케이션이 <code>read()</code>/<code>write()</code> 호출</td>
      <td>커널에 SQE 제출 후 CQE로 완료 수신</td>
    </tr>
    <tr>
      <td>네트워크 소켓</td>
      <td>매우 성숙하고 강함</td>
      <td>completion 기반으로 처리 가능</td>
    </tr>
    <tr>
      <td>파일 I/O</td>
      <td>event loop와 직접 결합하기 까다로움</td>
      <td>비동기 제출/완료 모델과 잘 맞음</td>
    </tr>
    <tr>
      <td>성능 포인트</td>
      <td>non-blocking readiness 처리</td>
      <td>shared ring, batching, fixed resource 최적화</td>
    </tr>
    <tr>
      <td>복잡도</td>
      <td>상대적으로 단순하고 생태계가 성숙함</td>
      <td>버퍼 생명주기와 완료 상태머신 설계가 중요함</td>
    </tr>
  </tbody>
</table>

<h2 id="java-백엔드에서는-바로-체감하기-어렵다">Java 백엔드에서는 바로 체감하기 어렵다</h2>

<p>Spring Boot, Tomcat, JDBC 기반 서버를 생각하면 <code class="language-plaintext highlighter-rouge">io_uring</code>이 곧바로 성능 향상으로 이어진다고 말하기 어렵다.</p>

<p>이런 서버의 병목은 자주 다른 곳에 있다.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>JDBC blocking call
DB connection pool 대기
외부 API latency
serialization/deserialization
GC
worker thread 점유
</code></pre></div></div>

<p>Tomcat NIO나 Netty가 epoll 기반 readiness 모델을 잘 사용하더라도, 컨트롤러 안에서 JDBC를 호출하면 worker thread는 DB 응답을 기다리며 블로킹된다.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>socket readable
-&gt; request parsing
-&gt; worker thread 배정
-&gt; JDBC query
-&gt; worker thread blocking
</code></pre></div></div>

<p>이 상황에서 네트워크 이벤트 감시 모델만 io_uring으로 바꾼다고 전체 병목이 사라지지는 않는다. io_uring을 제대로 활용하려면 서버 런타임이나 네트워크 라이브러리 레벨에서 completion 기반 transport를 제공하고, 애플리케이션 구조도 그 모델에 맞아야 한다.</p>

<p>반대로 다음 같은 시스템에서는 가치가 커질 수 있다.</p>

<ul>
  <li>고성능 프록시</li>
  <li>파일 서버</li>
  <li>object storage gateway</li>
  <li>로그 수집 서버</li>
  <li>네트워크와 디스크를 함께 많이 쓰는 서버</li>
  <li>C/Rust 기반 커스텀 런타임</li>
  <li>syscall overhead가 병목인 초고성능 서버</li>
</ul>

<h2 id="io_uring이-항상-상위호환은-아니다">io_uring이 항상 상위호환은 아니다</h2>

<p><code class="language-plaintext highlighter-rouge">io_uring</code>은 강력하지만 공짜는 아니다.</p>

<ul>
  <li>커널 버전에 따라 지원 opcode와 안정성이 달라질 수 있다.</li>
  <li>completion 기반 상태머신으로 코드를 설계해야 한다.</li>
  <li>유저 버퍼의 생명주기를 완료 시점까지 안전하게 유지해야 한다.</li>
  <li>특정 작업은 커널 내부 worker thread를 사용할 수 있다.</li>
  <li>보안 정책이나 운영 환경에 따라 제한될 수 있다.</li>
  <li>기존 epoll 기반 생태계는 훨씬 오래 검증되어 있다.</li>
</ul>

<p>특히 버퍼 생명주기는 자주 놓치는 부분이다. SQE에 유저 버퍼 주소를 넘긴다는 것은, 커널이 그 작업을 끝낼 때까지 그 버퍼가 살아 있어야 한다는 뜻이다. completion 기반 I/O는 “요청을 제출한 함수가 끝났다”와 “버퍼를 재사용해도 된다”가 같은 시점이 아니다.</p>

<h2 id="한-문장으로-정리하면">한 문장으로 정리하면</h2>

<p><code class="language-plaintext highlighter-rouge">epoll</code>은 준비된 fd 목록을 받아 애플리케이션이 직접 I/O를 호출하는 readiness 모델이고, <code class="language-plaintext highlighter-rouge">io_uring</code>은 I/O 작업을 커널에 제출한 뒤 완료 결과를 CQE로 받는 completion 모델이다.</p>

<p>그래서 io_uring은 epoll의 단순한 상위호환이라기보다, 파일 I/O와 네트워크 I/O를 같은 완료 기반 흐름으로 묶고 syscall 경계를 줄이기 위한 더 넓은 Linux I/O 인터페이스에 가깝다.</p>

<p>차분히 보면 핵심은 하나다.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>epoll:
  "준비됐어?"

io_uring:
  "끝났어?"
</code></pre></div></div>

<p>그 작은 질문의 차이가 서버의 상태머신, syscall 경계, 버퍼 생명주기, 파일 I/O 처리 방식을 바꾼다.</p>

<h2 id="참고한-자료">참고한 자료</h2>

<ul>
  <li><a href="https://www.man7.org/linux/man-pages/man7/io_uring.7.html">io_uring(7) - Linux manual page</a></li>
  <li><a href="https://manpages.ubuntu.com/manpages/jammy/man2/io_uring_enter.2.html">io_uring_enter(2) - Linux manual page</a></li>
  <li><a href="https://www.man7.org/linux/man-pages/man7/epoll.7.html">epoll(7) - Linux manual page</a></li>
  <li><a href="https://www.man7.org/linux/man-pages/man2/epoll_wait.2.html">epoll_wait(2) - Linux manual page</a></li>
  <li><a href="https://www.kernel.org/doc/html/latest/networking/iou-zcrx.html">io_uring zero copy Rx - Linux Kernel documentation</a></li>
</ul>]]></content><author><name>WooHyuk</name></author><category term="develop" /><category term="os" /><category term="linux" /><category term="io-uring" /><category term="epoll" /><category term="network" /><category term="filesystem" /><summary type="html"><![CDATA[epoll의 readiness notification과 io_uring의 completion notification을 비교하고, SQ/CQ ring, syscall batching, 파일 I/O와 네트워크 I/O 흐름을 정리합니다.]]></summary></entry><entry><title type="html">Consistent Hashing은 왜 서버가 바뀌어도 전체를 흔들지 않을까</title><link href="https://dngur6344.github.io/develop/2026-05-24-consistent-hashing/" rel="alternate" type="text/html" title="Consistent Hashing은 왜 서버가 바뀌어도 전체를 흔들지 않을까" /><published>2026-05-24T00:00:00+09:00</published><updated>2026-05-24T00:00:00+09:00</updated><id>https://dngur6344.github.io/develop/consistent-hashing</id><content type="html" xml:base="https://dngur6344.github.io/develop/2026-05-24-consistent-hashing/"><![CDATA[<p>분산 시스템에서 자주 마주치는 질문이 있다.</p>

<blockquote>
  <p>이 key는 어느 서버가 맡아야 할까?</p>
</blockquote>

<p>처음에는 간단해 보인다.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>server = hash(key) % N
</code></pre></div></div>

<p>서버가 4대라면 <code class="language-plaintext highlighter-rouge">hash(key) % 4</code>를 계산하면 된다. 구현은 쉽고, 해시 함수가 괜찮다면 분산도 나쁘지 않다.</p>

<p>문제는 서버 수가 바뀌는 순간 시작된다.</p>

<p><code class="language-plaintext highlighter-rouge">N = 4</code>에서 <code class="language-plaintext highlighter-rouge">N = 5</code>가 되면 같은 key라도 나머지 연산의 결과가 거의 전부 바뀐다. 서버를 한 대 추가했을 뿐인데, 대다수 key의 소유자가 바뀌고, 캐시도 비고, 데이터도 대량으로 이동해야 한다.</p>

<p>Consistent Hashing은 이 문제를 다르게 푼다.</p>

<blockquote>
  <p>서버 수가 바뀌어도 전체 key를 다시 흔들지 말고, 영향을 받는 구간만 움직이게 하자.</p>
</blockquote>

<style>
.hash-visual {
  --hash-panel: rgba(255, 250, 242, .075);
  --hash-panel-soft: rgba(255, 250, 242, .052);
  --hash-line: rgba(255, 250, 242, .18);
  --hash-ink: #fffaf2;
  --hash-muted: rgba(255, 250, 242, .7);
  --hash-gold: #d8b16f;
  --hash-green: #8fbf9b;
  --hash-blue: #8fb4d9;
  --hash-red: #d98989;
}

.hash-visual .hash-flow,
.hash-visual .hash-grid,
.hash-visual .hash-note-grid,
.hash-visual .hash-vnode-grid {
  display: grid;
  gap: .65rem;
}

.hash-visual .hash-flow {
  grid-template-columns: repeat(5, minmax(0, 1fr));
}

.hash-visual .hash-grid {
  grid-template-columns: 1.05fr .95fr;
  align-items: stretch;
}

.hash-visual .hash-note-grid {
  grid-template-columns: repeat(3, minmax(0, 1fr));
}

.hash-visual .hash-vnode-grid {
  grid-template-columns: repeat(4, minmax(0, 1fr));
}

.hash-visual .hash-step,
.hash-visual .hash-card,
.hash-visual .hash-note,
.hash-visual .hash-lane {
  min-width: 0;
  border: 1px solid var(--hash-line);
  border-radius: 6px;
  background: var(--hash-panel);
}

.hash-visual .hash-step {
  position: relative;
  padding: .58rem .62rem;
}

.hash-visual .hash-step:not(:last-child)::after {
  content: "->";
  position: absolute;
  right: -.49rem;
  top: 50%;
  color: rgba(255, 250, 242, .58);
  font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
  font-size: .66rem;
  transform: translate(50%, -50%);
}

.hash-visual b,
.hash-visual strong {
  display: block;
  color: var(--hash-ink);
}

.hash-visual span,
.hash-visual em {
  display: block;
  color: var(--hash-muted);
  font-style: normal;
  line-height: 1.45;
}

.hash-visual .hash-step b,
.hash-visual .hash-note b,
.hash-visual .hash-lane b {
  font-size: .66rem;
}

.hash-visual .hash-step span,
.hash-visual .hash-note span,
.hash-visual .hash-lane span {
  margin-top: .14rem;
  font-size: .62rem;
}

.hash-visual .hash-card,
.hash-visual .hash-note,
.hash-visual .hash-lane {
  padding: .68rem;
}

.hash-visual .hash-card-title {
  margin-bottom: .5rem;
  font-size: .74rem;
}

.hash-ring {
  position: relative;
  min-height: 19rem;
}

.hash-ring::before {
  content: "";
  position: absolute;
  left: 50%;
  top: 50%;
  width: min(15rem, 78%);
  aspect-ratio: 1;
  border: 1px solid rgba(255, 250, 242, .28);
  border-radius: 50%;
  background:
    radial-gradient(circle, rgba(255, 250, 242, .045) 0 38%, transparent 39%),
    conic-gradient(from 310deg, rgba(216, 177, 111, .18) 0 64deg, transparent 64deg 360deg);
  transform: translate(-50%, -50%);
}

.hash-token {
  position: absolute;
  min-width: 3.2rem;
  padding: .34rem .42rem;
  border: 1px solid rgba(255, 250, 242, .18);
  border-radius: 999px;
  color: var(--hash-ink);
  background: rgba(8, 10, 17, .58);
  font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
  font-size: .62rem;
  text-align: center;
  white-space: nowrap;
}

.hash-token[data-kind="node"] {
  border-color: rgba(143, 191, 155, .55);
  background: rgba(143, 191, 155, .18);
}

.hash-token[data-kind="new"] {
  border-color: rgba(216, 177, 111, .62);
  background: rgba(216, 177, 111, .2);
}

.hash-token[data-kind="key"] {
  border-color: rgba(143, 180, 217, .55);
  background: rgba(143, 180, 217, .16);
}

.hash-at-top {
  left: 50%;
  top: 1.2rem;
  transform: translateX(-50%);
}

.hash-at-right {
  right: 1.2rem;
  top: 48%;
  transform: translateY(-50%);
}

.hash-at-bottom {
  left: 50%;
  bottom: 1.2rem;
  transform: translateX(-50%);
}

.hash-at-left {
  left: 1.2rem;
  top: 48%;
  transform: translateY(-50%);
}

.hash-key-main {
  right: 24%;
  top: 25%;
}

.hash-key-moved {
  left: 24%;
  bottom: 26%;
}

.hash-lane + .hash-lane {
  margin-top: .5rem;
}

.hash-lane-track {
  display: grid;
  grid-template-columns: 1.1fr .7fr 1.1fr;
  gap: .38rem;
  margin-top: .5rem;
}

.hash-segment {
  min-height: 2rem;
  padding: .32rem .3rem;
  border: 1px solid rgba(255, 250, 242, .15);
  border-radius: 5px;
  color: var(--hash-ink);
  background: rgba(255, 250, 242, .07);
  font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
  font-size: .58rem;
  line-height: 1.25;
  text-align: center;
}

.hash-segment[data-kind="moved"] {
  border-color: rgba(216, 177, 111, .5);
  background: rgba(216, 177, 111, .16);
}

.hash-segment[data-kind="stable"] {
  border-color: rgba(143, 191, 155, .45);
  background: rgba(143, 191, 155, .12);
}

.hash-chip-row {
  display: grid;
  grid-template-columns: repeat(4, minmax(0, 1fr));
  gap: .34rem;
  margin-top: .45rem;
}

.hash-chip {
  min-height: 1.85rem;
  padding: .32rem .24rem;
  border: 1px solid rgba(143, 180, 217, .45);
  border-radius: 5px;
  color: var(--hash-ink);
  background: rgba(143, 180, 217, .12);
  font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
  font-size: .58rem;
  line-height: 1.22;
  text-align: center;
}

.hash-compare-table {
  width: 100%;
  border: 1px solid rgba(143, 94, 60, .32);
  border-collapse: separate;
  border-spacing: 0;
  border-radius: 6px;
  overflow: hidden;
  background: rgba(255, 250, 242, .88) !important;
  font-size: .88rem;
}

.hash-compare-table th,
.hash-compare-table td {
  border: 0;
  border-bottom: 1px solid rgba(143, 94, 60, .22);
  color: var(--coffee-ink) !important;
  vertical-align: top;
}

.hash-compare-table th {
  background: rgba(47, 33, 24, .92) !important;
  color: #fffaf2 !important;
  font-weight: 700;
}

.hash-compare-table tbody tr:nth-child(odd) td {
  background: rgba(255, 250, 242, .9) !important;
}

.hash-compare-table tbody tr:nth-child(even) td {
  background: rgba(247, 236, 222, .92) !important;
}

.hash-compare-table tbody tr:last-child td {
  border-bottom: 0;
}

.hash-compare-table td:first-child {
  color: #4d2d1e !important;
  font-weight: 700;
}

body.dark-mode .hash-compare-table {
  background: rgba(9, 13, 22, .86) !important;
  border-color: rgba(231, 212, 189, .24) !important;
  box-shadow: 0 1rem 2rem rgba(0, 0, 0, .22) !important;
}

body.dark-mode .hash-compare-table th {
  color: #fff4e5 !important;
  background: rgba(244, 234, 220, .12) !important;
}

body.dark-mode .hash-compare-table td {
  color: #ead8c3 !important;
  border-color: rgba(231, 212, 189, .18) !important;
}

body.dark-mode .hash-compare-table tbody tr:nth-child(odd) td {
  background: rgba(255, 250, 242, .045) !important;
}

body.dark-mode .hash-compare-table tbody tr:nth-child(even) td {
  background: rgba(255, 250, 242, .074) !important;
}

@media screen and (prefers-color-scheme: dark) {
  body:not(.light-mode) .hash-compare-table {
    background: rgba(9, 13, 22, .86) !important;
    border-color: rgba(231, 212, 189, .24) !important;
    box-shadow: 0 1rem 2rem rgba(0, 0, 0, .22) !important;
  }

  body:not(.light-mode) .hash-compare-table th {
    color: #fff4e5 !important;
    background: rgba(244, 234, 220, .12) !important;
  }

  body:not(.light-mode) .hash-compare-table td {
    color: #ead8c3 !important;
    border-color: rgba(231, 212, 189, .18) !important;
  }

  body:not(.light-mode) .hash-compare-table tbody tr:nth-child(odd) td {
    background: rgba(255, 250, 242, .045) !important;
  }

  body:not(.light-mode) .hash-compare-table tbody tr:nth-child(even) td {
    background: rgba(255, 250, 242, .074) !important;
  }
}

body.light-mode .hash-compare-table {
  background: rgba(255, 250, 242, .88) !important;
  border-color: rgba(143, 94, 60, .32) !important;
  box-shadow: none !important;
}

body.light-mode .hash-compare-table th {
  background: rgba(47, 33, 24, .92) !important;
  color: #fffaf2 !important;
}

body.light-mode .hash-compare-table td {
  color: var(--coffee-ink) !important;
  border-color: rgba(143, 94, 60, .22) !important;
}

body.light-mode .hash-compare-table tbody tr:nth-child(odd) td {
  background: rgba(255, 250, 242, .9) !important;
}

body.light-mode .hash-compare-table tbody tr:nth-child(even) td {
  background: rgba(247, 236, 222, .92) !important;
}

body.light-mode .hash-compare-table td:first-child {
  color: #4d2d1e !important;
}

@media screen and (max-width: 56rem) {
  .hash-visual .hash-flow,
  .hash-visual .hash-grid,
  .hash-visual .hash-note-grid,
  .hash-visual .hash-vnode-grid {
    grid-template-columns: 1fr;
  }

  .hash-visual .hash-step:not(:last-child)::after {
    content: "";
    left: 50%;
    right: auto;
    top: auto;
    bottom: -.5rem;
    width: 1px;
    height: .5rem;
    background: rgba(255, 250, 242, .34);
    transform: translateX(-50%);
  }

  .hash-ring {
    min-height: 16rem;
  }

  .hash-token {
    min-width: 2.8rem;
    font-size: .56rem;
  }

  .hash-chip-row {
    grid-template-columns: repeat(2, minmax(0, 1fr));
  }
}
</style>

<div class="gc-visual hash-visual" role="img" aria-label="Consistent Hashing은 key와 node를 같은 hash ring에 놓고, key 위치에서 시계 방향으로 처음 만나는 node에 배치한다">
  <div class="gc-visual__header">
    <strong>Hash Ring 기본 규칙</strong>
    <span>key와 node를 같은 원형 hash space에 놓고, key 위치에서 시계 방향으로 처음 만나는 node가 owner가 된다.</span>
  </div>
  <div class="hash-grid">
    <div class="hash-card">
      <b class="hash-card-title">ring lookup</b>
      <div class="hash-ring">
        <span class="hash-token hash-at-top" data-kind="node">Node A</span>
        <span class="hash-token hash-at-right" data-kind="node">Node B</span>
        <span class="hash-token hash-at-bottom" data-kind="node">Node C</span>
        <span class="hash-token hash-key-main" data-kind="key">key K</span>
        <span class="hash-token hash-key-moved" data-kind="key">key M</span>
      </div>
    </div>
    <div class="hash-card">
      <b class="hash-card-title">lookup rule</b>
      <div class="hash-flow">
        <div class="hash-step"><b>1</b><span>hash(key)</span></div>
        <div class="hash-step"><b>2</b><span>ring 위치</span></div>
        <div class="hash-step"><b>3</b><span>clockwise 이동</span></div>
        <div class="hash-step"><b>4</b><span>첫 node 선택</span></div>
        <div class="hash-step"><b>5</b><span>owner 결정</span></div>
      </div>
    </div>
  </div>
</div>

<h2 id="왜-hashkey--n으로는-부족할까">왜 <code class="language-plaintext highlighter-rouge">hash(key) % N</code>으로는 부족할까</h2>

<p>서버 수가 고정되어 있다면 modulo hashing도 쓸 수 있다. 문제는 클러스터가 고정되어 있지 않다는 점이다. 서버는 추가되고, 빠지고, 교체된다.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>N = 4
server = hash(key) % 4

N = 5
server = hash(key) % 5
</code></pre></div></div>

<p>이렇게 바뀌면 key 대부분의 목적지가 바뀐다. 실제 시스템에서는 이것이 단순 계산 문제가 아니다.</p>

<ul>
  <li>캐시라면 cache miss가 한꺼번에 늘어난다.</li>
  <li>저장소라면 데이터 이동량이 폭증한다.</li>
  <li>Kafka처럼 key와 partition ordering이 연결된 시스템에서는 mapping 변화가 ordering 기대를 흔들 수 있다.</li>
  <li>LSM 기반 저장소라면 이동된 데이터가 compaction과 tail latency를 건드릴 수 있다.</li>
</ul>

<p>Consistent Hashing은 서버 수 변경을 “전체 재배치”가 아니라 “일부 구간의 ownership 변경”으로 줄인다.</p>

<h2 id="링-위에-서버와-key를-같이-올린다">링 위에 서버와 key를 같이 올린다</h2>

<p>Consistent Hashing은 해시 함수의 출력 공간을 원처럼 생각한다.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>0 ................................ 2^32 - 1
^                                  |
|__________________________________|
</code></pre></div></div>

<p>서버도 해시한다.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>hash(nodeA)
hash(nodeB)
hash(nodeC)
</code></pre></div></div>

<p>key도 해시한다.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>hash("user:123")
</code></pre></div></div>

<p>key는 자기 위치에서 시계 방향으로 이동하다가 처음 만나는 node에 배치된다. 이때 node는 key의 owner가 된다.</p>

<h2 id="서버가-추가되면-일부-구간만-움직인다">서버가 추가되면 일부 구간만 움직인다</h2>

<p>새 서버 <code class="language-plaintext highlighter-rouge">D</code>가 <code class="language-plaintext highlighter-rouge">A</code>와 <code class="language-plaintext highlighter-rouge">B</code> 사이에 들어왔다고 하자. 그러면 <code class="language-plaintext highlighter-rouge">D</code> 바로 이전 구간에 있던 key들만 <code class="language-plaintext highlighter-rouge">D</code>로 이동한다. <code class="language-plaintext highlighter-rouge">C</code> 주변의 key, <code class="language-plaintext highlighter-rouge">A</code> 이전의 key, <code class="language-plaintext highlighter-rouge">B</code> 이후의 key는 그대로 남는다.</p>

<div class="gc-visual hash-visual" role="img" aria-label="새 노드 D가 A와 B 사이에 추가되면 D가 가져가는 작은 구간만 이동하고 나머지 구간은 안정적으로 유지된다">
  <div class="gc-visual__header">
    <strong>노드 추가 시 이동 범위</strong>
    <span>전체 key가 다시 섞이지 않고, 새 node가 끼어든 구간만 owner가 바뀐다.</span>
  </div>
  <div class="hash-grid">
    <div class="hash-card">
      <b class="hash-card-title">before</b>
      <div class="hash-lane">
        <b>A -&gt; B</b>
        <span>B가 넓은 구간을 담당한다.</span>
        <div class="hash-lane-track">
          <span class="hash-segment" data-kind="stable">A range</span>
          <span class="hash-segment" data-kind="stable">B owns</span>
          <span class="hash-segment" data-kind="stable">B owns</span>
        </div>
      </div>
    </div>
    <div class="hash-card">
      <b class="hash-card-title">after adding D</b>
      <div class="hash-lane">
        <b>A -&gt; D -&gt; B</b>
        <span>D가 들어온 구간 일부만 B에서 D로 이동한다.</span>
        <div class="hash-lane-track">
          <span class="hash-segment" data-kind="stable">A range</span>
          <span class="hash-segment" data-kind="moved">move to D</span>
          <span class="hash-segment" data-kind="stable">B keeps</span>
        </div>
      </div>
    </div>
  </div>
</div>

<p>반대로 서버가 빠지면 그 서버가 담당하던 구간만 다음 node로 넘어간다. 그래서 Consistent Hashing은 “클러스터 membership 변화에 대한 이동 비용”을 줄이는 알고리즘이다.</p>

<h2 id="그래도-균등-분산은-자동으로-보장되지-않는다">그래도 균등 분산은 자동으로 보장되지 않는다</h2>

<p>링을 만든다고 모든 문제가 끝나지는 않는다. 서버를 한 점씩만 링 위에 올리면 위치가 운 나쁘게 몰릴 수 있다.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>A --------------------------- B -- C
</code></pre></div></div>

<p>이런 배치에서는 <code class="language-plaintext highlighter-rouge">A</code>나 <code class="language-plaintext highlighter-rouge">B</code>가 담당하는 구간이 지나치게 커질 수 있다. hash function이 균등하더라도 node가 적으면 구간 길이 편차가 커질 수 있고, 결국 특정 서버에 데이터와 요청이 몰린다.</p>

<p>여기서 virtual node가 등장한다.</p>

<h2 id="virtual-node는-물리-노드를-여러-점으로-쪼갠다">Virtual Node는 물리 노드를 여러 점으로 쪼갠다</h2>

<p>Virtual node, 줄여서 vnode는 물리 서버 하나를 링 위의 여러 위치로 표현하는 방법이다.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Node A -&gt; A1, A2, A3, A4
Node B -&gt; B1, B2, B3, B4
Node C -&gt; C1, C2, C3, C4
</code></pre></div></div>

<div class="gc-visual hash-visual" role="img" aria-label="Virtual node는 하나의 물리 노드를 여러 token 위치로 나누어 링 전체에 분산한다">
  <div class="gc-visual__header">
    <strong>Virtual Node</strong>
    <span>물리 node 하나가 여러 token range를 맡으면 구간 편차가 줄고, 추가/삭제 시 작은 단위로 streaming할 수 있다.</span>
  </div>
  <div class="hash-vnode-grid">
    <div class="hash-note"><b>Node A</b><span>A1, A2, A3, A4</span></div>
    <div class="hash-note"><b>Node B</b><span>B1, B2, B3, B4</span></div>
    <div class="hash-note"><b>Node C</b><span>C1, C2, C3, C4</span></div>
    <div class="hash-note"><b>Node D</b><span>D1, D2, D3, D4</span></div>
  </div>
  <div class="hash-chip-row">
    <span class="hash-chip">A1</span>
    <span class="hash-chip">C1</span>
    <span class="hash-chip">B1</span>
    <span class="hash-chip">D1</span>
    <span class="hash-chip">A2</span>
    <span class="hash-chip">B2</span>
    <span class="hash-chip">D2</span>
    <span class="hash-chip">C2</span>
  </div>
</div>

<p>vnode의 장점은 세 가지다.</p>

<ol>
  <li>구간 편차가 줄어 load balancing이 좋아진다.</li>
  <li>새 서버가 들어올 때 여러 서버에서 조금씩 데이터를 받아 올 수 있다.</li>
  <li>성능이 다른 서버에 더 많은 vnode를 줄 수 있다.</li>
</ol>

<p>하지만 vnode가 많다고 항상 좋은 것은 아니다. token 관리, streaming, repair, 모니터링의 단위가 늘어난다. Cassandra 문서도 vnode가 load distribution에는 도움을 주지만 token 관리 오버헤드를 늘릴 수 있다고 설명한다.</p>

<h2 id="consistent-hashing은-partitioning만이-아니다">Consistent Hashing은 partitioning만이 아니다</h2>

<p>분산 저장소에서는 partitioning과 replication이 함께 간다.</p>

<p>replication factor가 3이라면 key의 primary owner 하나만 고르는 것이 아니라, 링에서 이어지는 다음 node들까지 replica로 선택할 수 있다.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>key K owner: Node B
replica candidates: Node B, Node C, Node D
</code></pre></div></div>

<p>Dynamo 계열 시스템에서는 이런 ownership과 replica list가 quorum, read repair, anti-entropy와 연결된다.</p>

<p>예를 들어 replica 수 <code class="language-plaintext highlighter-rouge">N = 3</code>, write quorum <code class="language-plaintext highlighter-rouge">W = 2</code>, read quorum <code class="language-plaintext highlighter-rouge">R = 2</code>라면:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>R + W &gt; N
</code></pre></div></div>

<p>조건을 만족하므로 읽기와 쓰기 quorum이 적어도 하나의 replica에서 겹친다. 물론 이것만으로 모든 일관성 문제가 사라지는 것은 아니다. 네트워크 partition, stale replica, conflict resolution, hinted handoff, read repair 같은 운영상의 문제가 뒤따른다.</p>

<h2 id="균등한-hash와-균등한-workload는-다르다">균등한 hash와 균등한 workload는 다르다</h2>

<p>Consistent Hashing은 key space를 나누는 데 도움을 준다. 하지만 실제 트래픽은 균등하지 않다.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>데이터 크기 != 요청 수 != CPU 비용
</code></pre></div></div>

<p>작은 key 하나가 전체 트래픽의 큰 비율을 차지할 수 있다. celebrity user, 인기 게시글, 대형 채팅방, 실시간 경기 이벤트 같은 key는 해시가 아무리 균등해도 한 partition을 뜨겁게 만든다.</p>

<p>이것이 hot key 문제다.</p>

<p>해결 방법은 상황에 따라 달라진다.</p>

<ul>
  <li>key salting: <code class="language-plaintext highlighter-rouge">user123#0</code>, <code class="language-plaintext highlighter-rouge">user123#1</code>처럼 나누어 write를 퍼뜨린다.</li>
  <li>read aggregation: salting된 key를 다시 모아서 읽는다.</li>
  <li>adaptive replication: hot partition의 replica를 늘린다.</li>
  <li>dynamic partitioning: load 기준으로 shard를 쪼갠다.</li>
  <li>cache isolation: hot key를 별도 캐시나 경로로 분리한다.</li>
</ul>

<p>이 모든 방법에는 대가가 있다. 특히 key salting은 쓰기는 퍼뜨리지만 읽기에는 aggregation 비용을 남긴다.</p>

<h2 id="kafka와는-어떻게-연결될까">Kafka와는 어떻게 연결될까</h2>

<p>Kafka producer도 key가 있는 record를 partition에 배치한다. 가장 단순하게 보면 <code class="language-plaintext highlighter-rouge">hash(key) % partition_count</code> 계열의 문제다.</p>

<p>Kafka에서 같은 key를 같은 partition으로 보내는 이유는 보통 per-key ordering 때문이다. 한 partition 안에서는 record order가 보존되지만, 여러 partition 사이에는 전체 순서가 없다.</p>

<p>따라서 partition 수를 늘리면 주의해야 한다. key-to-partition mapping이 바뀔 수 있고, 같은 key가 과거와 다른 partition으로 갈 수 있다. Kafka 문서도 partition 수를 바꾸는 일이 key 기반 ordering과 partitioning에 영향을 줄 수 있음을 설명한다.</p>

<p>여기서 tradeoff가 나온다.</p>

<table class="hash-compare-table">
  <thead>
    <tr>
      <th>선택</th>
      <th>장점</th>
      <th>대가</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>같은 key는 같은 partition</td>
      <td>per-key ordering을 유지하기 쉽다.</td>
      <td>hot key가 생기면 특정 partition이 막힌다.</td>
    </tr>
    <tr>
      <td>key를 더 잘게 분산</td>
      <td>병렬성과 처리량을 높일 수 있다.</td>
      <td>ordering과 aggregation이 어려워진다.</td>
    </tr>
    <tr>
      <td>partition 수 증가</td>
      <td>병렬 처리 여지를 늘린다.</td>
      <td>기존 key mapping, cache, consumer 배치가 흔들릴 수 있다.</td>
    </tr>
  </tbody>
</table>

<h2 id="rebalancing은-계산보다-운영-비용이-크다">Rebalancing은 계산보다 운영 비용이 크다</h2>

<p>Consistent Hashing은 이동해야 할 key 범위를 줄여준다. 하지만 이동이 “공짜”라는 뜻은 아니다.</p>

<p>노드 추가나 제거가 발생하면 실제로는 다음 비용이 생긴다.</p>

<ul>
  <li>네트워크 대역폭: 새 owner에게 데이터를 streaming해야 한다.</li>
  <li>디스크 I/O: 읽고 쓰는 작업이 foreground traffic과 경쟁한다.</li>
  <li>cache miss: ownership이 바뀐 구간은 cache가 따뜻하지 않다.</li>
  <li>LSM compaction: Cassandra, ScyllaDB 같은 LSM 계열에서는 이동된 SSTable과 compaction이 tail latency를 흔들 수 있다.</li>
  <li>운영 위험: 장애 복구와 scale-out이 동시에 일어나면 rebalance가 더 오래 걸린다.</li>
</ul>

<p>그래서 좋은 partitioning은 단지 “어디에 둘까”가 아니라 “바뀔 때 얼마나 조용히 움직일 수 있을까”까지 포함한다.</p>

<h2 id="언제-consistent-hashing을-떠올려야-할까">언제 Consistent Hashing을 떠올려야 할까</h2>

<p>다음 조건이 보이면 Consistent Hashing을 검토할 만하다.</p>

<ul>
  <li>서버 수가 자주 바뀐다.</li>
  <li>key ownership이 안정적이어야 한다.</li>
  <li>cache miss나 data movement 비용이 크다.</li>
  <li>중앙 coordinator 없이도 ownership을 계산하고 싶다.</li>
  <li>partitioning과 replication을 함께 설계해야 한다.</li>
</ul>

<p>반대로 단순하고 작은 시스템이라면 그냥 modulo hashing이나 명시적 shard map이 더 낫다. Consistent Hashing은 분산 시스템의 변화 비용을 줄이는 도구이지, 모든 배치 문제를 자동으로 해결하는 마법은 아니다.</p>

<h2 id="한-문장으로-정리하면">한 문장으로 정리하면</h2>

<p>Consistent Hashing은 key와 node를 같은 hash space에 배치해서, node 추가와 제거가 일어나도 전체 key를 다시 흔들지 않고 영향받는 구간만 이동시키는 distributed placement algorithm이다.</p>

<p>하지만 현실의 어려움은 그 다음에 온다. vnode로 구간 편차를 줄이고, replication으로 가용성을 만들고, quorum과 repair로 일관성을 다루고, hot key와 rebalancing 비용을 운영에서 견뎌야 한다. 균등한 hash는 시작일 뿐이고, 좋은 분산 시스템은 데이터 크기와 요청량과 이동 비용이 서로 다르다는 사실을 계속 다룬다.</p>

<h2 id="참고한-자료">참고한 자료</h2>

<ul>
  <li><a href="https://people.csail.mit.edu/karger/Papers/web.pdf">Consistent Hashing and Random Trees, Karger et al.</a></li>
  <li><a href="https://www.cs.princeton.edu/courses/archive/spring21/cos418/papers/dynamo.pdf">Dynamo: Amazon’s Highly Available Key-value Store</a></li>
  <li><a href="https://cassandra.apache.org/doc/latest/cassandra/architecture/dynamo.html">Apache Cassandra - Dynamo</a></li>
  <li><a href="https://cassandra.apache.org/doc/latest/cassandra/managing/operating/topo_changes.html">Apache Cassandra - Adding, replacing, moving and removing nodes</a></li>
  <li><a href="https://kafka.apache.org/38/operations/basic-kafka-operations/">Apache Kafka - Basic Kafka Operations</a></li>
  <li><a href="https://kafka.apache.org/35/design/protocol/">Apache Kafka - Protocol, Partitioning and bootstrapping</a></li>
</ul>]]></content><author><name>WooHyuk</name></author><category term="develop" /><category term="algorithm" /><category term="distributed-system" /><category term="consistent-hashing" /><category term="cassandra" /><category term="kafka" /><summary type="html"><![CDATA[Consistent Hashing의 hash ring, clockwise lookup, virtual node, hot key, rebalancing 비용과 Dynamo, Cassandra, Kafka와의 연결을 정리합니다.]]></summary></entry></feed>