<?xml version="1.0" encoding="utf-8"?><feed xmlns="http://www.w3.org/2005/Atom" ><generator uri="https://jekyllrb.com/" version="3.10.0">Jekyll</generator><link href="https://l2juhan.github.io/feed.xml" rel="self" type="application/atom+xml" /><link href="https://l2juhan.github.io/" rel="alternate" type="text/html" /><updated>2026-06-06T08:04:14+00:00</updated><id>https://l2juhan.github.io/feed.xml</id><title type="html">잉주한의 IT 일기</title><subtitle>컴퓨터공학, 클라우드, 프론트엔드 학습 기록</subtitle><author><name>이주한</name></author><entry><title type="html">Graph Optimization Problems and Greedy Algorithms — MST, Dijkstra, 그리고 그리디</title><link href="https://l2juhan.github.io/algorithm/2026/06/06/graph-optimization-and-greedy-algorithms.html" rel="alternate" type="text/html" title="Graph Optimization Problems and Greedy Algorithms — MST, Dijkstra, 그리고 그리디" /><published>2026-06-06T00:00:00+00:00</published><updated>2026-06-06T00:00:00+00:00</updated><id>https://l2juhan.github.io/algorithm/2026/06/06/graph-optimization-and-greedy-algorithms</id><content type="html" xml:base="https://l2juhan.github.io/algorithm/2026/06/06/graph-optimization-and-greedy-algorithms.html"><![CDATA[<p>연결할 정점이 여럿일 때 비용을 가장 적게 들이는 방법, 한 정점에서 다른 정점까지 가장 싸게 가는 경로. 둘 다 <strong>최적화 문제(optimization problem)</strong> 다. 이런 문제를 푸는 한 가지 전략이 매 순간 가장 좋아 보이는 선택을 하는 <strong>그리디(greedy)</strong> 다. 그리디가 늘 통하지는 않지만, 어떤 문제는 그리디로 정확한 최적해가 나온다는 것이 증명되어 있다. 이 글은 그 대표 사례인 최소 신장 트리(MST)와 단일 출발점 최단 경로(Dijkstra)를 다룬다.</p>

<h2 id="최적화-문제와-그리디">최적화 문제와 그리디</h2>

<p>최적화 문제는 <strong>총비용을 최소화</strong>하거나 <strong>총이익을 최대화</strong>하는 문제다. 푸는 방식은 크게 둘로 갈린다.</p>

<ul>
  <li><strong>모든 경우를 분석해 최선을 찾는다.</strong> 동적 계획법(DP)이 이 계열이다.</li>
  <li><strong>일련의 선택을 하되, 그 선택들의 전체 효과가 최적이 되도록 한다.</strong> 그리디가 이 계열이다.</li>
</ul>

<h3 id="그리디-알고리즘">그리디 알고리즘</h3>

<p>그리디는 선택을 순차적으로 하되 다음 두 성질을 가진다.</p>

<ul>
  <li><strong>각 선택이 그 시점에서 최선이다.</strong> 단, “단기(short-term)” 기준에 따른 최선이고, 그 기준은 평가 비용이 너무 크지 않아야 한다.</li>
  <li><strong>한 번 한 선택은 되돌릴 수 없다(cannot be undone).</strong> 나중에 그게 나쁜 선택이었음이 드러나도 무를 수 없다.</li>
</ul>

<p>여기서 그리디의 약점이 나온다. 당장의 비용이 작은 행동이, 나중에 큰 비용을 피할 수 없는 상황으로 몰아넣을 수 있다. 그래서 그리디가 최적해를 보장하는지는 문제마다 따로 증명해야 한다.</p>

<p>다행히 일부 그래프 최적화 문제는 그리디로 <strong>정확하게(exactly)</strong> 풀린다.</p>

<ol>
  <li>모든 정점을 잇는 최소 비용 → <strong>최소 신장 트리 알고리즘</strong></li>
  <li>두 정점 사이 최단 경로 → <strong>단일 출발점 최단 경로 알고리즘</strong></li>
</ol>

<h2 id="최소-신장-트리mst">최소 신장 트리(MST)</h2>

<p>연결된 무방향 그래프 $G = (V, E)$에 대해 <strong>신장 트리(spanning tree)</strong> 는 다음을 만족하는 $G$의 부분그래프다.</p>

<ul>
  <li>무방향 트리이고(사이클이 없고),</li>
  <li>$G$의 <strong>모든 정점을 포함</strong>한다.</li>
</ul>

<p>가중 그래프 $G = (V, E, W)$에서 부분그래프의 <strong>무게(weight)</strong> 는 그 안에 든 간선들의 가중치 합이다. <strong>최소 신장 트리(minimum spanning tree)</strong> 는 무게가 최소인 신장 트리다.</p>

<p>주의할 점은 MST가 <strong>유일하지 않을 수 있다</strong>는 것이다. 같은 최소 무게를 갖는 신장 트리가 여러 개일 수 있다. 예를 들어 정점 $A, B, C, D$에 간선 $AB = 2$, $AC = 3$, $AD = 2$, $CD = 4$, $BD = 1$ 같은 그래프에서는 무게가 같은 신장 트리가 둘 이상 나온다.</p>

<p>MST를 구하는 그리디 알고리즘은 두 가지가 대표적이다. <strong>정점을 키워 나가는</strong> Prim과 <strong>간선을 골라 나가는</strong> Kruskal이다.</p>

<h2 id="prim-알고리즘">Prim 알고리즘</h2>

<p>Prim은 트리를 하나의 정점에서 시작해 간선을 하나씩 붙여 키운다.</p>

<ol>
  <li>임의의 시작 정점 하나를 고른다(루트 역할).</li>
  <li>지금까지 만든 트리에서, 붙일 수 있는 모든 간선 중 <strong>가중치가 가장 작은 간선</strong>을 골라 트리에 붙이고, 그 간선에 딸린 정점을 트리에 추가한다. ← 이 선택이 그리디다.</li>
  <li>모든 정점이 트리에 들어올 때까지 반복한다.</li>
</ol>

<p>알고리즘이 도는 동안 정점은 서로 겹치지 않는 세 부류로 나뉜다.</p>

<table>
  <thead>
    <tr>
      <th>상태</th>
      <th>의미</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><strong>tree</strong></td>
      <td>지금까지 만든 트리에 들어 있는 정점</td>
    </tr>
    <tr>
      <td><strong>fringe</strong></td>
      <td>트리에 없지만 트리의 어떤 정점과 인접한 정점(곧 트리가 될 후보)</td>
    </tr>
    <tr>
      <td><strong>unseen</strong></td>
      <td>그 외 전부(아직 살펴보지 않은 정점)</td>
    </tr>
  </tbody>
</table>

<h3 id="개요">개요</h3>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>PrimMST(G, n):
    모든 정점을 unseen으로 초기화
    임의의 정점 s를 골라 tree로 분류
    s에 인접한 모든 정점을 fringe로 분류
    while (fringe 정점이 있는 동안):              // 약 n-1번 반복
        tree 정점 t와 fringe 정점 v 사이에서
            가중치가 최소인 간선 tv를 고른다       // greedy
        v를 tree로 재분류하고, 간선 tv를 트리에 추가
        v에 인접한 unseen 정점들을 fringe로 재분류
</code></pre></div></div>

<p>매 반복마다 “fringe 중에서 트리에 가장 싸게 붙는 정점”을 하나 뽑는다. 이 “최솟값 뽑기”를 어떤 자료구조로 하느냐가 전체 복잡도를 결정한다.</p>

<h3 id="우선순위-큐와-복잡도">우선순위 큐와 복잡도</h3>

<p>fringe 정점들을 우선순위 큐(priority queue)에 넣고, 각 정점의 키(key)를 “트리에 붙는 최소 간선 가중치”로 둔다. 새 정점이 트리에 들어올 때마다 인접한 fringe 정점들의 키를 더 작은 값으로 갱신하는데, 이 연산이 <code class="language-plaintext highlighter-rouge">decreaseKey</code>다. 필요한 연산은 <code class="language-plaintext highlighter-rouge">insert</code>, <code class="language-plaintext highlighter-rouge">removeMin</code>, <code class="language-plaintext highlighter-rouge">decreaseKey</code> 세 가지이고, 구현마다 비용이 다르다.</p>

<table>
  <thead>
    <tr>
      <th>연산</th>
      <th>정렬 안 된 배열</th>
      <th>정렬된 배열</th>
      <th>힙(heap)</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">insert</code></td>
      <td>$O(1)$</td>
      <td>$O(n)$</td>
      <td>$O(\log n)$</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">removeMin</code></td>
      <td>$O(n)$</td>
      <td>$O(1)$</td>
      <td>$O(\log n)$</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">decreaseKey</code></td>
      <td>$O(1)$</td>
      <td>$O(n)$</td>
      <td>$O(\log n)$</td>
    </tr>
  </tbody>
</table>

<p>정렬 안 된 배열로 구현하면 매 반복의 <code class="language-plaintext highlighter-rouge">removeMin</code>이 $O(n)$이고 이를 $n-1$번 하므로, 초기화 $O(n)$까지 더해 전체가 다음과 같다.</p>

\[O(n) + (n-1) \times O(n) = O(n^2)\]

<p>이는 밀집 그래프($m \in O(n^2)$)에 유리하다. 반면 힙으로 구현하면 정점마다 <code class="language-plaintext highlighter-rouge">removeMin</code>($O(\log n)$)을 하고 간선마다 <code class="language-plaintext highlighter-rouge">decreaseKey</code>($O(\log n)$)를 하므로 다음과 같다.</p>

\[O(n \log n + 2m \log n) = O(m \log n)\]

<p>이는 희소 그래프($m \in O(n)$)에 유리하다. 즉 <strong>밀집 그래프면 정렬 안 된 배열($O(n^2)$), 희소 그래프면 힙($O(m \log n)$)</strong> 을 쓰는 것이 정석이다.</p>

<h2 id="kruskal-알고리즘">Kruskal 알고리즘</h2>

<p>Kruskal은 정점이 아니라 <strong>간선 정보로</strong> 접근한다. 전체 간선을 가벼운 순서로 보면서, 사이클을 만들지 않는 간선만 골라 숲(forest)에 추가한다.</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>KruskalMST(G, n):
    R = E           // R은 남은 간선들
    F = ∅           // F는 숲(트리)을 이루는 간선들
    while (R이 비어 있지 않은 동안):              // F가 n-1개 간선이면 종료
        R에서 가장 가벼운(짧은) 간선 vw를 꺼낸다
        if (vw가 F에 사이클을 만들지 않으면):
            vw를 F에 추가
    return F
</code></pre></div></div>

<p>“가장 가벼운 간선 꺼내기”는 두 방식으로 구현한다.</p>

<ul>
  <li><strong>간선을 미리 정렬</strong>해 두면 꺼내기가 $O(1)$. 정렬에 $O(m \log m)$.</li>
  <li><strong>우선순위 큐(최소 힙)</strong> 로 두면 꺼내기가 $O(\log m)$.</li>
</ul>

<p>어느 쪽이든 전체 시간은 다음과 같다.</p>

\[O(m \log m) \;\xrightarrow{\;m \in O(n^2)\;}\; O(2m \log n) = O(m \log n)\]

<h3 id="사이클-판정--union-find">사이클 판정 — Union-Find</h3>

<p>핵심은 “이 간선을 추가하면 사이클이 생기는가”를 빠르게 판정하는 것이다. Kruskal은 트리들의 숲을 유지하며, <strong>서로 다른 트리를 잇는 간선만 받아들인다</strong>(같은 트리 안의 두 정점을 이으면 사이클이 된다).</p>

<p>이를 위해 서로소 집합(disjoint set)을 관리하는 자료구조가 필요하고, 두 연산을 제공한다.</p>

<ul>
  <li><code class="language-plaintext highlighter-rouge">find(u)</code>: $u$가 속한 집합(트리)을 반환한다.</li>
  <li><code class="language-plaintext highlighter-rouge">union(u, v)</code>: $u$가 속한 집합과 $v$가 속한 집합을 하나로 합친다.</li>
</ul>

<p>간선 $vw$를 볼 때 <code class="language-plaintext highlighter-rouge">find(v)</code>와 <code class="language-plaintext highlighter-rouge">find(w)</code>가 다르면(다른 트리면) 추가하고 <code class="language-plaintext highlighter-rouge">union</code>으로 합치며, 같으면 버린다. 이 자료구조가 <strong>Union-Find</strong>다.</p>

<h2 id="단일-출발점-최단-경로">단일 출발점 최단 경로</h2>

<p>최단 경로 문제에는 입력에 따라 여러 변형이 있다.</p>

<table>
  <thead>
    <tr>
      <th>변형</th>
      <th>입력</th>
      <th>의미</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>single-source</td>
      <td>$(G, s)$</td>
      <td>출발점 $s$에서 모든 정점까지</td>
    </tr>
    <tr>
      <td>single-destination</td>
      <td>$(G, t)$</td>
      <td>모든 정점에서 도착점 $t$까지</td>
    </tr>
    <tr>
      <td>single-pair</td>
      <td>$(G, s, t)$</td>
      <td>한 쌍 $s \to t$만</td>
    </tr>
    <tr>
      <td>all-pair</td>
      <td>$(G)$</td>
      <td>모든 정점 쌍</td>
    </tr>
  </tbody>
</table>

<p>흥미로운 사실은, <strong>최악의 경우 한 쌍 $s \to t$ 최단 경로를 찾는 것이 $s$에서 도달 가능한 모든 정점까지의 최단 경로를 찾는 것보다 쉽지 않다</strong>는 점이다. $s \to t$만 구하려 해도 결국 $s$에서 출발하는 경로를 두루 키워야 $t$에 닿기 때문이다. 그래서 표준 문제를 <strong>단일 출발점 최단 경로(single-source shortest path)</strong> 로 잡는다.</p>

<h3 id="최단-경로의-정의와-성질">최단 경로의 정의와 성질</h3>

<p>가중 그래프 $G = (V, E, W)$에서 $k$개 간선 $xv_1, v_1v_2, \dots, v_{k-1}y$로 이루어진 비어 있지 않은 경로 $P$의 무게는 간선 가중치의 합이다.</p>

\[W(P) = W(xv_1) + W(v_1v_2) + \cdots + W(v_{k-1}y)\]

<p>$x = y$면 빈 경로를 $x$에서 $y$로 가는 경로로 보고 무게는 0이다. $x$와 $y$ 사이 어떤 경로도 $W(P)$보다 작은 무게를 갖지 않으면 $P$를 <strong>최단 경로(shortest path)</strong>, 즉 최소 무게 경로라 한다. 최단 경로는 여럿일 수 있고, 하나만 찾으면 된다.</p>

<p><strong>최단 경로 성질(최적 부분 구조).</strong> $x$에서 $z$로 가는 최단 경로가 “$x \to y$ 경로 $P$ 다음에 $y \to z$ 경로 $Q$”로 이루어진다고 하자. 그러면 $P$는 $x \to y$의 최단 경로이고 $Q$는 $y \to z$의 최단 경로다.</p>

<p>즉 최단 경로를 잘라 보면 각 조각도 최단 경로다. 이것이 <strong>최적 부분 구조(optimal substructure)</strong> 이며, 그리디와 DP가 동작하는 근거다. 단, <strong>역은 성립하지 않는다.</strong> 최단 경로 조각들을 이어 붙인다고 전체가 최단 경로가 되지는 않는다.</p>

<h2 id="dijkstra-알고리즘">Dijkstra 알고리즘</h2>

<p>Dijkstra는 단일 출발점 최단 경로를 푸는 그리디 알고리즘이다. 전제 조건이 하나 있다.</p>

<blockquote>
  <p><strong>모든 가중치가 음이 아니어야 한다(nonnegative weights).</strong></p>
</blockquote>

<p>구조는 Prim과 거의 같다. 정점을 tree / fringe / unseen으로 나누고 fringe에서 하나씩 트리로 끌어온다. 단, 고르는 기준이 다르다. Prim은 “트리에 붙는 간선 가중치”가 최소인 정점을 골랐지만, Dijkstra는 “<strong>출발점 $s$에서의 누적 거리</strong>“가 최소인 정점을 고른다.</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>dijkstraSSSP(G, n):
    모든 정점을 unseen으로 초기화
    출발 정점 s를 tree로 분류하고 d(s, s) = 0
    s에 인접한 모든 정점을 fringe로 분류
    while (fringe 정점이 있는 동안):
        tree 정점 t와 fringe 정점 v 사이에서
            d(s, t) + W(tv)가 최소가 되는 간선을 고른다    // greedy
        v를 tree로 재분류하고 간선 tv를 트리에 추가
        d(s, v) = d(s, t) + W(tv)
        v에 인접한 unseen 정점들을 fringe로 분류
</code></pre></div></div>

<p>Prim의 선택 기준 $W(tv)$가 Dijkstra에서는 $d(s, t) + W(tv)$로 바뀐 것이 전부다. 만들어지는 트리는 MST가 아니라 <strong>최단 경로 트리(shortest-path tree)</strong> 이고, 각 정점에 부모(predecessor)를 함께 기록하면 출발점에서 그 정점까지의 최단 경로를 역추적할 수 있다.</p>

<p>복잡도도 Prim과 동일한 구조다. 정렬 안 된 배열이면 $O(n^2)$, 힙이면 <code class="language-plaintext highlighter-rouge">decreaseKey</code>까지 포함해 $O(m \log n)$이다.</p>

<h3 id="정당성">정당성</h3>

<p>알고리즘의 핵심 정리(Theorem 8.6)는 이렇다.</p>

<blockquote>
  <p>가중치가 음이 아닌 가중 그래프 $G = (V, E, W)$에서 $V’ \subseteq V$, $s \in V’$라 하자. 각 $y \in V’$에 대해 $d(s, y)$가 $G$에서 $s \to y$의 최단 거리라고 가정한다. 한쪽 끝 $y$는 $V’$에, 다른 끝 $z$는 $V - V’$에 둔 <strong>모든 간선</strong> 중에서 $d(s, y) + W(yz)$를 최소화하는 간선 $yz$를 고르면, “$s \to y$ 최단 경로 다음에 간선 $yz$”는 $s \to z$의 최단 경로다.</p>
</blockquote>

<p>이 정리가 매 반복에서 새로 트리에 넣는 정점의 거리가 확정적으로 최단임을 보장한다. 결국 Dijkstra는 음이 아닌 가중치의 방향 그래프에서 $s$로부터 도달 가능한 모든 정점까지의 최단 거리를 정확히 계산한다.</p>

<h3 id="왜-음수-가중치에서-깨지는가">왜 음수 가중치에서 깨지는가</h3>

<p>Dijkstra가 음수 가중치를 다루지 못하는 이유는 그리디 + “한 번 확정하면 무르지 않음” 때문이다. 다음 그래프를 보자.</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>      A (= s)
   2 /     \ 5
    B  ←——— C
       -10
</code></pre></div></div>

<p>간선은 $A \to B = 2$, $A \to C = 5$, $C \to B = -10$이다. Dijkstra는 $d(A, A) = 0$을 고정한 뒤 fringe에서 가장 가까운 $B$($d = 2$)를 먼저 확정한다. 하지만 실제 최단 경로는 $A \to C \to B = 5 + (-10) = -5$로 더 짧다. Dijkstra는 $B$를 이미 $d = 2$로 확정해 버렸기 때문에 나중에 등장하는 음수 간선 $C \to B$를 <strong>반영하지 못한다.</strong> 음수 간선이 있으면 “더 멀리 돌아가는 게 더 싸질” 수 있는데, 그리디는 그 가능성을 닫아 버린다. 이런 경우엔 Bellman-Ford 같은 다른 알고리즘이 필요하다.</p>]]></content><author><name>이주한</name></author><category term="Algorithm" /><category term="greedy" /><category term="optimization" /><category term="minimum-spanning-tree" /><category term="prim" /><category term="kruskal" /><category term="dijkstra" /><category term="shortest-path" /><category term="priority-queue" /><category term="union-find" /><category term="optimal-substructure" /><summary type="html"><![CDATA[최적화 문제를 그리디로 푸는 관점에서 출발해 최소 신장 트리(Prim·Kruskal)와 단일 출발점 최단 경로(Dijkstra)를 다룬다. 우선순위 큐 구현에 따른 복잡도 차이, 최적 부분 구조, 음수 가중치에서 Dijkstra가 깨지는 이유까지 정리한다.]]></summary></entry><entry><title type="html">Graphs and Graph Traversals — 그래프 표현과 DFS, BFS, 강연결요소</title><link href="https://l2juhan.github.io/algorithm/2026/06/06/graphs-and-graph-traversals.html" rel="alternate" type="text/html" title="Graphs and Graph Traversals — 그래프 표현과 DFS, BFS, 강연결요소" /><published>2026-06-06T00:00:00+00:00</published><updated>2026-06-06T00:00:00+00:00</updated><id>https://l2juhan.github.io/algorithm/2026/06/06/graphs-and-graph-traversals</id><content type="html" xml:base="https://l2juhan.github.io/algorithm/2026/06/06/graphs-and-graph-traversals.html"><![CDATA[<p>항공 노선, 순서도, 컴퓨터 네트워크, “$x$는 $y$의 진약수다” 같은 이항 관계. 이들은 전부 <strong>개체와 개체 사이의 연결</strong>이라는 같은 구조를 가진다. 이 구조를 추상화한 것이 그래프(graph)다. 그래프 위에서 도는 대부분의 알고리즘은 결국 정점과 간선을 빠짐없이 한 번씩 훑는 일로 환원되고, 그 훑는 방식이 DFS와 BFS다. 이 글은 그래프의 정의와 표현에서 출발해 두 순회 전략, 그리고 그 응용으로 강연결요소(SCC)를 찾는 알고리즘까지 다룬다.</p>

<h2 id="그래프의-정의">그래프의 정의</h2>

<h3 id="방향-그래프directed-graph">방향 그래프(Directed Graph)</h3>

<p>방향 그래프, 줄여서 <strong>digraph</strong>는 쌍 $G = (V, E)$다.</p>

<ul>
  <li>$V$는 정점(vertex, 노드(node)라고도 한다)의 집합이다.</li>
  <li>$E$는 $V$ 원소들의 <strong>순서 있는 쌍(ordered pair)</strong> 의 집합이다. 즉 간선에 방향이 있다.</li>
</ul>

<p>$E$의 원소를 간선(edge), 방향 간선(directed edge), 또는 호(arc)라고 부른다. 방향 간선 $(v, w)$에서 $v$를 꼬리(tail), $w$를 머리(head)라 하고, 다이어그램에서는 화살표 $v \rightarrow w$로 그린다. 간단히 $vw$로도 쓴다. 표기는 (origin, destination), (start, end), (tail, head) 등 문맥에 따라 다양하게 쓰인다.</p>

<p>자기 자신으로 가는 간선 $(v, v)$를 <strong>자기 루프(self-loop)</strong> 라 하며, 방향 그래프에서는 허용된다.</p>

<p><strong>차수(degree).</strong> 정점 $v$에 대해 들어오는 간선 수를 진입 차수 $\text{indeg}(v)$, 나가는 간선 수를 진출 차수 $\text{outdeg}(v)$라 한다. 전체 차수는 둘의 합이다.</p>

\[\deg(v) = \text{indeg}(v) + \text{outdeg}(v)\]

<p>예를 들어 들어오는 간선 1개, 나가는 간선 3개인 정점은 $\text{indeg}(v) = 1$, $\text{outdeg}(v) = 3$, $\deg(v) = 4$다. 자기 루프는 차수를 2로 센다(들어오면서 동시에 나가므로).</p>

<h3 id="무방향-그래프undirected-graph">무방향 그래프(Undirected Graph)</h3>

<p>무방향 그래프도 쌍 $G = (V, E)$지만, $E$가 서로 다른 두 정점의 <strong>순서 없는 쌍(unordered pair)</strong> 의 집합이라는 점이 다르다. 각 간선은 정점 두 개를 원소로 갖는 부분집합으로 볼 수 있고, ${v, w}$로 표기한다. 다이어그램에서는 화살표 없는 선 $v - w$로 그린다.</p>

<p>순서가 없으므로 $vw$와 $wv$는 같은 간선이고, 중복 원소가 없어야 하므로 <strong>무방향 그래프에는 자기 루프가 없다.</strong></p>

<p>두 개념을 구분해 둘 필요가 있다.</p>

<ul>
  <li><strong>incident</strong>: 정점과 간선이 맞닿아 있는 관계. 간선 $vw$는 정점 $v$, $w$에 incident하다.</li>
  <li><strong>adjacent</strong>: 두 정점이 하나의 간선으로 연결된 관계. $v$와 $w$가 인접(adjacent)하다.</li>
</ul>

<h3 id="가중-그래프weighted-graph">가중 그래프(Weighted Graph)</h3>

<p>가중 그래프는 삼중쌍 $(V, E, W)$다. $(V, E)$는 (방향이든 무방향이든) 그래프이고, $W$는 $E$에서 실수 $\mathbb{R}$로 가는 함수다. 간선 $e$에 대해 $W(e)$를 $e$의 가중치(weight)라 한다. 항공 노선에서 도시 간 거리, 네트워크에서 링크 비용 등이 가중치로 들어간다.</p>

<h2 id="그래프-표현">그래프 표현</h2>

<table>
  <tbody>
    <tr>
      <td>$G = (V, E)$, $n =</td>
      <td>V</td>
      <td>$(정점 수), $m =</td>
      <td>E</td>
      <td>$(간선 수), $V = {v_1, v_2, \dots, v_n}$라 하자. 그래프를 메모리에 담는 표준적인 방법은 두 가지다.</td>
    </tr>
  </tbody>
</table>

<h3 id="인접-행렬adjacency-matrix">인접 행렬(Adjacency Matrix)</h3>

<p>$n \times n$ 행렬 $A$로 표현한다. 무방향 그래프에서는 $v_i$와 $v_j$가 인접하면 $A[i][j] = 1$, 아니면 $0$이다. 무방향이므로 행렬은 주대각선에 대해 <strong>대칭</strong>이다.</p>

<p>예를 들어 정점 7개짜리 무방향 그래프</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>1 — 2        간선: {1,2} {1,3} {2,3} {2,4}
|  X |              {3,4} {3,6} {4,6}
3 — 4              {5,6} {6,7}
5 — 6 — 7
</code></pre></div></div>

<p>의 인접 행렬은 다음과 같다.</p>

<table>
  <thead>
    <tr>
      <th> </th>
      <th>$v_1$</th>
      <th>$v_2$</th>
      <th>$v_3$</th>
      <th>$v_4$</th>
      <th>$v_5$</th>
      <th>$v_6$</th>
      <th>$v_7$</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>$v_1$</td>
      <td>0</td>
      <td>1</td>
      <td>1</td>
      <td>0</td>
      <td>0</td>
      <td>0</td>
      <td>0</td>
    </tr>
    <tr>
      <td>$v_2$</td>
      <td>1</td>
      <td>0</td>
      <td>1</td>
      <td>1</td>
      <td>0</td>
      <td>0</td>
      <td>0</td>
    </tr>
    <tr>
      <td>$v_3$</td>
      <td>1</td>
      <td>1</td>
      <td>0</td>
      <td>1</td>
      <td>0</td>
      <td>1</td>
      <td>0</td>
    </tr>
    <tr>
      <td>$v_4$</td>
      <td>0</td>
      <td>1</td>
      <td>1</td>
      <td>0</td>
      <td>0</td>
      <td>1</td>
      <td>0</td>
    </tr>
    <tr>
      <td>$v_5$</td>
      <td>0</td>
      <td>0</td>
      <td>0</td>
      <td>0</td>
      <td>0</td>
      <td>1</td>
      <td>0</td>
    </tr>
    <tr>
      <td>$v_6$</td>
      <td>0</td>
      <td>0</td>
      <td>1</td>
      <td>1</td>
      <td>1</td>
      <td>0</td>
      <td>1</td>
    </tr>
    <tr>
      <td>$v_7$</td>
      <td>0</td>
      <td>0</td>
      <td>0</td>
      <td>0</td>
      <td>0</td>
      <td>1</td>
      <td>0</td>
    </tr>
  </tbody>
</table>

<p>가중 방향 그래프라면 1/0 대신 가중치를 넣는다. 이때 자기 자신은 $0$, 간선이 없는 칸은 $\infty$로 둔다(최단 경로 계산 등에서 “도달 비용 무한”을 뜻한다). 방향 그래프이므로 더 이상 대칭이 아니고, $A[i][j]$는 $v_i$에서 $v_j$로 가는 <strong>나가는 간선(outgoing edge)</strong> 의 가중치를 의미한다.</p>

<h3 id="인접-리스트adjacency-list">인접 리스트(Adjacency List)</h3>

<p>정점마다 인접한 정점들의 연결 리스트를 두고, 그 리스트들을 배열로 묶는다. 위 무방향 그래프의 인접 리스트는 이렇게 된다.</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>adjVertices
1 → 2 → 3
2 → 1 → 3 → 4
3 → 1 → 2 → 4 → 6
4 → 2 → 3 → 6
5 → 6
6 → 3 → 4 → 5 → 7
7 → 6
</code></pre></div></div>

<p>가중 방향 그래프라면 리스트 노드에 (인접 정점, 가중치) 쌍을 저장하고, 방향 그래프이므로 <strong>나가는 간선만</strong> 리스트에 담는다.</p>

<h3 id="복잡도-비교">복잡도 비교</h3>

<p>두 표현은 연산별로 비용이 다르다. 무방향 그래프 기준으로 정리하면 다음과 같다.</p>

<table>
  <thead>
    <tr>
      <th>연산</th>
      <th>인접 행렬</th>
      <th>인접 리스트</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">v.incidentEdges()</code> (v에 붙은 간선 전부)</td>
      <td>$O(n)$</td>
      <td>$O(\deg(v))$</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">v.adjacentTo(w)</code> (v와 w가 인접한가)</td>
      <td>$O(1)$</td>
      <td>$O(\min(\deg(v), \deg(w)))$</td>
    </tr>
    <tr>
      <td>공간</td>
      <td>$O(n^2)$</td>
      <td>$O(n + m)$</td>
    </tr>
  </tbody>
</table>

<p>어느 쪽이 유리한지는 그래프의 <strong>밀도</strong>에 달려 있다.</p>

<ul>
  <li><strong>밀집(dense)</strong> 그래프: 간선 수 $m$이 $\Theta(n^2)$에 가까운 경우. 인접 행렬이 유리하다.</li>
  <li><strong>희소(sparse)</strong> 그래프: 간선 수 $m$이 $O(n)$에 가까운 경우. 인접 리스트가 유리하다.</li>
</ul>

<p>밀집/희소의 경계는 엄밀한 정의라기보다 주관적인 구분이다. 무방향 그래프의 연결성과 간선 수 사이에는 다음 관계가 있다.</p>

<ul>
  <li>연결(connected) 그래프: $m \geq n - 1$</li>
  <li>트리(tree): $m = n - 1$</li>
  <li>숲(forest): $m \leq n - 1$</li>
</ul>

<h2 id="더-많은-정의">더 많은 정의</h2>

<p>빈 그래프 $G = (\emptyset, \emptyset)$는 모든 그래프의 부분그래프다. 아래 정의는 단순 그래프(self-loop·중복 간선 없는 그래프)를 전제로 한다.</p>

<ul>
  <li><strong>부분그래프(subgraph)</strong>: $V’ \subseteq V$, $E’ \subseteq E$를 만족하는 $G’ = (V’, E’)$.</li>
  <li><strong>완전 그래프(complete graph)</strong>: 모든 정점 쌍 사이에 간선이 존재하는 그래프. 간선 수는 $m = \dfrac{n(n-1)}{2}$이고, $m \in O(n^2)$이므로 가장 밀집한 경우다.</li>
  <li><strong>인접 관계(adjacency relation)</strong>: 두 정점이 하나의 간선으로 연결된 관계.</li>
  <li>
    <table>
      <tbody>
        <tr>
          <td><strong>경로(path)</strong>: 정점들의 나열 $P = \langle v_1, v_2, \dots, v_k \rangle$이며, 인접한 정점 사이에 간선이 있어야 한다. 경로 길이 $</td>
          <td>P</td>
          <td>$는 간선의 수다. <strong>단순 경로(simple path)</strong> 는 정점이 모두 서로 다른 경로다. $v$에서 $w$로 가는 경로가 존재하면 $w$는 $v$로부터 <strong>도달 가능(reachable)</strong> 하다.</td>
        </tr>
      </tbody>
    </table>
  </li>
  <li><strong>연결(connected) / 강연결(strongly connected)</strong>: “어떤 조건을 만족해 그래프가 한 덩어리인가”를 나타낸다. connected는 무방향 그래프에, strongly connected는 방향 그래프에 쓰며, 모든 정점이 서로 도달 가능할 때 성립한다.</li>
  <li><strong>사이클(cycle) / 단순 사이클(simple cycle)</strong>: 시작과 끝이 같은 경로. 사이클이 없으면 <strong>비순환(acyclic)</strong> 이다.</li>
  <li><strong>숲·자유 트리·루트 트리</strong>: 무방향 숲(undirected forest), 자유 트리(free tree, 루트가 지정되지 않은 무방향 트리), 루트 트리(rooted tree).</li>
  <li><strong>연결 요소(connected component)</strong>: $G$의 극대(maximal) 연결 부분그래프. 더 이상 정점을 추가해 연결 상태를 유지할 수 없는 가장 큰 덩어리를 말한다.</li>
</ul>

<h2 id="그래프-순회">그래프 순회</h2>

<p>그래프 문제를 푸는 대부분의 알고리즘은 <strong>각 정점과 각 간선을 검사하거나 처리</strong>한다. 그 기반이 되는 두 순회 전략이 너비 우선 탐색(BFS)과 깊이 우선 탐색(DFS)이고, 둘 다 모든 정점과 간선을 정확히 한 번씩 방문하는 효율적인 방법을 제공한다. 그래프의 입력 크기가 $n + m$이고 순회도 정점과 간선을 한 번씩 보므로, 두 순회 모두 시간 복잡도는 다음과 같다.</p>

\[O(n + m)\]

<h2 id="깊이-우선-탐색dfs">깊이 우선 탐색(DFS)</h2>

<p>방향 그래프에서의 DFS 전략은 이렇다.</p>

<ol>
  <li>시작 정점을 고르고 거리 $d = 0$으로 둔다(예제에서는 key 값이 작은 쪽부터 접근한다고 가정).</li>
  <li>거리 $d$의 정점에서 나가는 간선 하나를 따라 인접한 거리 $d+1$ 정점으로 간다.</li>
  <li>다시 거리 $d+1$ 정점에서 간선 하나를 따라 $d+2$로 가고, 이를 반복한다.</li>
  <li>새 정점이 더 이상 없거나 막다른 길(dead end, 더 갈 곳 없는 finished 정점)에 도달하면 한 단계 <strong>백트랙(backtrack)</strong> 해서 다른 간선을 시도한다.</li>
  <li>최종적으로 시작 정점까지 백트랙하고 더 발견할 정점이 없으면 끝난다.</li>
</ol>

<p>핵심은 “갈 수 있는 데까지 깊이 들어갔다가 막히면 되돌아온다”는 것이다.</p>

<h3 id="정점과-간선의-상태">정점과 간선의 상태</h3>

<p>DFS 진행 중 정점은 세 가지 상태(색)를 가진다.</p>

<ul>
  <li><strong>unexplored / white</strong>: 아직 발견 전.</li>
  <li><strong>visited / grey</strong>: 발견됐고 아직 처리 중(자손을 탐색 중).</li>
  <li><strong>finished / black</strong>: 자손까지 모두 탐색을 마침.</li>
</ul>

<p>간선도 상태를 가진다. 아직 안 본 간선은 unexplored, 처음 따라가 새 정점을 발견하면 <strong>discovery</strong> 간선, 따라갔더니 도착 정점이 이미 visited 상태면 <strong>back</strong> 간선이다.</p>

<h3 id="dfs-트리와-간선-분류">DFS 트리와 간선 분류</h3>

<p>DFS가 따라간 discovery 간선들만 모으면 <strong>DFS 트리</strong>가 된다. 방향 그래프의 모든 간선은 이 트리를 기준으로 네 종류로 분류된다.</p>

<table>
  <thead>
    <tr>
      <th>분류</th>
      <th>의미</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><strong>tree edge</strong></td>
      <td>DFS 트리를 이루는 discovery(=forward) 간선</td>
    </tr>
    <tr>
      <td><strong>back edge</strong></td>
      <td>트리 상의 조상(ancestor)을 향하는 간선</td>
    </tr>
    <tr>
      <td><strong>descendant edge</strong></td>
      <td>트리 상 후손(descendant)이면서 이미 처리된 정점을 향하는 간선</td>
    </tr>
    <tr>
      <td><strong>cross edge</strong></td>
      <td>조상도 후손도 아닌(즉 sibling 등) 정점을 향하는 간선</td>
    </tr>
  </tbody>
</table>

<p>예를 들어 시작 정점 $A$에서 DFS를 돌리면 방문 순서와 종료 순서가 각각 따로 기록된다. 어떤 그래프에서 방문(visited) 순서가 $A, B, C, D, F, E, G$이고 종료(finished) 순서가 $C, D, B, F, A, G, E$ 식으로 나오는데, 두 순서가 다르다는 점이 중요하다. 종료 순서는 뒤에서 강연결요소 알고리즘의 핵심 재료로 쓰인다.</p>

<h2 id="너비-우선-탐색bfs">너비 우선 탐색(BFS)</h2>

<p>방향 그래프에서의 BFS 전략은 DFS와 거의 같은 문장으로 시작하지만 결정적으로 다르다.</p>

<ol>
  <li>시작 정점을 고르고 $d = 0$으로 둔다.</li>
  <li>거리 $d$ 정점들에서 나가는 <strong>모든</strong> 간선을 검사해 인접한 거리 $d+1$ 정점을 전부 발견한다.</li>
  <li>그다음 거리 $d+1$ 정점들에서 나가는 모든 간선을 검사해 $d+2$ 정점을 발견하고, 이를 반복한다.</li>
  <li>새 정점이 더 이상 없으면 끝난다.</li>
</ol>

<p>DFS가 “한 간선만 따라 깊이” 들어갔다면, BFS는 “한 거리의 정점을 전부” 처리하고 다음 거리로 넘어간다. 즉 시작 정점으로부터 <strong>거리(레벨) 순</strong>으로 정점을 방문한다. 이 동작을 구현하려면 먼저 발견한 정점을 먼저 처리하는 <strong>큐(queue)</strong> 가 필요하다.</p>

<p>거리 순으로 방문하기 때문에 BFS는 가중치 없는 그래프에서 <strong>최단 경로(shortest path)</strong> 를 계산할 수 있다. 각 정점에 자신을 발견한 정점(predecessor/parent)을 기록해 두면, 시작점에서 임의 정점까지의 최단 경로를 역추적할 수 있다. DFS와 달리 BFS는 finished 상태를 따로 표시할 필요가 없다.</p>

<p>예를 들어 정점 $A$에서 BFS를 시작하면 $d = 1$에서 $B, C, F$를 방문하고 $d = 2$에서 $D$를 방문하는 식으로, 큐에 <code class="language-plaintext highlighter-rouge">A | B C F | D</code> 처럼 거리 경계가 구분되어 쌓인다. predecessor 배열을 함께 채우면 그것이 곧 BFS 트리다.</p>

<h2 id="강연결요소strongly-connected-components">강연결요소(Strongly Connected Components)</h2>

<p>방향 그래프 $G$가 <strong>강연결(strongly connected)</strong> 이라는 것은, 임의의 두 정점 $v$, $w$에 대해 $v$에서 $w$로 가는 경로가 존재한다는 뜻이다. <strong>강연결요소(SCC)</strong> 는 $G$의 극대 강연결 부분그래프다. 방향 그래프를 SCC 단위로 쪼개면 각 덩어리 안에서는 모든 정점이 서로 오갈 수 있다.</p>

<h3 id="알고리즘--두-번의-dfs">알고리즘 — 두 번의 DFS</h3>

<p>인접 리스트로 표현된 그래프에서 SCC는 <strong>DFS를 두 번</strong> 돌려 찾는다(코사라주(Kosaraju) 알고리즘).</p>

<p><strong>Phase 1.</strong> $G$에 대해 표준 DFS를 수행하면서, 정점이 finished될 때마다 스택에 넣는다. 이 단계는 finished 스택을 만드는 일이고 $O(n)$ 수준이다. 끝나면 스택의 top에는 가장 늦게 끝난 정점이 온다.</p>

<p><strong>Phase 2.</strong> <strong>전치 그래프(transpose graph)</strong> $G^T$에 대해 DFS를 수행한다. $G^T$는 원래 그래프의 모든 간선 방향을 뒤집은 그래프다. 탐색을 시작할 정점은 Phase 1에서 만든 스택을 pop해서 정한다. 한 번의 DFS로 도달되는 정점들이 하나의 SCC를 이루며, 그 SCC는 탐색을 시작한 정점의 이름으로 식별한다. 이 시작 정점을 <strong>리더(leader)</strong> 라 부른다.</p>

<p>전체 시간은 두 번의 DFS이므로 $O(n + m)$이다.</p>

<h3 id="왜-두-단계로-나누는가">왜 두 단계로 나누는가</h3>

<p>직관은 이렇다. Phase 1의 종료 순서(finished 스택)는 “가장 바깥 SCC(다른 SCC로 나가기만 하는 덩어리)”의 정점을 스택 위쪽에 모아 준다. 간선을 뒤집은 $G^T$에서 그 정점부터 탐색을 시작하면, 원래 그래프에서 나가기만 하던 방향이 막혀서 <strong>자기 SCC 안에서만</strong> 돌게 된다. 그 결과 한 번의 DFS가 정확히 하나의 SCC만 긁어낸다. 스택을 pop하며 이를 반복하면 그래프 전체가 SCC 단위로 분해된다.</p>

<p>예를 들어 정점 $A$~$G$짜리 그래프에서 Phase 1의 finished 스택이 (top에서 bottom 순으로) $E, G, A, F, B, D, C$로 쌓였다면, $G^T$에서 $E$부터 pop해 가며 DFS를 돌려 ${A, B, D, F}$, ${C}$, ${E, G}$ 같은 SCC들로 나뉘는 식이다. 각 정점에는 자신이 속한 SCC의 리더가 기록된다.</p>]]></content><author><name>이주한</name></author><category term="Algorithm" /><category term="graph" /><category term="directed-graph" /><category term="undirected-graph" /><category term="adjacency-matrix" /><category term="adjacency-list" /><category term="dfs" /><category term="bfs" /><category term="edge-classification" /><category term="strongly-connected-component" /><category term="kosaraju" /><summary type="html"><![CDATA[그래프의 정의와 두 가지 표현(인접 행렬·인접 리스트), 복잡도 비교에서 출발해 DFS와 BFS 순회 전략, DFS 트리의 간선 분류, 그리고 두 번의 DFS로 강연결요소를 찾는 알고리즘까지 정리한다.]]></summary></entry><entry><title type="html">Transitive Closure and All-Pairs Shortest Paths — 도달 가능성과 모든 쌍 최단 경로, 그리고 Floyd-Warshall</title><link href="https://l2juhan.github.io/algorithm/2026/06/06/transitive-closure-and-all-pairs-shortest-paths.html" rel="alternate" type="text/html" title="Transitive Closure and All-Pairs Shortest Paths — 도달 가능성과 모든 쌍 최단 경로, 그리고 Floyd-Warshall" /><published>2026-06-06T00:00:00+00:00</published><updated>2026-06-06T00:00:00+00:00</updated><id>https://l2juhan.github.io/algorithm/2026/06/06/transitive-closure-and-all-pairs-shortest-paths</id><content type="html" xml:base="https://l2juhan.github.io/algorithm/2026/06/06/transitive-closure-and-all-pairs-shortest-paths.html"><![CDATA[<p>방향 그래프에서 “$u$에서 $v$로 갈 수 있는가”라는 질문은 단일 경로 탐색으로 매번 풀 수도 있다. 하지만 모든 정점 쌍에 대해 이 질문을 반복적으로 던져야 한다면, 도달 가능성 정보를 미리 한 번에 계산해 두는 편이 낫다. 이 발상이 추이적 폐쇄(Transitive Closure)이고, 여기에 간선 가중치를 더하면 모든 쌍 최단 경로(All-Pairs Shortest Paths) 문제가 된다. 두 문제는 놀랍도록 비슷한 구조를 가지며, 같은 동적 계획법 틀로 풀린다.</p>

<h2 id="추이적-폐쇄">추이적 폐쇄</h2>

<p>방향 그래프(digraph) $G$가 주어졌을 때, $G$의 <strong>추이적 폐쇄</strong>는 다음 조건을 만족하는 방향 그래프 $G^*$이다.</p>

<ul>
  <li>$G^*$는 $G$와 <strong>같은 정점 집합</strong>을 가진다.</li>
  <li>$G$에 $u$에서 $v$로 가는 방향 경로(directed path)가 존재하면($u \neq v$), $G^*$에는 $u$에서 $v$로 가는 <strong>방향 간선</strong>(directed edge)이 존재한다.</li>
</ul>

<p>즉 원래 그래프에서 여러 간선을 거쳐야 닿을 수 있던 정점을, $G^<em>$에서는 간선 하나로 바로 연결해 버린다. $A \to C$와 $C \to D$가 있으면 $A \to D$ 간선을 새로 만드는 식이다. 결과적으로 $G^</em>$는 그래프의 <strong>도달 가능성(reachability)</strong> 정보를 그대로 담는다. $G^*$에 $u \to v$ 간선이 있다는 것은 곧 $G$에서 $u$가 $v$에 도달할 수 있다는 뜻이다.</p>

<p>예를 들어 정점 ${A, B, C, D, E}$에 대해 $G$가 다음 간선을 가진다고 하자.</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>B → D    D → E    A → C    C → D
</code></pre></div></div>

<p>여기서 $A$는 $C \to D \to E$를 거쳐 $E$까지 닿는다. $B$는 $D \to E$로 $E$에 닿는다. 이런 모든 도달 관계를 직접 간선으로 추가한 것이 $G^*$다. $A \to D$, $A \to E$, $B \to E$, $C \to E$ 같은 간선이 새로 생긴다.</p>

<h3 id="단순한-방법-정점마다-dfs">단순한 방법: 정점마다 DFS</h3>

<p>추이적 폐쇄를 구하는 가장 직관적인 방법은 각 정점에서 한 번씩 DFS(또는 BFS)를 돌리는 것이다. 정점 $u$에서 DFS를 시작해 도달하는 모든 정점 $v$에 대해 $u \to v$ 간선을 추가하면 된다.</p>

<p>정점 하나당 DFS는 $O(n + m)$이고, 이를 $n$개의 정점에 대해 반복하므로 전체 복잡도는 다음과 같다.</p>

\[O(n(n + m))\]

<p>여기서 $n$은 정점 수, $m$은 간선 수다. 밀집 그래프에서 $m = O(n^2)$이면 $O(n^3)$이 된다.</p>

<h2 id="floyd-warshall-알고리즘">Floyd-Warshall 알고리즘</h2>

<p>DFS를 정점마다 반복하는 대신, 동적 계획법(Dynamic Programming)으로 추이적 폐쇄를 구하는 것이 <strong>Floyd-Warshall 알고리즘</strong>이다. 핵심 아이디어 두 가지로 정리된다.</p>

<ul>
  <li><strong>아이디어 1</strong>: 정점에 $1, 2, \dots, n$으로 번호를 매긴다.</li>
  <li><strong>아이디어 2</strong>: 중간 정점(intermediate vertex)으로 $1, 2, \dots, k$번 정점만 사용하는 경로를 단계적으로 고려한다.</li>
</ul>

<p>여기서 “중간 정점”이란 경로의 시작점과 끝점을 제외한, 경로가 거쳐 가는 정점을 말한다. 핵심 점화 관계는 이렇다. 정점 $i$에서 $j$로 가는 경로가 중간 정점으로 $1, \dots, k$만 사용한다고 하자. 이 경로는 두 경우 중 하나다.</p>

<ol>
  <li>$k$번 정점을 거치지 <strong>않는다</strong> → 중간 정점으로 $1, \dots, k-1$만 쓰는 $i \to j$ 경로가 이미 존재한다.</li>
  <li>$k$번 정점을 거<strong>친다</strong> → $i \to k$ 경로와 $k \to j$ 경로가 각각 중간 정점으로 $1, \dots, k-1$만 써서 존재한다.</li>
</ol>

<p>따라서 “$i \to k$가 가능하고 $k \to j$가 가능하면, $i \to j$ 간선을 (없을 경우) 추가한다”는 갱신을 $k$를 $1$부터 $n$까지 늘려가며 반복하면 된다.</p>

<h3 id="일련의-그래프-g_0-dots-g_n">일련의 그래프 $G_0, \dots, G_n$</h3>

<p>알고리즘은 정점을 $v_1, \dots, v_n$으로 번호 매기고, 그래프 수열 $G_0, G_1, \dots, G_n$을 계산한다.</p>

<ul>
  <li>$G_0 = G$ (원래 그래프, 중간 정점을 하나도 안 쓰는 직접 간선만)</li>
  <li>$G_k$는 $G$에 $v_i$에서 $v_j$로 가는 경로가 <strong>중간 정점을 ${v_1, \dots, v_k}$ 안에서만</strong> 거쳐 존재하면 방향 간선 $(v_i, v_j)$를 가진다.</li>
  <li>최종적으로 $G_n = G^*$ (모든 정점을 중간 정점으로 허용 = 모든 도달 관계)</li>
</ul>

<p>단계 $k$에서 $G_k$는 직전 단계 $G_{k-1}$로부터 계산된다.</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Algorithm FloydWarshall(G)
    Input  digraph G
    Output transitive closure G* of G

    i ← 1
    for all v ∈ G.vertices()
        denote v as v_i
        i ← i + 1
    G_0 ← G
    for k ← 1 to n do
        G_k ← G_{k-1}
        for i ← 1 to n (i ≠ k) do
            for j ← 1 to n (j ≠ i, k) do
                if G_{k-1}.areAdjacent(v_i, v_k) ∧ G_{k-1}.areAdjacent(v_k, v_j)
                    if ¬G_k.areAdjacent(v_i, v_j)
                        G_k.insertDirectedEdge(v_i, v_j, k)
    return G_n
</code></pre></div></div>

<h3 id="복잡도">복잡도</h3>

<p>세 겹의 반복문이 각각 $n$번 도므로 시간 복잡도는 다음과 같다.</p>

\[O(n^3) = (\text{전체 정점 수}) \times (\text{행 탐색}) \times (\text{열 탐색})\]

<p>단, 이 분석은 <code class="language-plaintext highlighter-rouge">areAdjacent</code> 연산이 $O(1)$이라는 가정에 의존한다. 두 정점의 인접 여부를 상수 시간에 확인하려면 그래프를 <strong>인접 행렬(adjacency matrix)</strong>로 표현해야 한다. 인접 리스트로 표현하면 인접 확인에 추가 비용이 들어 이 복잡도가 깨진다. 공간 복잡도는 인접 행렬 크기인 $O(n^2)$이다.</p>

<h2 id="floyd-warshall-동작-예시">Floyd-Warshall 동작 예시</h2>

<p>정점 7개짜리 항공 노선 그래프로 알고리즘이 어떻게 행렬을 채워 나가는지 본다. 정점 번호는 다음과 같이 매긴다.</p>

<table>
  <thead>
    <tr>
      <th>번호</th>
      <th>$v_1$</th>
      <th>$v_2$</th>
      <th>$v_3$</th>
      <th>$v_4$</th>
      <th>$v_5$</th>
      <th>$v_6$</th>
      <th>$v_7$</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>공항</td>
      <td>LAX</td>
      <td>SFO</td>
      <td>DFW</td>
      <td>ORD</td>
      <td>MIA</td>
      <td>JFK</td>
      <td>BOS</td>
    </tr>
  </tbody>
</table>

<p>초기 인접 행렬 $G_0$는 직접 연결된 간선만 $1$로 표시한다. 행이 출발, 열이 도착이다.</p>

<table>
  <thead>
    <tr>
      <th> </th>
      <th>1</th>
      <th>2</th>
      <th>3</th>
      <th>4</th>
      <th>5</th>
      <th>6</th>
      <th>7</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><strong>1</strong></td>
      <td>0</td>
      <td>0</td>
      <td>0</td>
      <td>1</td>
      <td>0</td>
      <td>0</td>
      <td>0</td>
    </tr>
    <tr>
      <td><strong>2</strong></td>
      <td>0</td>
      <td>0</td>
      <td>0</td>
      <td>0</td>
      <td>0</td>
      <td>0</td>
      <td>0</td>
    </tr>
    <tr>
      <td><strong>3</strong></td>
      <td>1</td>
      <td>1</td>
      <td>0</td>
      <td>1</td>
      <td>0</td>
      <td>0</td>
      <td>0</td>
    </tr>
    <tr>
      <td><strong>4</strong></td>
      <td>0</td>
      <td>0</td>
      <td>1</td>
      <td>0</td>
      <td>0</td>
      <td>0</td>
      <td>0</td>
    </tr>
    <tr>
      <td><strong>5</strong></td>
      <td>1</td>
      <td>0</td>
      <td>1</td>
      <td>0</td>
      <td>0</td>
      <td>0</td>
      <td>0</td>
    </tr>
    <tr>
      <td><strong>6</strong></td>
      <td>0</td>
      <td>1</td>
      <td>1</td>
      <td>0</td>
      <td>1</td>
      <td>0</td>
      <td>1</td>
    </tr>
    <tr>
      <td><strong>7</strong></td>
      <td>0</td>
      <td>0</td>
      <td>0</td>
      <td>0</td>
      <td>1</td>
      <td>1</td>
      <td>0</td>
    </tr>
  </tbody>
</table>

<p>각 반복 $k$는 “$k$번 정점을 중간 경유지로 허용했을 때 새로 생기는 도달 관계”를 행렬에 채운다. 자기 자신으로의 경로(self-loop)는 고려하지 않으므로 대각선은 그대로 둔다.</p>

<ul>
  <li><strong>Iteration 1</strong> ($k=1$, LAX 경유 허용): $i \to 1$이고 $1 \to j$인 쌍을 찾는다. $1 \to 4$(LAX→ORD)가 있으므로, $5 \to 1$(MIA→LAX)을 가진 정점 5는 $5 \to 4$(MIA→ORD)에 새로 도달한다.</li>
  <li><strong>Iteration 2</strong> ($k=2$, SFO 경유): SFO는 나가는 간선이 없다($v_2$ 행이 전부 $0$). 따라서 $2 \to j$가 하나도 없어 새 간선이 추가되지 않는다.</li>
  <li><strong>Iteration 3</strong> ($k=3$, DFW 경유): DFW는 LAX, SFO, ORD로 나간다. DFW에 도달하던 정점들(ORD, MIA, JFK 등)이 DFW를 거쳐 LAX·SFO·ORD로 무더기로 연결된다. 이 단계에서 간선이 가장 많이 추가된다.</li>
  <li><strong>Iteration 4</strong> ($k=4$, ORD 경유): ORD는 DFW로 나가므로, ORD에 도달하던 정점들이 DFW에 연결된다. $1 \to 2$, $1 \to 3$ 등이 채워진다.</li>
  <li><strong>Iteration 5</strong> ($k=5$, MIA 경유): MIA를 거쳐 BOS가 ORD·SFO·DFW·LAX로 연결된다.</li>
  <li><strong>Iteration 6</strong> ($k=6$, JFK 경유): JFK를 경유하는 도달 관계가 마저 채워진다.</li>
</ul>

<p>$k=7$(BOS)까지 마치면 $G_7 = G^*$가 완성된다. 최종 행렬은 각 정점에서 도달 가능한 모든 정점이 $1$로 채워진 형태가 된다.</p>

<table>
  <thead>
    <tr>
      <th> </th>
      <th>1</th>
      <th>2</th>
      <th>3</th>
      <th>4</th>
      <th>5</th>
      <th>6</th>
      <th>7</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><strong>1</strong></td>
      <td>0</td>
      <td>1</td>
      <td>1</td>
      <td>1</td>
      <td>0</td>
      <td>0</td>
      <td>0</td>
    </tr>
    <tr>
      <td><strong>2</strong></td>
      <td>0</td>
      <td>0</td>
      <td>0</td>
      <td>0</td>
      <td>0</td>
      <td>0</td>
      <td>0</td>
    </tr>
    <tr>
      <td><strong>3</strong></td>
      <td>1</td>
      <td>1</td>
      <td>0</td>
      <td>1</td>
      <td>0</td>
      <td>0</td>
      <td>0</td>
    </tr>
    <tr>
      <td><strong>4</strong></td>
      <td>1</td>
      <td>1</td>
      <td>1</td>
      <td>0</td>
      <td>0</td>
      <td>0</td>
      <td>0</td>
    </tr>
    <tr>
      <td><strong>5</strong></td>
      <td>1</td>
      <td>1</td>
      <td>1</td>
      <td>1</td>
      <td>0</td>
      <td>0</td>
      <td>0</td>
    </tr>
    <tr>
      <td><strong>6</strong></td>
      <td>1</td>
      <td>1</td>
      <td>1</td>
      <td>1</td>
      <td>1</td>
      <td>0</td>
      <td>1</td>
    </tr>
    <tr>
      <td><strong>7</strong></td>
      <td>0</td>
      <td>0</td>
      <td>0</td>
      <td>0</td>
      <td>1</td>
      <td>1</td>
      <td>0</td>
    </tr>
  </tbody>
</table>

<p>SFO($v_2$) 행이 끝까지 전부 $0$인 점에 주목하자. SFO는 나가는 간선이 없으니 어디에도 도달하지 못하고, 추이적 폐쇄에서도 마찬가지다.</p>

<h2 id="모든-쌍-최단-경로">모든 쌍 최단 경로</h2>

<p>추이적 폐쇄가 “도달할 수 있는가(yes/no)”를 물었다면, <strong>모든 쌍 최단 경로(All-Pairs Shortest Paths)</strong>는 한 걸음 더 나아가 “최소 비용이 얼마인가”를 묻는다. 가중 방향 그래프(weighted directed graph) $G$에서 <strong>모든 정점 쌍</strong> 사이의 최단 거리를 구하는 문제다.</p>

<h3 id="방법-1-dijkstra를-n번">방법 1: Dijkstra를 $n$번</h3>

<p>음수 간선이 없다면, 각 정점을 출발점으로 삼아 Dijkstra 알고리즘을 $n$번 돌리면 된다. Dijkstra 한 번이 우선순위 큐(힙) 구현 기준 $O(m \log n)$이므로 전체는 다음과 같다.</p>

\[O(nm \log n)\]

<p>희소 그래프(sparse graph)에서는 이 방법이 유리하다.</p>

<h3 id="방법-2-동적-계획법으로-on3">방법 2: 동적 계획법으로 $O(n^3)$</h3>

<p>Floyd-Warshall과 같은 동적 계획법 틀을 쓰면 $O(n^3)$에 풀 수 있다. 추이적 폐쇄에서 “도달 가능 여부”를 갱신하던 자리에, 여기서는 “최단 거리”를 갱신한다.</p>

<p>$D_k[i, j]$를 “중간 정점으로 $1, \dots, k$번만 사용했을 때 $i$에서 $j$까지의 최단 거리”로 정의한다. 점화식은 다음과 같다.</p>

\[D_k[i, j] = \min\{\, D_{k-1}[i, j],\ \ D_{k-1}[i, k] + D_{k-1}[k, j] \,\}\]

<p>직관은 추이적 폐쇄와 똑같다. $i \to j$ 최단 경로가 $k$번 정점을 거치지 않으면 첫 번째 항이고, 거치면 $i \to k$ 최단 거리와 $k \to j$ 최단 거리의 합인 두 번째 항이다. 둘 중 작은 값을 취한다. 추이적 폐쇄의 논리 OR(<code class="language-plaintext highlighter-rouge">∧</code>로 연결해 간선을 추가하던 것)가 여기서는 덧셈과 <code class="language-plaintext highlighter-rouge">min</code>으로 바뀐 셈이다.</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Algorithm AllPair(G)   {정점이 1, …, n으로 번호 매겨져 있다고 가정}
    for all vertex pairs (i, j)
        if i = j
            D_0[i, i] ← 0
        else if (i, j) is an edge in G
            D_0[i, j] ← weight of edge (i, j)
        else
            D_0[i, j] ← +∞
    for k ← 1 to n do
        for i ← 1 to n do
            for j ← 1 to n do
                D_k[i, j] ← min{ D_{k-1}[i, j], D_{k-1}[i, k] + D_{k-1}[k, j] }
    return D_n
</code></pre></div></div>

<p>초기화 $D_0$가 세 갈래라는 점이 핵심이다. 자기 자신까지의 거리는 $0$, 직접 간선이 있으면 그 가중치, 둘 다 아니면 $+\infty$(도달 불가)로 둔다. 이후 세 겹 반복문이 $k$를 늘려가며 거리를 줄여 나간다.</p>

<p>구현 최적화로, 갱신 시 $D_{k-1}[i, k]$가 $0$이거나 $+\infty$이면 그 $i$에 대해서는 어떤 $j$로도 거리가 줄어들 여지가 없으므로 안쪽 루프를 건너뛸 수 있다.</p>

<h3 id="복잡도-1">복잡도</h3>

<p>세 겹 반복문이므로 시간 복잡도는 $O(n^3)$, 거리 행렬을 저장하므로 공간 복잡도는 $O(n^2)$이다. 밀집 그래프에서는 Dijkstra를 $n$번 돌리는 $O(nm \log n) = O(n^3 \log n)$보다 빠르고, 무엇보다 구현이 단순하다.</p>

<h2 id="두-문제의-공통-구조">두 문제의 공통 구조</h2>

<p>추이적 폐쇄와 모든 쌍 최단 경로는 같은 동적 계획법 골격을 공유한다. 차이는 “무엇을 갱신하는가”뿐이다.</p>

<table>
  <thead>
    <tr>
      <th>구분</th>
      <th>추이적 폐쇄</th>
      <th>모든 쌍 최단 경로</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>상태</td>
      <td>도달 가능 여부 (0/1)</td>
      <td>최단 거리 (수치)</td>
    </tr>
    <tr>
      <td>초기값</td>
      <td>직접 간선이면 1, 아니면 0</td>
      <td>직접 간선이면 가중치, 아니면 $+\infty$</td>
    </tr>
    <tr>
      <td>갱신 연산</td>
      <td>$(i{\to}k) \land (k{\to}j)$이면 $i{\to}j$ 추가</td>
      <td>$D[i,j] \leftarrow \min(D[i,j],\, D[i,k] + D[k,j])$</td>
    </tr>
    <tr>
      <td>결합 의미</td>
      <td>논리 AND / OR</td>
      <td>덧셈 / 최소값</td>
    </tr>
    <tr>
      <td>시간</td>
      <td>$O(n^3)$</td>
      <td>$O(n^3)$</td>
    </tr>
    <tr>
      <td>공간</td>
      <td>$O(n^2)$</td>
      <td>$O(n^2)$</td>
    </tr>
  </tbody>
</table>

<p>이렇게 “중간 정점을 $1$부터 $n$까지 한 단계씩 허용하며 정보를 누적한다”는 발상이 두 문제를 관통한다. 같은 틀에 어떤 반환값(boolean이냐, 거리냐)과 어떤 결합 연산(AND/OR이냐, +/min이냐)을 끼우느냐에 따라 서로 다른 문제가 풀리는 것이다.</p>]]></content><author><name>이주한</name></author><category term="Algorithm" /><category term="transitive-closure" /><category term="reachability" /><category term="floyd-warshall" /><category term="all-pairs-shortest-path" /><category term="dynamic-programming" /><category term="dijkstra" /><category term="adjacency-matrix" /><category term="digraph" /><summary type="html"><![CDATA[방향 그래프에서 어떤 정점이 어떤 정점에 도달할 수 있는지를 묻는 추이적 폐쇄에서 출발해, 가중 그래프의 모든 정점 쌍 사이 최단 거리를 구하는 문제까지 다룬다. 두 문제를 같은 동적 계획법 틀(Floyd-Warshall)로 푸는 방법과 $O(n^3)$ 복잡도의 근거를 정리한다.]]></summary></entry><entry><title type="html">IP Routing — 라우팅 알고리즘, Link State와 Distance Vector, AS와 실제 네트워크</title><link href="https://l2juhan.github.io/computer-network/2026/06/05/ip-routing.html" rel="alternate" type="text/html" title="IP Routing — 라우팅 알고리즘, Link State와 Distance Vector, AS와 실제 네트워크" /><published>2026-06-05T00:00:00+00:00</published><updated>2026-06-05T00:00:00+00:00</updated><id>https://l2juhan.github.io/computer-network/2026/06/05/ip-routing</id><content type="html" xml:base="https://l2juhan.github.io/computer-network/2026/06/05/ip-routing.html"><![CDATA[<p>데이터그램이 목적지로 가려면 각 라우터가 “다음에 어느 링크로 내보낼지”를 알아야 한다. 이 forwarding table을 채우는 일이 <strong>라우팅(routing)</strong>이다. forwarding이 패킷 하나를 테이블 보고 내보내는 data plane의 동작이라면, 라우팅은 그 테이블 자체를 계산하는 <strong>control plane</strong>의 동작이다. 이 글은 라우팅 알고리즘이 어떻게 동작하는지, 그리고 그것이 실제 인터넷에서 어떻게 조직되는지를 다룬다.</p>

<h2 id="라우팅을-그래프-문제로-추상화한다">라우팅을 그래프 문제로 추상화한다</h2>

<p>라우팅 프로토콜의 목표는 보내는 호스트에서 받는 호스트까지 라우터들을 거치는 <strong>좋은(good) 경로</strong>를 찾는 것이다. 여기서 경로(path)는 패킷이 통과하는 라우터의 시퀀스고, “좋다”는 것은 비용(cost)이 가장 작거나, 가장 빠르거나, 가장 덜 혼잡한 경로를 뜻한다.</p>

<p>이 문제는 그래프로 깔끔하게 추상화된다. 네트워크를 그래프 $G = (N, E)$로 본다. $N$은 라우터의 집합, $E$는 라우터를 잇는 링크의 집합이다. 각 링크 $(a, b)$에는 비용 $c_{a,b}$가 붙는다. 직접 연결되지 않은 두 노드 사이의 비용은 $c_{a,b} = \infty$로 둔다.</p>

<p><img src="/assets/images/posts/ip-routing/ip-routing-1.png" alt="라우팅의 그래프 추상화 — 라우터는 노드, 링크는 간선, 각 링크에 비용이 붙는다" /></p>

<p>비용은 네트워크 운영자가 정한다. 모든 링크를 1로 두면 경로의 비용은 곧 hop 수가 되고, 대역폭에 반비례하게 두면 빠른 링크를 선호하게 되고, 혼잡도에 반비례하게 두면 덜 막히는 경로를 선호하게 된다. 어떤 정책을 쓰든, 최소 비용 경로를 찾는 문제로 환원된다.</p>

<h2 id="라우팅-알고리즘의-분류">라우팅 알고리즘의 분류</h2>

<p>라우팅 알고리즘은 두 축으로 나뉜다.</p>

<p>첫째, 정보를 <strong>얼마나 전역적으로</strong> 쓰느냐다.</p>

<ul>
  <li><strong>global(전역) 알고리즘</strong>: 모든 라우터가 전체 토폴로지와 모든 링크 비용을 안다. 이 정보로 최소 비용 경로를 직접 계산한다. <strong>link-state(LS)</strong> 알고리즘이 여기 속한다.</li>
  <li><strong>decentralized(분산) 알고리즘</strong>: 각 라우터는 처음에 자기에게 직접 연결된 이웃까지의 비용만 안다. 이웃과 정보를 주고받는 반복(iterative) 과정을 거쳐 점점 경로를 알아간다. <strong>distance-vector(DV)</strong> 알고리즘이 여기 속한다.</li>
</ul>

<p>둘째, 경로가 <strong>얼마나 빨리</strong> 바뀌느냐다.</p>

<ul>
  <li><strong>static(정적)</strong>: 경로가 거의 바뀌지 않는다. 사람이 손으로 설정하는 수준.</li>
  <li><strong>dynamic(동적)</strong>: 링크 비용 변화에 반응하거나 주기적으로 경로를 갱신한다.</li>
</ul>

<p>실제 인터넷 라우팅 프로토콜은 대부분 dynamic하며, LS 또는 DV 중 하나에 기반한다.</p>

<h2 id="확장성-문제-왜-평평한-라우팅은-안-되는가">확장성 문제: 왜 평평한 라우팅은 안 되는가</h2>

<p>지금까지의 설명은 이상화된 가정 위에 있다. 모든 라우터가 동일하고, 네트워크가 하나의 평평한(flat) 구조라는 가정이다. 현실은 다르다. 두 가지 문제가 발생한다.</p>

<p><strong>규모(scale).</strong> 인터넷에는 수십억 개의 목적지가 있다. 이 모두를 라우팅 테이블에 담을 수 없고, 담는다 해도 라우팅 정보를 교환하는 것만으로 링크가 마비된다.</p>

<p><strong>관리 자율성(administrative autonomy).</strong> 인터넷은 “네트워크들의 네트워크”다. 각 네트워크 관리자는 자기 네트워크 안의 라우팅을 스스로 통제하고 싶어 한다.</p>

<p>해법은 라우터를 <strong>autonomous system(AS, 자율 시스템)</strong>, 다른 말로 <strong>도메인(domain)</strong>이라는 영역으로 묶는 것이다. 라우팅을 두 층위로 나눈다.</p>

<ul>
  <li><strong>intra-AS(intra-domain) 라우팅</strong>: 같은 AS 안의 라우터들 사이의 라우팅. 한 AS 안의 모든 라우터는 동일한 intra-domain 프로토콜을 돌려야 한다. 단, AS가 다르면 서로 다른 프로토콜을 써도 된다.</li>
  <li><strong>inter-AS(inter-domain) 라우팅</strong>: AS와 AS 사이의 라우팅. 자기 AS의 가장자리(edge)에서 다른 AS의 라우터와 링크를 갖는 <strong>gateway router</strong>가 이 일을 맡는다.</li>
</ul>

<p>라우터의 forwarding table은 이 둘이 함께 채운다. 목적지가 같은 AS 안이면 intra-AS 라우팅이 결정하고, 외부 목적지면 inter-AS와 intra-AS가 함께 결정한다. AS1의 라우터가 외부로 나가는 패킷을 받으면 “어느 gateway로 보낼지” 정해야 하는데, 이를 위해 inter-domain 라우팅은 (1) 어떤 목적지가 어느 이웃 AS를 통해 도달 가능한지 학습하고, (2) 그 도달성 정보를 AS 내 모든 라우터에 전파한다.</p>

<p>가장 흔한 프로토콜은 다음과 같다.</p>

<table>
  <thead>
    <tr>
      <th>구분</th>
      <th>프로토콜</th>
      <th>기반</th>
      <th>비고</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>intra-AS</td>
      <td>RIP</td>
      <td>distance vector</td>
      <td>30초마다 DV 교환, 현재는 거의 안 씀</td>
    </tr>
    <tr>
      <td>intra-AS</td>
      <td>EIGRP</td>
      <td>distance vector</td>
      <td>오래 Cisco 독점이었다가 2013년 공개</td>
    </tr>
    <tr>
      <td>intra-AS</td>
      <td>OSPF</td>
      <td>link state</td>
      <td>IS-IS도 사실상 OSPF와 동일</td>
    </tr>
    <tr>
      <td>inter-AS</td>
      <td>BGP</td>
      <td>path vector</td>
      <td>인터넷을 묶는 사실상의 표준</td>
    </tr>
  </tbody>
</table>

<p><strong>BGP(Border Gateway Protocol)</strong>는 “인터넷을 하나로 붙들고 있는 접착제”라고 불린다. 서브넷이 “나는 여기 있고, 이런 목적지에 도달할 수 있으며, 경로는 이렇다”를 인터넷 전체에 광고하게 해준다. 이웃 AS로부터 도달성 정보를 받고(eBGP), 정책(policy)에 따라 경로를 정하고, 그 정보를 AS 내부 라우터로 전파하고(iBGP), 이웃 네트워크에 광고한다.</p>

<p>intra와 inter를 굳이 다른 프로토콜로 나누는 이유는 <strong>정책</strong>, <strong>규모</strong>, <strong>성능</strong>의 우선순위가 다르기 때문이다. inter-AS는 관리자가 트래픽 경로를 통제하려는 정책이 성능보다 우선한다. intra-AS는 관리자가 하나뿐이라 정책 문제가 작고, 성능에 집중할 수 있다. 계층화 자체는 테이블 크기와 갱신 트래픽을 줄여 규모 문제를 해결한다.</p>

<h2 id="link-state-라우팅-dijkstra-알고리즘">Link-State 라우팅: Dijkstra 알고리즘</h2>

<p>link-state 라우팅의 전제는 <strong>각 라우터가 전체 토폴로지를 안다</strong>는 것이다. 각 라우터는 자기에게 직접 연결된 링크의 상태(link state)를 관찰할 수 있고, 이를 <strong>link-state broadcast</strong>로 다른 모든 라우터에 뿌린다. 그 결과 모든 라우터가 동일한 전체 지도를 갖게 된다.</p>

<p><img src="/assets/images/posts/ip-routing/ip-routing-2.png" alt="각 라우터가 자기 링크 상태를 broadcast하면 모든 라우터가 동일한 전체 토폴로지를 갖는다" /></p>

<p>전체 지도를 가진 라우터는 자기를 출발점으로 모든 목적지까지의 최소 비용 경로를 <strong>중앙집중적으로(centralized)</strong> 계산한다. 이 계산이 곧 <strong>Dijkstra 알고리즘</strong>이다. 표기는 다음과 같다.</p>

<ul>
  <li>$c_{x,y}$: 노드 $x$에서 $y$로의 직접 링크 비용. 직접 이웃이 아니면 $\infty$.</li>
  <li>$D(v)$: 출발지에서 목적지 $v$까지 현재 추정된 최소 비용.</li>
  <li>$p(v)$: 출발지에서 $v$로 가는 경로 상에서 $v$의 직전(predecessor) 노드.</li>
  <li>$N’$: 최소 비용 경로가 <strong>확정된</strong> 노드의 집합.</li>
</ul>

<p>알고리즘은 출발지 $u$만 $N’$에 넣고 시작해, 매 반복마다 아직 확정 안 된 노드 중 $D$가 가장 작은 노드를 골라 $N’$에 넣고, 그 노드를 거치는 경로로 이웃들의 비용을 갱신한다.</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Initialization:
  N' = {u}
  for all nodes v:
    if v adjacent to u: D(v) = c(u,v)
    else:               D(v) = ∞

Loop:
  N'에 없는 노드 w 중 D(w)가 최소인 것을 찾는다
  w를 N'에 추가
  w에 인접하고 N'에 없는 모든 v에 대해:
    D(v) = min( D(v), D(w) + c(w,v) )
until 모든 노드가 N'에 들어갈 때까지
</code></pre></div></div>

<p>핵심은 갱신식 $D(v) = \min(D(v),\ D(w) + c_{w,v})$다. $v$까지의 새 최소 비용은 “기존에 알던 비용”과 “방금 확정된 $w$까지의 비용 + $w$에서 $v$로 가는 직접 비용” 중 작은 쪽이다.</p>

<h3 id="예제로-따라가기">예제로 따라가기</h3>

<p>앞의 그래프(출발지 $u$)에 적용해보자. 링크 비용은 $c_{u,v}=2$, $c_{u,w}=5$, $c_{u,x}=1$, $c_{v,w}=3$, $c_{v,x}=2$, $c_{x,w}=3$, $c_{x,y}=1$, $c_{w,y}=1$, $c_{w,z}=5$, $c_{y,z}=2$다.</p>

<table>
  <thead>
    <tr>
      <th>Step</th>
      <th>N’</th>
      <th>D(v),p(v)</th>
      <th>D(w),p(w)</th>
      <th>D(x),p(x)</th>
      <th>D(y),p(y)</th>
      <th>D(z),p(z)</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>0</td>
      <td>u</td>
      <td>2,u</td>
      <td>5,u</td>
      <td><strong>1,u</strong></td>
      <td>∞</td>
      <td>∞</td>
    </tr>
    <tr>
      <td>1</td>
      <td>ux</td>
      <td>2,u</td>
      <td>4,x</td>
      <td> </td>
      <td><strong>2,x</strong></td>
      <td>∞</td>
    </tr>
    <tr>
      <td>2</td>
      <td>uxy</td>
      <td><strong>2,u</strong></td>
      <td>3,y</td>
      <td> </td>
      <td> </td>
      <td>4,y</td>
    </tr>
    <tr>
      <td>3</td>
      <td>uxyv</td>
      <td> </td>
      <td><strong>3,y</strong></td>
      <td> </td>
      <td> </td>
      <td>4,y</td>
    </tr>
    <tr>
      <td>4</td>
      <td>uxyvw</td>
      <td> </td>
      <td> </td>
      <td> </td>
      <td> </td>
      <td><strong>4,y</strong></td>
    </tr>
    <tr>
      <td>5</td>
      <td>uxyvwz</td>
      <td> </td>
      <td> </td>
      <td> </td>
      <td> </td>
      <td> </td>
    </tr>
  </tbody>
</table>

<p>각 단계에서 굵게 표시된 값이 그 라운드에 $N’$로 확정되는 노드다. 매번 확정 직후 이웃들의 $D$를 갱신한다. 예를 들어 Step 1에서 $x$가 확정되면 $D(w) = \min(5,\ 1+3) = 4$, $D(y) = \min(\infty,\ 1+1) = 2$로 줄어든다.</p>

<p>계산이 끝나면 $p(\cdot)$를 거꾸로 추적해 <strong>최소 비용 경로 트리</strong>를 만들고, 거기서 $u$의 forwarding table을 뽑는다. $u$ 입장에서 $v$는 직접 링크 $(u,v)$로, 나머지 $w, x, y, z$는 전부 첫 hop이 $x$이므로 $(u,x)$ 링크로 내보낸다.</p>

<h3 id="복잡도와-진동oscillation">복잡도와 진동(oscillation)</h3>

<p>노드가 $n$개일 때, 각 반복에서 $N’$에 없는 모든 노드를 확인해야 하므로 비교 횟수는 $n(n+1)/2$, 즉 $O(n^2)$이다. 우선순위 큐를 쓰면 $O(n \log n)$까지 줄일 수 있다. 메시지 복잡도는 각 라우터가 link state를 다른 $n$개 라우터에 broadcast해야 하므로 전체 $O(n^2)$이다.</p>

<p>link-state에는 미묘한 함정이 있다. 링크 비용이 <strong>트래픽 양에 의존</strong>하면 경로가 <strong>진동(oscillation)</strong>할 수 있다. 모두가 덜 혼잡한 경로로 몰리면 그 경로가 혼잡해지고, 그러면 다시 다른 경로로 몰리고, 이게 반복된다. 비용을 트래픽과 무관하게 두거나, 모든 라우터가 동시에 계산하지 않도록 조정해 완화한다.</p>

<h2 id="distance-vector-라우팅-bellman-ford">Distance-Vector 라우팅: Bellman-Ford</h2>

<p>distance-vector는 전혀 다른 접근이다. 각 라우터는 전체 지도를 모른 채, 이웃과 “거리 벡터”를 주고받으며 점진적으로 경로를 알아간다. 이론적 토대는 <strong>Bellman-Ford(BF) 방정식</strong>이다.</p>

<p>$D_x(y)$를 $x$에서 $y$까지의 최소 비용이라 하면,</p>

\[D_x(y) = \min_{v} \{\, c_{x,v} + D_v(y) \,\}\]

<p>이다. $\min$은 $x$의 모든 이웃 $v$에 대해 취한다. $c_{x,v}$는 $x$에서 이웃 $v$로의 직접 비용이고, $D_v(y)$는 그 이웃 $v$가 추정한 $y$까지의 최소 비용이다. 즉 “$y$로 가는 최선은, 이웃 하나를 거쳐 가는 경우들 중 가장 싼 것”이라는 동적 계획법(dynamic programming)의 점화식이다.</p>

<p>예를 들어 $u$의 이웃 $v, x, w$가 목적지 $z$까지의 비용을 각각 $D_v(z)=5$, $D_x(z)=3$, $D_w(z)=3$으로 알려줬다고 하자. 그러면</p>

\[D_u(z) = \min\{\, c_{u,v}+D_v(z),\ c_{u,x}+D_x(z),\ c_{u,w}+D_w(z) \,\} = \min\{2+5,\ 1+3,\ 5+3\} = 4\]

<p>이고, 최소를 만든 이웃 $x$가 $z$로 가는 경로의 <strong>next hop</strong>이 된다.</p>

<h3 id="동작-방식">동작 방식</h3>

<p>핵심 아이디어는 단순하다.</p>

<ul>
  <li>각 노드는 때때로 자기 distance vector(목적지별 최소 비용 추정의 묶음)를 이웃에게 보낸다.</li>
  <li>노드 $x$가 어떤 이웃으로부터 새 DV를 받으면, BF 방정식으로 자기 DV를 갱신한다.</li>
</ul>

\[D_x(y) \leftarrow \min_{v} \{\, c_{x,v} + D_v(y) \,\} \quad \text{for each node } y \in N\]

<p>약한 자연스러운 조건 아래에서, 이 추정값 $D_x(y)$는 실제 최소 비용 $d_x(y)$로 수렴한다. 이 과정은 비동기적이고 자기 종료적(self-terminating)이다. 갱신할 게 없으면 멈춘다.</p>

<p>정보는 반복적인 통신·계산을 통해 네트워크에 <strong>확산(diffuse)</strong>된다. $t=0$에 노드 $c$만 알던 상태가, $t=1$에는 1 hop 떨어진 이웃까지, $t=2$에는 2 hop까지 영향을 미친다. 즉 $t$번 반복 후 정보는 $t$ hop까지 퍼진다.</p>

<h3 id="link-cost가-변할-때-good-news-vs-bad-news">Link cost가 변할 때: good news vs bad news</h3>

<p>DV의 진짜 흥미로운(그리고 골치 아픈) 부분은 링크 비용이 변할 때다. 노드는 로컬 링크 비용 변화를 감지하면 라우팅 정보를 갱신하고 DV를 다시 계산하며, DV가 바뀌면 이웃에게 알린다.</p>

<p><img src="/assets/images/posts/ip-routing/ip-routing-3.png" alt="link cost 변화 — good news는 빠르게, bad news는 느리게 퍼진다" /></p>

<p><strong>“good news travels fast”</strong> — 비용이 줄어드는 경우. 위 그림에서 $y$-$x$ 링크 비용이 4에서 1로 줄었다고 하자.</p>

<ul>
  <li>$t_0$: $y$가 변화를 감지하고 DV를 갱신, 이웃에게 알린다.</li>
  <li>$t_1$: $z$가 $y$의 갱신을 받아 $x$까지의 새 최소 비용을 계산하고, 자기 이웃에게 알린다.</li>
  <li>$t_2$: $y$가 $z$의 갱신을 받지만 $y$의 최소 비용은 바뀌지 않으므로 더 이상 알리지 않는다. 빠르게 안정된다.</li>
</ul>

<p><strong>“bad news travels slow”</strong> — 비용이 늘어나는 경우. 이번엔 $y$-$x$ 비용이 4에서 60으로 뛰었다고 하자. 여기서 <strong>count-to-infinity</strong> 문제가 발생한다.</p>

<ul>
  <li>$y$는 $x$로의 직접 비용이 60이 됐지만, $z$가 “나는 $x$까지 비용 5인 경로가 있다”고 했던 걸 기억한다. 그래서 $y$는 “$z$를 거치면 6”이라 계산하고 $z$에게 알린다.</li>
  <li>$z$는 “$y$를 거치는 경로가 6이 됐네”라며 “그럼 나는 7”이라 계산하고 $y$에게 알린다.</li>
  <li>$y$는 다시 8, $z$는 9… 이렇게 둘이 서로의 옛 정보를 보고 1씩 올려가며, 실제 정답(50)에 도달할 때까지 핑퐁한다.</li>
</ul>

<p>문제의 본질은 $y$와 $z$가 서로의 경로가 <strong>자기를 거쳐 간다는 사실</strong>을 모른 채 상대의 광고를 믿는 데 있다. 분산 알고리즘이 까다로운 이유를 잘 보여주는 사례다.</p>

<h2 id="link-state-vs-distance-vector">Link State vs Distance Vector</h2>

<p>두 알고리즘을 정리하면 이렇다.</p>

<table>
  <thead>
    <tr>
      <th>기준</th>
      <th>Link State</th>
      <th>Distance Vector</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>메시지 복잡도</td>
      <td>$n$ 라우터, $O(n^2)$ 메시지</td>
      <td>이웃끼리만 교환, 수렴 시간 가변</td>
    </tr>
    <tr>
      <td>수렴 속도</td>
      <td>$O(n^2)$ 알고리즘, 진동 가능</td>
      <td>가변, 라우팅 루프·count-to-infinity 가능</td>
    </tr>
    <tr>
      <td>견고성(robustness)</td>
      <td>잘못된 <strong>링크</strong> 비용을 광고할 수 있으나, 각 라우터가 <strong>자기 테이블만</strong> 계산</td>
      <td>잘못된 <strong>경로</strong> 비용을 광고하면(“어디든 싸게 간다”) black-holing 발생, 오류가 네트워크 전체로 전파</td>
    </tr>
  </tbody>
</table>

<p>robustness가 중요한 차이다. LS에서는 고장난 라우터가 자기 링크 비용을 틀리게 말해도 영향이 국소적이다. 반면 DV에서는 한 라우터의 DV를 다른 라우터들이 그대로 받아 쓰므로, 한 곳의 오류가 연쇄적으로 퍼진다.</p>

<h2 id="실제-네트워크는-어떻게-생겼나">실제 네트워크는 어떻게 생겼나</h2>

<p>이론을 봤으니 실제 네트워크가 이 조각들을 어떻게 조립하는지 보자.</p>

<h3 id="캠퍼스-네트워크">캠퍼스 네트워크</h3>

<p>UMass 캠퍼스 네트워크는 방화벽 4개, 라우터 10개, 스위치 2000개 이상, 무선 AP 6000개, 유선 잭 3만 개, 동시 접속 무선 기기 5만 5천 개 규모를 약 15명이 운영한다. 구조는 명확한 계층을 이룬다.</p>

<p><img src="/assets/images/posts/ip-routing/ip-routing-4.png" alt="캠퍼스 네트워크의 계층 구조와 각 계층에서 쓰이는 라우팅 프로토콜" /></p>

<p>위 그림에서 계층별로 쓰이는 기술이 다르다는 점이 핵심이다.</p>

<ul>
  <li>외부로 나가는 border router는 <strong>eBGP</strong>로 inter-domain 라우팅을 한다(10G, 100G 준비 중).</li>
  <li>core 계층은 <strong>iBGP</strong>와 <strong>IS-IS</strong>로 intra-domain 라우팅을 한다(40G, 100G).</li>
  <li>aggregation 계층은 <strong>IS-IS</strong>(40G).</li>
  <li>맨 아래 building closet과 데이터센터 쪽은 <strong>Ethernet</strong>의 layer-2 스위칭(10G, 1G).</li>
</ul>

<p>여기서 스위치와 라우터의 구분이 다시 등장한다. 둘 다 store-and-forward 장비고 둘 다 forwarding table을 갖지만, <strong>라우터</strong>는 네트워크 계층 장비로 IP 주소와 라우팅 알고리즘으로 테이블을 계산하고, <strong>스위치</strong>는 링크 계층 장비로 MAC 주소를 flooding·learning해 테이블을 채운다.</p>

<h3 id="데이터센터-네트워크">데이터센터 네트워크</h3>

<p>데이터센터는 수만에서 수십만 대의 호스트가 가까이 모인 환경이다. e-business(Amazon), 콘텐츠 서버(YouTube, Akamai), 검색·데이터마이닝(Google) 등이 돌아간다. 도전 과제는 막대한 클라이언트를 감당하는 다중 애플리케이션, 신뢰성, 그리고 부하 분산과 병목 회피다.</p>

<p><img src="/assets/images/posts/ip-routing/ip-routing-5.png" alt="데이터센터 네트워크 — border router, tier-1·tier-2 스위치, TOR 스위치, 서버 랙의 계층" /></p>

<p>구조는 계층적이다. 맨 위 <strong>border router</strong>가 데이터센터 외부와 연결되고, 그 아래 <strong>tier-1 스위치</strong> 각각이 약 16개의 tier-2 스위치에 연결되며, <strong>tier-2 스위치</strong>는 각각 약 16개의 <strong>TOR(Top of Rack) 스위치</strong>에 연결된다. TOR은 랙마다 하나씩 놓여 100G~400G Ethernet으로 서버 블레이드와 이어진다. 랙 하나에는 20~40개의 서버 블레이드(호스트)가 들어간다.</p>

<p>이 구조의 두 가지 설계 포인트가 있다.</p>

<ul>
  <li><strong>multipath</strong>: 스위치·랙 사이를 촘촘히 연결해 랙 간 처리량을 높이고(여러 라우팅 경로 가능), 중복(redundancy)으로 신뢰성을 높인다. 두 랙 사이에 서로 겹치지 않는(disjoint) 경로가 여러 개 존재한다.</li>
  <li><strong>application-layer routing</strong>: load balancer가 외부 클라이언트 요청을 받아 데이터센터 내부로 작업을 분배하고, 결과를 클라이언트에 돌려준다. 클라이언트에게는 내부 구조가 숨겨진다.</li>
</ul>

<p>데이터센터는 프로토콜 혁신의 무대이기도 하다. 링크 계층에서는 RoCE(RDMA over Converged Ethernet), 전송 계층에서는 ECN 기반 혼잡 제어(DCTCP, DCQCN)와 hop-by-hop backpressure 실험, 라우팅·관리에서는 SDN이 널리 쓰인다.</p>

<h3 id="프로토콜은-죽어가는가">프로토콜은 죽어가는가</h3>

<p>마지막으로 Google의 control plane인 <strong>ORION</strong>은 흥미로운 질문을 던진다. ORION은 내부 데이터센터(Jupiter)와 광역망(B4)을 위한 SDN control plane으로, 라우팅(intra-domain, iBGP)과 traffic engineering을 ORION 코어 위의 <strong>애플리케이션</strong>으로 구현한다. edge-edge flow 기반 제어(CoFlow 스케줄링 등)로 계약 SLA를 맞추고, 관리는 Orion 코어의 pub-sub 마이크로서비스로, 스위치 신호·모니터링은 OpenFlow로 한다.</p>

<p>주목할 점은 <strong>라우팅 프로토콜이 없다</strong>는 것이다. 전통적으로 라우터들이 분산 프로토콜로 자율적으로 합의하던 일을, SDN 컨트롤러가 중앙에서 계산해 내려보낸다. 혼잡 제어조차 부분적으로 프로토콜이 아니라 SDN이 관리한다. “프로토콜은 죽어가는가?”라는 도발적 질문이 자연스럽게 따라온다. link-state와 distance-vector가 라우터의 자율적 합의로 경로를 찾는 고전적 모델이었다면, SDN은 그 통제권을 중앙으로 되돌린 셈이다.</p>]]></content><author><name>이주한</name></author><category term="Computer-Network" /><category term="network-layer" /><category term="routing" /><category term="control-plane" /><category term="link-state" /><category term="dijkstra" /><category term="distance-vector" /><category term="bellman-ford" /><category term="count-to-infinity" /><category term="autonomous-system" /><category term="ospf" /><category term="bgp" /><category term="datacenter-network" /><summary type="html"><![CDATA[라우팅을 그래프 문제로 추상화하는 것에서 출발해 Dijkstra link-state와 Bellman-Ford distance vector 알고리즘, count-to-infinity 문제, AS 기반 계층 라우팅, 그리고 캠퍼스·데이터센터 같은 실제 네트워크 구조까지 정리한다.]]></summary></entry><entry><title type="html">ARP, ICMP, and MPLS — 링크 계층 주소 해석과 IP 보조 프로토콜</title><link href="https://l2juhan.github.io/computer-network/2026/06/03/arp-icmp-mpls.html" rel="alternate" type="text/html" title="ARP, ICMP, and MPLS — 링크 계층 주소 해석과 IP 보조 프로토콜" /><published>2026-06-03T00:00:00+00:00</published><updated>2026-06-03T00:00:00+00:00</updated><id>https://l2juhan.github.io/computer-network/2026/06/03/arp-icmp-mpls</id><content type="html" xml:base="https://l2juhan.github.io/computer-network/2026/06/03/arp-icmp-mpls.html"><![CDATA[<p>IP 데이터그램이 실제로 케이블 위를 흐르려면 링크 계층의 도움이 필요하다. 다음 홉의 <strong>MAC 주소</strong>를 알아내는 ARP, 전달 중 생긴 문제를 되돌려 알리는 ICMP, 그리고 라벨로 빠르게 스위칭하는 MPLS를 차례로 본다.</p>

<h2 id="mac-주소">MAC 주소</h2>

<p>인터페이스에는 두 종류의 주소가 붙는다.</p>

<table>
  <thead>
    <tr>
      <th> </th>
      <th>IP 주소</th>
      <th>MAC 주소</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>계층</td>
      <td>네트워크 계층(L3)</td>
      <td>링크 계층(L2)</td>
    </tr>
    <tr>
      <td>길이</td>
      <td>32비트</td>
      <td>48비트 (대부분 LAN)</td>
    </tr>
    <tr>
      <td>표기</td>
      <td><code class="language-plaintext highlighter-rouge">128.119.40.136</code></td>
      <td><code class="language-plaintext highlighter-rouge">1A-2F-BB-76-09-AD</code> (16진수)</td>
    </tr>
    <tr>
      <td>용도</td>
      <td>L3 포워딩</td>
      <td>같은 서브넷 내 인접 인터페이스로 프레임 전달</td>
    </tr>
    <tr>
      <td>할당</td>
      <td>부착된 IP 서브넷에 의존</td>
      <td>NIC의 ROM에 각인(IEEE 관리)</td>
    </tr>
  </tbody>
</table>

<p><strong>MAC(또는 LAN, physical, Ethernet) 주소</strong>는 같은 서브넷 안에서 한 인터페이스의 프레임을 물리적으로 연결된 다른 인터페이스로 옮기는 데 <strong>로컬로</strong> 쓰인다. 48비트 주소는 보통 NIC의 ROM에 각인되지만 소프트웨어로 설정 가능한 경우도 있다.</p>

<p>MAC 주소 할당은 <strong>IEEE</strong>가 관리한다. 제조사가 MAC 주소 공간의 일부를 사서 유일성을 보장받는다. 비유하면 <strong>MAC 주소는 주민등록번호, IP 주소는 우편 주소</strong>다. MAC은 평면(flat) 주소라 <strong>이식성(portability)</strong>이 있다 — 인터페이스를 다른 LAN으로 옮겨도 그대로다. 반면 IP 주소는 노드가 붙은 IP 서브넷에 의존하므로 이식되지 않는다.</p>

<h2 id="arp">ARP</h2>

<p>IP 주소는 TCP와 IP 모듈에게만 의미가 있다. 데이터 링크는 자기만의 주소 체계(예: 이더넷의 48비트 MAC)를 갖는다. 그래서 IP가 다음 홉을 정하면, 그 다음 홉의 <strong>링크 계층 주소</strong>를 알아내야 한다.</p>

<p>여기서 핵심은 알아내야 하는 것이 <strong>IP 데이터그램의 목적지 주소가 아니라는 점</strong>이다. 호스트 A가 멀리 있는 호스트 C로 보낼 때, A가 알아야 할 건 첫 홉 라우터 B의 MAC 주소이지 C의 MAC 주소가 아니다.</p>

<p><strong>ARP(Address Resolution Protocol, RFC 826)</strong>는 IP 주소와 데이터 링크가 쓰는 주소 사이의 <strong>동적(dynamic) 매핑</strong>을 제공한다. 여기서 동적이란 사람의 개입 없이 자동으로 주소를 변환한다는 뜻이다. ARP는 각 네트워크 인터페이스가 <strong>하드웨어 주소(physical address)</strong>를 가진다고 가정하며, 정상 형태로는 <strong>브로드캐스트 네트워크에서만</strong> 동작한다. 점대점(point-to-point) 링크는 ARP를 쓰지 않는다. (역방향 매핑용 RARP(RFC 903)는 거의 쓰이지 않는다.)</p>

<h3 id="arp의-두-동작-request와-reply">ARP의 두 동작: request와 reply</h3>

<p><img src="/assets/images/posts/arp-icmp-mpls/arp-icmp-mpls-1.png" alt="ARP request는 multicast, reply는 unicast" /></p>

<p>ARP에는 <strong>request</strong>와 <strong>reply</strong> 두 동작이 있다.</p>

<ul>
  <li><strong>ARP request</strong>: “IP 주소 141.23.56.23을 쓰는 노드의 물리 주소를 찾는다”를 <strong>브로드캐스트(멀티캐스트)</strong>로 LAN 전체에 뿌린다</li>
  <li><strong>ARP reply</strong>: 해당 IP를 가진 노드만 자기 MAC 주소를 담아 <strong>유니캐스트</strong>로 답한다</li>
</ul>

<p>요청은 모두에게, 응답은 요청자에게만 가는 비대칭 구조다.</p>

<h3 id="arp-캐시arp-테이블">ARP 캐시(ARP 테이블)</h3>

<p>매번 브로드캐스트하면 낭비다. 그래서 각 호스트와 라우터는 <strong>ARP 캐시(ARP table)</strong>를 둔다. 주소 변환을 쓰는 각 인터페이스에 대해 최근의 <code class="language-plaintext highlighter-rouge">&lt;IP 주소, MAC 주소, TTL&gt;</code> 매핑을 보관한다.</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Linux% arp -a
printer.home (10.0.0.4) at 00:0A:95:87:38:6A [ether] on eth1
gw.home (10.0.0.1) at 00:0D:66:4F:60:00 [ether] on eth1
</code></pre></div></div>

<p>캐시 항목의 정상 만료 시간은 생성 후 <strong>20분</strong>이다(RFC 1122). <code class="language-plaintext highlighter-rouge">arp -a</code> 명령으로 전체 항목을 볼 수 있다(Linux, Windows 공통).</p>

<p>A가 같은 LAN의 B에게 데이터그램을 보내는데 B의 MAC을 모르는 경우의 흐름은 이렇다.</p>

<ol>
  <li>A가 B의 IP를 담은 ARP query를 <strong>브로드캐스트</strong>한다 (목적지 MAC = <code class="language-plaintext highlighter-rouge">FF-FF-FF-FF-FF-FF</code>). LAN의 모든 노드가 받는다</li>
  <li>B만 자기 MAC을 담아 A에게 ARP response를 보낸다 (B의 MAC을 알려줌)</li>
  <li>A가 응답을 받아 B 항목을 자기 ARP 테이블에 추가한다 (TTL과 함께)</li>
</ol>

<h3 id="arp-패킷-포맷">ARP 패킷 포맷</h3>

<p><img src="/assets/images/posts/arp-icmp-mpls/arp-icmp-mpls-2.png" alt="ARP 패킷 포맷, 이더넷 기준 28바이트" /></p>

<p>이더넷 ARP 메시지는 14바이트 MAC 헤더 뒤에 붙는다. 주요 필드는 다음과 같다.</p>

<ul>
  <li>ARP request의 목적지 MAC(DST) = <code class="language-plaintext highlighter-rouge">FF:FF:FF:FF:FF:FF</code></li>
  <li>‘Length or Type’ 필드 = <code class="language-plaintext highlighter-rouge">0x0806</code> (ARP, request·reply 공통)</li>
  <li>‘Prot Type’ 필드 = <code class="language-plaintext highlighter-rouge">0x0800</code> (IPv4 주소)</li>
  <li>IPv4 ↔ Ethernet ARP면 (Hard Size, Prot Size) = $(6, 4)$</li>
  <li><strong>Op</strong> 필드: ARP request면 1, ARP reply면 2</li>
</ul>

<p>존재하지 않는 호스트로 보내면 ARP request만 반복되고 응답이 없다. 그동안 ARP 캐시에는 <code class="language-plaintext highlighter-rouge">&lt;incomplete&gt;</code>로 남는다.</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Linux% arp -a
? (10.0.0.99) at &lt;incomplete&gt; on eth0
</code></pre></div></div>

<h3 id="arp-캐시-타임아웃과-soft-state">ARP 캐시 타임아웃과 Soft State</h3>

<p>각 항목에는 타임아웃이 걸린다. 대부분의 구현은 완성된 항목은 <strong>20분</strong>, 미완성 항목은 <strong>3분</strong>을 쓴다. 보통 항목이 사용될 때마다 20분 타임아웃을 다시 시작한다(RFC 1122는 사용 중이어도 타임아웃이 발생해야 한다고 하지만, 많은 구현이 따르지 않는다).</p>

<p>ARP 캐시 타임아웃은 <strong>soft state</strong>의 예다. soft state는 타임아웃 전에 갱신(refresh)되지 않으면 폐기되는 정보를 말한다. 주기적으로 갱신하지 않으면 사라지는 상태라는 점이 핵심이다.</p>

<h3 id="proxy-arp-gratuitous-arp-acd">Proxy ARP, Gratuitous ARP, ACD</h3>

<p>ARP에는 몇 가지 변형이 있다.</p>

<p><strong>Proxy ARP(RFC 1027)</strong>: 어떤 시스템(보통 특수 설정된 라우터)이 <strong>다른 호스트를 대신해</strong> ARP request에 응답하게 한다. 요청자는 응답한 시스템이 목적지인 줄 알지만 실제 목적지는 다른 곳(또는 없을 수도)에 있다. LAN 경계 보안을 깨므로 가능하면 피한다. <em>promiscuous ARP</em> 또는 <em>ARP hack</em>이라고도 불린다.</p>

<p><strong>Gratuitous ARP(GARP)</strong>: 호스트가 <strong>자기 자신의 주소</strong>를 찾는 ARP request를 보내는 것이다. 보통 인터페이스가 부팅 시 “up”될 때 한다. 목적은 두 가지다 — IP 주소 중복 탐지, 그리고 다른 호스트들이 ARP 캐시 항목을 갱신하게 하는 것(리부트로 MAC이 바뀐 경우 유용). Linux-HA에서는 백업 서버가 GARP로 죽은 서버를 넘겨받는다.</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Linux# tcpdump -e -n arp
1 0.0  0:0:c0:6f:2d:40 ff:ff:ff:ff:ff:ff arp 60:
        arp who-has 10.0.0.56 tell 10.0.0.56
</code></pre></div></div>

<p><strong>IPv4 Address Conflict Detection(ACD, RFC 5227)</strong>: ARP probe와 ARP announcement 패킷을 정의한다. <em>ARP probe</em>는 Sender’s Protocol(IP) Address 필드를 0으로 설정한 ARP request로, 캐시 오염을 막는다. “Up”일 때 무작위 지연 알고리즘으로 probe 3개를 보내고(동시 전원 인가 혼잡 방지), 충돌이 없으면 2초 간격으로 announcement 2개를 보내 기존 캐시 매핑을 갱신한다.</p>

<h3 id="다른-서브넷으로-라우팅">다른 서브넷으로 라우팅</h3>

<p>ARP가 어떻게 실제 전달에 쓰이는지, A가 라우터 R을 거쳐 다른 서브넷의 B로 보내는 과정을 본다.</p>

<p><img src="/assets/images/posts/arp-icmp-mpls/arp-icmp-mpls-3.png" alt="다른 서브넷으로 라우팅하는 토폴로지" /></p>

<p>전제: A는 B의 IP 주소를 알고, 첫 홉 라우터 R의 IP 주소를 알고(어떻게? — DHCP나 설정), R의 MAC 주소도 안다(어떻게? — ARP). 주소 지정을 IP(데이터그램)와 MAC(프레임) 두 계층에서 따라가면 이렇다.</p>

<ol>
  <li>A가 IP 데이터그램을 만든다 — <strong>IP src = A, IP dest = B</strong></li>
  <li>A가 이 데이터그램을 담은 링크 계층 프레임을 만든다 — <strong>프레임의 목적지 MAC = R의 MAC</strong>. R로 프레임 전송</li>
  <li>R이 프레임을 받아 데이터그램을 꺼내 IP로 올린다</li>
  <li>R이 나갈 인터페이스를 정하고, A→B 데이터그램을 다시 링크 계층으로 내린다. 이번엔 <strong>프레임 목적지 MAC = B의 MAC</strong>. 프레임 전송</li>
  <li>B가 프레임을 받아 데이터그램을 꺼내 IP로 올린다</li>
</ol>

<p>핵심은 <strong>IP 주소(src=A, dest=B)는 끝까지 변하지 않지만, MAC 주소는 매 홉마다 바뀐다</strong>는 점이다. IP는 최종 목적지를, MAC은 바로 다음 홉을 가리킨다.</p>

<h2 id="icmp">ICMP</h2>

<p>IP는 데이터그램을 전달하다 문제가 생겨도 송신자에게 알리지 않는다. 이 빈틈을 메우는 게 <strong>ICMP(Internet Control Message Protocol)</strong>다. 호스트와 라우터가 네트워크 수준 정보를 주고받는 데 쓴다.</p>

<ul>
  <li><strong>오류 보고</strong>: 도달 불가능한 호스트, 네트워크, 포트, 프로토콜</li>
  <li><strong>echo request/reply</strong>: <code class="language-plaintext highlighter-rouge">ping</code>이 사용</li>
</ul>

<p>ICMP는 IP “위”의 네트워크 계층 프로토콜이지만, ICMP 메시지 자체는 <strong>IP 데이터그램에 담겨</strong> 운반된다. ICMP 메시지는 type, code, 그리고 <strong>오류를 일으킨 IP 데이터그램의 첫 8바이트</strong>로 구성된다.</p>

<h3 id="icmp-메시지-포맷과-오류-데이터-필드">ICMP 메시지 포맷과 오류 데이터 필드</h3>

<p>ICMP 일반 메시지 포맷은 Type(8비트) + Code(8비트) + Checksum(16비트) + Rest of header + Data section이다.</p>

<p><img src="/assets/images/posts/arp-icmp-mpls/arp-icmp-mpls-4.png" alt="ICMP 오류 메시지의 데이터 필드" /></p>

<p>오류 메시지의 데이터 필드에는 <strong>원본 IP 데이터그램의 IP 헤더 + 데이터 첫 8바이트만</strong> 담긴다. 받은 데이터그램(IP 헤더 + 8바이트 + 나머지)에서 앞부분만 떼어 ICMP 헤더를 붙이고, 다시 IP 헤더로 감싸 송신측에 돌려보낸다. 첫 8바이트면 상위 계층(TCP/UDP)의 포트 번호까지 들어 있어 어떤 연결의 문제인지 송신측이 식별할 수 있다.</p>

<h3 id="주요-icmp-메시지-타입">주요 ICMP 메시지 타입</h3>

<table>
  <thead>
    <tr>
      <th>Type</th>
      <th>이름</th>
      <th>E/I</th>
      <th>용도</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>0</td>
      <td>Echo Reply</td>
      <td>I</td>
      <td><code class="language-plaintext highlighter-rouge">ping</code> 응답</td>
    </tr>
    <tr>
      <td>3</td>
      <td>Destination Unreachable</td>
      <td>E</td>
      <td>호스트/프로토콜 도달 불가</td>
    </tr>
    <tr>
      <td>4</td>
      <td>Source Quench</td>
      <td>E</td>
      <td>혼잡 표시 (deprecated)</td>
    </tr>
    <tr>
      <td>5</td>
      <td>Redirect</td>
      <td>E</td>
      <td>다른 라우터를 쓰라고 알림</td>
    </tr>
    <tr>
      <td>8</td>
      <td>Echo</td>
      <td>I</td>
      <td><code class="language-plaintext highlighter-rouge">ping</code> 요청</td>
    </tr>
    <tr>
      <td>9</td>
      <td>Router Advertisement</td>
      <td>I</td>
      <td>라우터 주소/선호도</td>
    </tr>
    <tr>
      <td>10</td>
      <td>Router Solicitation</td>
      <td>I</td>
      <td>Router Advertisement 요청</td>
    </tr>
    <tr>
      <td>11</td>
      <td>Time Exceeded</td>
      <td>E</td>
      <td>자원 소진 (예: IPv4 TTL)</td>
    </tr>
    <tr>
      <td>12</td>
      <td>Parameter Problem</td>
      <td>E</td>
      <td>잘못된 패킷/헤더</td>
    </tr>
  </tbody>
</table>

<p>E는 오류 메시지, I는 질의/정보 메시지다.</p>

<p><strong>Echo request/reply (Type 8/0)</strong>: 호스트나 라우터가 echo request를 보내면, 받은 쪽이 echo reply로 답한다. 호스트의 도달 가능성을 테스트하며, 보통 <code class="language-plaintext highlighter-rouge">ping</code> 명령으로 호출한다. Identifier와 Sequence number 필드로 요청-응답을 짝짓는다.</p>

<p><strong>Destination Unreachable (Type 3)</strong>: code로 세분된다.</p>

<table>
  <thead>
    <tr>
      <th>Code</th>
      <th>의미</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>0</td>
      <td>네트워크 도달 불가 — 라우팅 테이블에 목적지 경로가 없고 디폴트 라우트도 없음</td>
    </tr>
    <tr>
      <td>1</td>
      <td>호스트 도달 불가 — 최종 목적지 호스트에 도달 불가</td>
    </tr>
    <tr>
      <td>2</td>
      <td>프로토콜 도달 불가 — 목적지 호스트가 특정 프로토콜을 쓸 수 없음</td>
    </tr>
    <tr>
      <td>3</td>
      <td>포트 도달 불가 — 목적지 호스트가 특정 포트를 쓸 수 없음</td>
    </tr>
    <tr>
      <td>4</td>
      <td>단편화 필요하지만 DF 비트 설정됨 — MTU가 작은데 DF가 켜져 라우터가 폐기</td>
    </tr>
    <tr>
      <td>13</td>
      <td>통신이 관리적으로 금지됨 — 방화벽이 정책상 데이터그램을 폐기 (보통은 오류를 안 보냄)</td>
    </tr>
  </tbody>
</table>

<p><strong>Redirect (Type 5)</strong>: 라우터가 같은 로컬 네트워크의 호스트에게 더 나은 대체 라우터를 알린다. 호스트 A가 R1으로 보낸 패킷을 R1이 R2로 보내야 한다고 판단하면, R1은 패킷을 R2로 전달하면서 A에게 redirect 메시지(RM)를 보내 다음부터 R2로 직접 보내게 한다.</p>

<p><strong>Time Exceeded (Type 11)</strong>: code 0은 라우터가 TTL이 0이 됐음을 알릴 때만, code 1은 목적지 호스트가 정해진 시간 안에 모든 단편을 받지 못했음을 알릴 때만 쓴다.</p>

<p><strong>Parameter Problem (Type 12)</strong>: 라우터나 목적지 호스트가 만든다. Pointer(바이트 오프셋)가 문제가 시작된 필드를 가리킨다.</p>

<h3 id="traceroute">Traceroute</h3>

<p><code class="language-plaintext highlighter-rouge">traceroute</code>는 ICMP와 TTL을 영리하게 조합해 경로상의 라우터들을 알아낸다.</p>

<p><img src="/assets/images/posts/arp-icmp-mpls/arp-icmp-mpls-5.png" alt="Traceroute와 ICMP" /></p>

<ol>
  <li>출발지가 목적지로 UDP 세그먼트 묶음을 보낸다 — 1번째 묶음은 <strong>TTL=1</strong>, 2번째는 <strong>TTL=2</strong>, … 이런 식으로 늘린다</li>
  <li>$n$번째 묶음의 데이터그램이 $n$번째 라우터에 도달하면, 그 라우터는 TTL이 0이 됐으므로 데이터그램을 폐기하고 출발지로 ICMP 메시지(<strong>Type 11, Code 0</strong>)를 보낸다. 이 메시지에 라우터 이름과 IP가 담긴다</li>
  <li>ICMP가 출발지에 도착하면 RTT를 기록한다</li>
</ol>

<p><strong>멈추는 조건</strong>: UDP 세그먼트가 마침내 목적지 호스트에 도달하면, 목적지는 (열려 있지 않은 포트라서) ICMP “port unreachable”(<strong>Type 3, Code 3</strong>)을 돌려준다. 이걸 받으면 출발지가 멈춘다. 각 홉마다 보통 probe 3개를 보내 RTT를 3번 측정한다.</p>

<h2 id="mpls">MPLS</h2>

<blockquote>
  <p>시간 관계상 MPLS는 시험범위가 아니다. 개념만 짚는다.</p>
</blockquote>

<p><strong>MPLS(Multiprotocol Label Switching)</strong>의 목표는 MPLS 지원 라우터들 사이에서 <strong>고정 길이 라벨(label)</strong>로 고속 IP 전달을 하는 것이다. 기존 IP는 목적지 주소의 <strong>최장 프리픽스 매칭(longest prefix matching)</strong>으로 전달하는데, 이는 가변 길이 비교라 느리다. 고정 길이 식별자를 쓰면 조회가 빨라진다. <strong>가상 회선(Virtual Circuit)</strong> 방식에서 아이디어를 빌렸지만, IP 데이터그램은 여전히 IP 주소를 그대로 유지한다.</p>

<p><img src="/assets/images/posts/arp-icmp-mpls/arp-icmp-mpls-6.png" alt="MPLS 라벨 구조" /></p>

<p>라벨은 이더넷 헤더와 IP 헤더 사이에 끼워진다. label(20비트), Exp(3비트), S(1비트), TTL(8비트)로 구성된다.</p>

<p><strong>MPLS 지원 라우터</strong>는 <em>label-switched router</em>라고도 한다. <strong>IP 주소를 보지 않고 라벨 값만으로</strong> 나갈 인터페이스를 정한다. MPLS 포워딩 테이블은 IP 포워딩 테이블과 별개다.</p>

<p>유연성이 장점이다. MPLS 전달 결정은 IP와 다를 수 있다 — 목적지뿐 아니라 <strong>출발지 주소까지</strong> 써서 같은 목적지로 가는 흐름을 다르게 라우팅할 수 있고(트래픽 엔지니어링), 링크 장애 시 미리 계산해 둔 백업 경로로 빠르게 우회(fast reroute)할 수 있다.</p>

<p><img src="/assets/images/posts/arp-icmp-mpls/arp-icmp-mpls-7.png" alt="MPLS 포워딩 테이블" /></p>

<p>각 MPLS 라우터는 <code class="language-plaintext highlighter-rouge">(in label, out label, dest, out interface)</code> 테이블을 갖는다. 들어온 라벨을 보고 나갈 라벨로 바꿔 달면서(label swapping) 지정된 인터페이스로 내보낸다. 진입 라우터는 OSPF·IS-IS 링크 상태 플러딩을 확장해 MPLS 정보(링크 대역폭, 예약된 대역폭 등)를 나르고, <strong>RSVP-TE</strong> 시그널링으로 하류 라우터들의 MPLS 전달을 설정한다.</p>

<h2 id="vlan-optional">VLAN (Optional)</h2>

<p>LAN이 커지고 사용자가 접속 위치를 바꾸면 문제가 생긴다. 단일 브로드캐스트 도메인에서는 모든 L2 브로드캐스트 트래픽(ARP, DHCP, 알 수 없는 MAC)이 <strong>LAN 전체를 가로질러야</strong> 한다. 확장성, 효율, 보안, 프라이버시가 나빠진다. 관리 문제도 있다 — CS 부서 사용자가 EE 부서로 자리를 옮기면 물리적으로는 EE 스위치에 붙지만 논리적으로는 CS에 남고 싶을 수 있다.</p>

<p><strong>VLAN(Virtual Local Area Network)</strong>은 VLAN 기능을 지원하는 스위치를 설정해 <strong>하나의 물리 LAN 인프라 위에 여러 논리 LAN</strong>을 정의한다.</p>

<p><img src="/assets/images/posts/arp-icmp-mpls/arp-icmp-mpls-8.png" alt="Port-based VLAN" /></p>

<p><strong>Port-based VLAN</strong>은 스위치 포트를 그룹으로 묶어, 하나의 물리 스위치가 여러 개의 가상 스위치처럼 동작하게 한다. 예를 들어 포트 1~8은 EE VLAN, 9~15는 CS VLAN으로 묶는다.</p>

<ul>
  <li><strong>트래픽 격리</strong>: 포트 1~8을 오가는 프레임은 포트 1~8에만 도달한다. 스위치 포트 대신 단말의 MAC 주소로 VLAN을 정의할 수도 있다</li>
  <li><strong>동적 멤버십</strong>: 포트를 VLAN 사이에서 동적으로 재배정할 수 있다</li>
  <li><strong>VLAN 간 전달</strong>: 별개의 스위치들처럼 <strong>라우팅</strong>을 통해 이뤄진다 (실무에선 스위치+라우터 결합 제품을 판매)</li>
</ul>

<h3 id="여러-스위치에-걸친-vlan과-8021q">여러 스위치에 걸친 VLAN과 802.1Q</h3>

<p>VLAN이 여러 물리 스위치에 걸치면 <strong>trunk port</strong>가 스위치 사이에서 VLAN 프레임을 나른다. 그런데 스위치 사이를 오가는 프레임은 평범한 802.1 프레임일 수 없다 — 어느 VLAN 소속인지(VLAN ID)를 실어야 하기 때문이다. <strong>802.1q</strong> 프로토콜이 trunk port 사이 프레임에 헤더 필드를 추가·제거한다.</p>

<p><img src="/assets/images/posts/arp-icmp-mpls/arp-icmp-mpls-9.png" alt="802.1Q VLAN 프레임 포맷" /></p>

<p>802.1Q 프레임은 기존 802.1 이더넷 프레임의 source address 뒤에 4바이트를 끼운다.</p>

<ul>
  <li><strong>2바이트 Tag Protocol Identifier</strong> (값 <code class="language-plaintext highlighter-rouge">81-00</code>)</li>
  <li><strong>Tag Control Information</strong>: 12비트 VLAN ID 필드 + 3비트 우선순위 필드(IP TOS와 유사)</li>
</ul>

<p>태그가 끼워지므로 CRC도 다시 계산한다.</p>

<p><strong>EVPN(Ethernet VPN, 일명 VXLAN)</strong>은 한 걸음 더 나간다. 서로 떨어진 데이터센터의 L2 이더넷 스위치를 <strong>IP를 언더레이(underlay)로 삼아 논리적으로</strong> 연결한다. 이더넷 프레임을 IP 데이터그램 안에 담아 사이트 사이로 나르는 <strong>터널링</strong>으로, L3 네트워크 위에 L2 네트워크를 오버레이해 L2 네트워크를 “늘린다”(RFC 7348).</p>]]></content><author><name>이주한</name></author><category term="Computer-Network" /><category term="arp" /><category term="icmp" /><category term="mpls" /><category term="mac-address" /><category term="vlan" /><category term="traceroute" /><category term="ping" /><category term="proxy-arp" /><category term="802-1q" /><summary type="html"><![CDATA[IP 주소를 MAC 주소로 바꾸는 ARP, 네트워크 오류를 알리는 ICMP, 라벨 기반 고속 전달을 하는 MPLS, 그리고 물리 LAN을 논리적으로 나누는 VLAN까지 링크·네트워크 계층의 보조 프로토콜을 정리한다.]]></summary></entry><entry><title type="html">HTTP, DNS and DHCP — 응용 계층 핵심 프로토콜</title><link href="https://l2juhan.github.io/computer-network/2026/06/03/http-dns-dhcp.html" rel="alternate" type="text/html" title="HTTP, DNS and DHCP — 응용 계층 핵심 프로토콜" /><published>2026-06-03T00:00:00+00:00</published><updated>2026-06-03T00:00:00+00:00</updated><id>https://l2juhan.github.io/computer-network/2026/06/03/http-dns-dhcp</id><content type="html" xml:base="https://l2juhan.github.io/computer-network/2026/06/03/http-dns-dhcp.html"><![CDATA[<p>웹 페이지 하나를 여는 순간 응용 계층 프로토콜 세 개가 동시에 움직인다. 주소를 입력하면 <strong>DHCP</strong>로 내 IP부터 받고, <strong>DNS</strong>로 도메인을 IP로 바꾸고, <strong>HTTP</strong>로 페이지를 가져온다. 각각을 따로 본 뒤 마지막에 한 번의 웹 요청으로 묶는다.</p>

<h2 id="http">HTTP</h2>

<p>웹 페이지는 <strong>객체(object)</strong>들의 묶음이다. HTML 파일, JPEG 이미지, 오디오 파일 등이 각각 별개의 객체이고, 서로 다른 서버에 흩어져 있을 수도 있다. 페이지는 <strong>base HTML 파일</strong> 하나가 다른 객체들을 URL로 참조하는 구조다.</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>www.someschool.edu/someDept/pic.gif
└──── host name ───┘└─── path name ──┘
</code></pre></div></div>

<p><strong>HTTP(HyperText Transfer Protocol)</strong>는 웹의 응용 계층 프로토콜이다. 클라이언트/서버 모델로 동작한다 — 클라이언트(브라우저)가 객체를 요청하고, 서버(웹 서버)가 응답으로 객체를 보낸다.</p>

<h3 id="http는-tcp-위에서-동작한다">HTTP는 TCP 위에서 동작한다</h3>

<p>전송 계층으로 <strong>TCP</strong>를 쓴다. 흐름은 단순하다.</p>

<ol>
  <li>클라이언트가 서버의 <strong>포트 80</strong>으로 TCP 연결(소켓)을 연다</li>
  <li>서버가 연결을 수락한다</li>
  <li>브라우저와 서버가 HTTP 메시지를 주고받는다</li>
  <li>TCP 연결을 닫는다</li>
</ol>

<p>HTTP는 <strong>무상태(stateless)</strong> 프로토콜이다. 서버는 과거 클라이언트 요청에 대한 정보를 전혀 유지하지 않는다. 상태를 유지하는 프로토콜은 복잡하다 — 과거 이력을 보관해야 하고, 서버나 클라이언트가 죽으면 서로의 상태 인식이 어긋나 이를 다시 맞춰야 한다. HTTP는 이 복잡성을 처음부터 버렸다.</p>

<h3 id="두-가지-연결-방식">두 가지 연결 방식</h3>

<table>
  <thead>
    <tr>
      <th> </th>
      <th>Non-persistent HTTP</th>
      <th>Persistent HTTP</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>연결당 객체 수</td>
      <td>최대 1개</td>
      <td>여러 개</td>
    </tr>
    <tr>
      <td>동작</td>
      <td>객체 하나마다 TCP 연결을 새로 열고 닫음</td>
      <td>한 TCP 연결을 열어두고 재사용</td>
    </tr>
    <tr>
      <td>비용</td>
      <td>객체 N개면 연결 N번</td>
      <td>연결 1번</td>
    </tr>
  </tbody>
</table>

<p><strong>Non-persistent HTTP</strong>는 객체 하나를 받을 때마다 TCP 연결을 새로 만든다. 10개의 JPEG을 참조하는 페이지라면 연결을 10번 더 열어야 한다. 응답 시간을 따져보면 객체당 다음과 같다.</p>

<p><img src="/assets/images/posts/http-dns-dhcp/http-dns-dhcp-1.png" alt="Non-persistent HTTP 응답 시간" /></p>

<ul>
  <li>TCP 연결을 여는 데 <strong>1 RTT</strong>(Round Trip Time, 왕복 시간)</li>
  <li>HTTP 요청을 보내고 응답 첫 바이트가 돌아오는 데 <strong>1 RTT</strong></li>
  <li>그 뒤 파일 전송 시간</li>
</ul>

\[\text{Non-persistent HTTP 응답 시간} = 2 \cdot RTT + \text{파일 전송 시간}\]

<p>객체마다 $2 \cdot RTT$가 붙고, TCP 연결마다 OS 오버헤드도 든다. 그래서 브라우저는 보통 여러 TCP 연결을 병렬로 열어 객체를 동시에 받는다.</p>

<p><strong>Persistent HTTP(HTTP/1.1)</strong>는 응답을 보낸 뒤에도 연결을 열어둔다. 이후 객체들은 같은 연결 위에서 주고받는다. 참조된 객체를 발견하는 즉시 요청을 보내고, <strong>파이프라이닝(pipelining)</strong>까지 쓰면 모든 참조 객체를 단 <strong>1 RTT</strong>에 요청할 수 있어 응답 시간이 절반으로 준다.</p>

<h3 id="http-메시지-요청">HTTP 메시지: 요청</h3>

<p>HTTP 메시지는 <strong>요청(request)</strong>과 <strong>응답(response)</strong> 두 종류다. 둘 다 ASCII(사람이 읽을 수 있는 형식)다.</p>

<p><img src="/assets/images/posts/http-dns-dhcp/http-dns-dhcp-2.png" alt="HTTP request message 구조" /></p>

<p>요청 메시지는 <strong>요청 라인(request line)</strong> 한 줄과 <strong>헤더 라인(header lines)</strong>들로 구성된다.</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>GET /index.html HTTP/1.1\r\n
Host: www-net.cs.umass.edu\r\n
User-Agent: Mozilla/5.0 ...\r\n
Accept: text/html,application/xhtml+xml\r\n
Accept-Language: en-us,en;q=0.5\r\n
Accept-Encoding: gzip,deflate\r\n
Connection: keep-alive\r\n
\r\n
</code></pre></div></div>

<p>각 줄 끝의 <code class="language-plaintext highlighter-rouge">\r\n</code>은 캐리지 리턴(carriage return)과 라인 피드(line feed)다. 헤더가 끝나면 빈 줄(<code class="language-plaintext highlighter-rouge">\r\n</code> 하나)이 헤더의 끝을 알린다. 일반 형식으로 보면 <code class="language-plaintext highlighter-rouge">[요청 라인] → [헤더 라인들] → [빈 줄] → [본문(entity body)]</code> 순서다.</p>

<p>요청 메서드는 여러 가지가 있다.</p>

<table>
  <thead>
    <tr>
      <th>메서드</th>
      <th>용도</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><strong>GET</strong></td>
      <td>객체 요청. 서버로 데이터를 보낼 때는 URL의 <code class="language-plaintext highlighter-rouge">?</code> 뒤에 붙임 (<code class="language-plaintext highlighter-rouge">...?monkeys&amp;banana</code>)</td>
    </tr>
    <tr>
      <td><strong>POST</strong></td>
      <td>폼 입력 등 사용자 데이터를 본문(entity body)에 담아 전송</td>
    </tr>
    <tr>
      <td><strong>HEAD</strong></td>
      <td>GET과 동일하되 헤더만 요청 (본문 없음)</td>
    </tr>
    <tr>
      <td><strong>PUT</strong></td>
      <td>새 파일을 서버에 업로드, 지정 URL의 파일을 통째로 교체</td>
    </tr>
  </tbody>
</table>

<h3 id="http-메시지-응답">HTTP 메시지: 응답</h3>

<p><img src="/assets/images/posts/http-dns-dhcp/http-dns-dhcp-3.png" alt="HTTP response message 구조" /></p>

<p>응답 메시지는 <strong>상태 라인(status line)</strong>으로 시작한다. 프로토콜 버전, 상태 코드, 상태 문구가 들어간다.</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>HTTP/1.1 200 OK
Date: Tue, 08 Sep 2020 00:53:20 GMT
Server: Apache/2.4.6 (CentOS) ...
Last-Modified: Tue, 01 Mar 2016 18:57:50 GMT
Content-Length: 2651
Content-Type: text/html; charset=UTF-8
\r\n
data data data data data ...
</code></pre></div></div>

<p>상태 코드는 응답 메시지 첫 줄에 나타난다.</p>

<table>
  <thead>
    <tr>
      <th>코드</th>
      <th>의미</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><strong>200 OK</strong></td>
      <td>요청 성공, 객체가 이 메시지 뒤에 포함됨</td>
    </tr>
    <tr>
      <td><strong>301 Moved Permanently</strong></td>
      <td>객체 이동, 새 위치는 <code class="language-plaintext highlighter-rouge">Location:</code> 필드에 명시</td>
    </tr>
    <tr>
      <td><strong>400 Bad Request</strong></td>
      <td>서버가 요청 메시지를 이해하지 못함</td>
    </tr>
    <tr>
      <td><strong>404 Not Found</strong></td>
      <td>요청한 문서가 서버에 없음</td>
    </tr>
    <tr>
      <td><strong>505 HTTP Version Not Supported</strong></td>
      <td>지원하지 않는 HTTP 버전</td>
    </tr>
  </tbody>
</table>

<p>직접 확인해볼 수도 있다. <code class="language-plaintext highlighter-rouge">nc -c -v gaia.cs.umass.edu 80</code>(macOS)으로 포트 80에 TCP 연결을 연 뒤 <code class="language-plaintext highlighter-rouge">GET /kurose_ross/interactive/index.php HTTP/1.1</code>과 <code class="language-plaintext highlighter-rouge">Host:</code> 헤더를 입력하고 엔터를 두 번 치면 최소한의 GET 요청이 서버로 가고, 서버의 응답 메시지를 직접 볼 수 있다.</p>

<h3 id="쿠키-무상태-위에-상태-얹기">쿠키: 무상태 위에 상태 얹기</h3>

<p>HTTP는 무상태인데, 장바구니나 로그인 같은 건 어떻게 유지할까. 답은 <strong>쿠키(cookie)</strong>다. 쿠키는 네 부분으로 동작한다.</p>

<ol>
  <li>HTTP 응답 메시지의 쿠키 헤더 라인 (<code class="language-plaintext highlighter-rouge">Set-cookie</code>)</li>
  <li>다음 HTTP 요청 메시지의 쿠키 헤더 라인 (<code class="language-plaintext highlighter-rouge">cookie</code>)</li>
  <li>사용자 호스트에 저장돼 브라우저가 관리하는 쿠키 파일</li>
  <li>웹 사이트의 백엔드 데이터베이스</li>
</ol>

<p><img src="/assets/images/posts/http-dns-dhcp/http-dns-dhcp-4.png" alt="쿠키 동작 시퀀스" /></p>

<p>처음 사이트에 접속하면 서버가 고유 ID를 만들어 백엔드 DB에 기록하고, 응답에 <code class="language-plaintext highlighter-rouge">Set-cookie: 1678</code>을 담아 보낸다. 브라우저는 이걸 쿠키 파일에 저장한다. 이후 요청부터는 매번 <code class="language-plaintext highlighter-rouge">cookie: 1678</code>을 실어 보내므로 서버가 사용자를 식별할 수 있다. 일주일 뒤에 다시 와도 같은 쿠키 값으로 이어진다.</p>

<p>쿠키는 <strong>인증(authorization)</strong>, 장바구니, 추천, 세션 상태 유지 등에 쓰인다. 상태를 유지하는 방법은 두 갈래다 — <strong>프로토콜 양 끝점(endpoint)</strong>에서 여러 트랜잭션에 걸쳐 상태를 보관하거나, <strong>메시지 안에</strong> 쿠키로 상태를 실어 나르는 것이다.</p>

<h3 id="서드파티-쿠키와-추적">서드파티 쿠키와 추적</h3>

<p>문제는 쿠키가 추적 수단이 된다는 점이다.</p>

<ul>
  <li><strong>퍼스트파티 쿠키(first party cookie)</strong>: 내가 직접 방문한 사이트(base HTML 제공)가 심는 쿠키</li>
  <li><strong>서드파티 쿠키(third party cookie)</strong>: 내가 방문한 적 없는 사이트(예: 광고 서버 AdX)가 심는 쿠키</li>
</ul>

<p>뉴스 사이트에 광고가 박혀 있으면, 그 광고를 가져오는 HTTP GET이 광고 서버 AdX로 가면서 AdX의 쿠키가 심긴다. AdX 광고가 걸린 여러 사이트를 돌아다니면 AdX는 <code class="language-plaintext highlighter-rouge">Referrer</code> 헤더로 내가 어떤 사이트를 봤는지 사이트를 넘나들며 추적하고, 그 이력을 바탕으로 타겟 광고를 돌려준다. 추적은 보이지 않게도 가능하다 — 광고 대신 보이지 않는 링크가 GET을 트리거할 수도 있다.</p>

<p>이런 이유로 서드파티 추적 쿠키는 Firefox, Safari에서 기본 차단됐고, <strong>GDPR(EU General Data Protection Regulation)</strong>은 쿠키가 개인을 식별할 수 있으면 개인정보로 보아 규제 대상으로 삼는다. 그래서 “이 사이트는 쿠키를 사용합니다” 동의 배너가 뜨는 것이다.</p>

<h3 id="http-버전-정리">HTTP 버전 정리</h3>

<table>
  <thead>
    <tr>
      <th>버전</th>
      <th>RFC / 연도</th>
      <th>핵심</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><strong>HTTP/1.1</strong></td>
      <td>RFC 2616, 1997</td>
      <td>단일 TCP 연결에서 다중·파이프라인 GET. 서버는 <strong>FCFS</strong>(선입선출)로 응답 → 작은 객체가 큰 객체 뒤에서 막히는 <strong>HOL(Head-of-Line) blocking</strong> 발생</td>
    </tr>
    <tr>
      <td><strong>HTTP/2</strong></td>
      <td>RFC 7540, 2015</td>
      <td>클라이언트가 지정한 우선순위로 전송 순서 조정, 요청 안 한 객체도 푸시, 객체를 프레임으로 쪼개 HOL blocking 완화</td>
    </tr>
    <tr>
      <td><strong>HTTP/3</strong></td>
      <td>RFC 9114, 2022</td>
      <td>QUIC/UDP 위에서 보안과 <strong>객체별</strong> 오류·혼잡 제어 추가, 세밀한 파이프라이닝</td>
    </tr>
  </tbody>
</table>

<h2 id="dns">DNS</h2>

<p>사람은 <code class="language-plaintext highlighter-rouge">cs.umass.edu</code> 같은 이름을 쓰지만, 인터넷 호스트와 라우터는 32비트 IP 주소로 데이터그램을 주소 지정한다. 이름과 주소를 서로 변환해야 한다. 이 일을 하는 게 <strong>DNS(Domain Name System)</strong>다.</p>

<p>DNS는 두 얼굴을 가진다.</p>

<ul>
  <li><strong>분산 데이터베이스(distributed database)</strong>: 수많은 <strong>네임 서버(name server)</strong>의 계층 구조로 구현됨</li>
  <li><strong>응용 계층 프로토콜</strong>: 호스트와 DNS 서버가 통신해 이름을 <strong>resolve</strong>(주소/이름 변환)함</li>
</ul>

<p>DNS는 인터넷의 핵심 기능이지만 네트워크의 <strong>가장자리(edge)</strong>에 복잡성을 두는 방식, 즉 응용 계층 프로토콜로 구현됐다.</p>

<h3 id="도메인-계층-구조">도메인 계층 구조</h3>

<p>도메인 네임스페이스(domain namespace)는 트리 형태로 조직된다. 각 노드를 <strong>도메인(domain)</strong>, 부모 기준으로는 <strong>서브도메인(subdomain)</strong>이라 부른다. 트리의 뿌리는 <strong>ROOT</strong>이고 <code class="language-plaintext highlighter-rouge">.</code>으로 표기한다.</p>

<p><img src="/assets/images/posts/http-dns-dhcp/http-dns-dhcp-5.png" alt="DNS 도메인 계층" /></p>

<ul>
  <li><strong>ROOT</strong>: 최상단. <code class="language-plaintext highlighter-rouge">www.example.com</code>을 거꾸로 읽으면 맨 끝의 <code class="language-plaintext highlighter-rouge">.</code>이 루트</li>
  <li><strong>Top-Level Domain(TLD)</strong>: 루트 바로 아래. <code class="language-plaintext highlighter-rouge">www.example.com</code>의 TLD는 <code class="language-plaintext highlighter-rouge">.com</code></li>
  <li><strong>Second-level domain</strong>: 그 아래, 보통 회사·학교 같은 특정 주체에 할당</li>
</ul>

<p><strong>ROOT 서버</strong>는 이 트리의 시작점이다. 루트 존(zone)에는 <strong>13개</strong>의 권한 네임서버(DNS root server, <code class="language-plaintext highlighter-rouge">a</code>~<code class="language-plaintext highlighter-rouge">m.root-servers.net</code>)가 있고, <strong>ICANN</strong>이 루트 DNS 도메인을 관리한다. 이들은 모든 TLD의 네임서버 정보(약 2MB)를 제공하며 DNS 질의의 출발점이 된다. 인터넷에서 가장 중요한 인프라다.</p>

<p>TLD는 종류가 나뉜다.</p>

<table>
  <thead>
    <tr>
      <th>종류</th>
      <th>예시</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Infrastructure TLD</td>
      <td><code class="language-plaintext highlighter-rouge">.arpa</code></td>
    </tr>
    <tr>
      <td>Generic TLD (gTLD)</td>
      <td><code class="language-plaintext highlighter-rouge">.com</code>, <code class="language-plaintext highlighter-rouge">.net</code></td>
    </tr>
    <tr>
      <td>Sponsored TLD (sTLD)</td>
      <td><code class="language-plaintext highlighter-rouge">.edu</code>, <code class="language-plaintext highlighter-rouge">.gov</code>, <code class="language-plaintext highlighter-rouge">.mil</code>, <code class="language-plaintext highlighter-rouge">.travel</code></td>
    </tr>
    <tr>
      <td>Country Code TLD (ccTLD)</td>
      <td><code class="language-plaintext highlighter-rouge">.au</code>, <code class="language-plaintext highlighter-rouge">.cn</code>, <code class="language-plaintext highlighter-rouge">.fr</code>, <code class="language-plaintext highlighter-rouge">.kr</code></td>
    </tr>
    <tr>
      <td>Reserved TLD</td>
      <td><code class="language-plaintext highlighter-rouge">.example</code>, <code class="language-plaintext highlighter-rouge">.test</code>, <code class="language-plaintext highlighter-rouge">.localhost</code>, <code class="language-plaintext highlighter-rouge">.invalid</code></td>
    </tr>
  </tbody>
</table>

<p>공식 TLD 목록은 <strong>IANA(Internet Assigned Numbers Authority)</strong>가 관리한다. 각 TLD는 IANA가 지정한 관리자, 즉 <strong>레지스트리(registry)</strong>에 위임된다. 한국의 <code class="language-plaintext highlighter-rouge">.kr</code>은 <strong>KISA(한국인터넷진흥원)</strong>가 ccTLD 관리자다. 레지스트리는 다시 <strong>등록대행자(registrar)</strong>들과 계약한다 — GoDaddy, 가비아, 후이즈, 메가존 같은 업체가 실제 등록 업무를 대행한다.</p>

<h3 id="zone-vs-domain">Zone vs Domain</h3>

<p>도메인 트리는 네임스페이스가 어떻게 조직되는지를 나타낼 뿐, <strong>DNS 시스템이 어떻게 조직되는지</strong>와는 다르다. DNS는 <strong>존(zone)</strong> 단위로 관리된다.</p>

<p>존은 트리 위에서 인접한 도메인·서브도메인을 묶어 <strong>하나의 관리 주체(authority)</strong>에 권한을 부여한 것이다. 도메인은 권한 정보를 담지 않지만 존은 담는다. 한 도메인이 여러 존으로 쪼개져 여러 주체가 나눠 관리할 수도 있다. 예를 들어 <code class="language-plaintext highlighter-rouge">example.com</code> 회사가 나라별 지사의 독립성을 위해 <code class="language-plaintext highlighter-rouge">usa.example.com</code>, <code class="language-plaintext highlighter-rouge">uk.example.com</code>의 관리 권한을 각 지사로 <strong>위임(delegate)</strong>하면, 각 지사가 자기 DNS 정보를 직접 관리한다.</p>

<p>도메인이 서브도메인으로 나뉘지 않으면 존과 도메인은 같다. 나뉘더라도 DNS 데이터를 같은 존에 둘 수 있어 여전히 같을 수 있다. 다만 서브도메인은 자기만의 존을 가질 수 있다. 즉 <strong>존은 도메인의 DNS 데이터 일부만 담는다.</strong></p>

<h3 id="네임-서버의-종류">네임 서버의 종류</h3>

<p><strong>권한 네임 서버(authoritative name server)</strong>: 각 존은 최소 하나의 권한 네임서버를 두고 그 존 정보를 공개한다. DNS 질의에 대한 원본이자 확정적인 답을 준다. 마스터 서버(primary)는 모든 존 레코드의 원본을 저장하고, 슬레이브 서버(secondary)는 자동 갱신으로 마스터의 복사본을 유지한다. 조직이 직접 운영하거나 서비스 제공자에게 맡긴다.</p>

<p><strong>로컬 DNS 네임 서버(local DNS name server)</strong>: 호스트가 DNS 질의를 하면 우선 로컬 DNS 서버로 간다. 로컬 서버는 최근 변환 쌍을 담은 <strong>캐시</strong>에서 바로 답하거나, 답이 없으면 DNS 계층으로 질의를 전달한다. 각 ISP가 로컬 DNS 서버를 둔다(macOS는 <code class="language-plaintext highlighter-rouge">scutil --dns</code>, Windows는 <code class="language-plaintext highlighter-rouge">ipconfig /all</code>로 확인). 로컬 DNS 서버는 엄밀히 말하면 계층 구조에 속하지 않는다.</p>

<p>요즘은 로컬 ISP 서버 대신 <code class="language-plaintext highlighter-rouge">1.1.1.1</code>(Cloudflare)이나 <code class="language-plaintext highlighter-rouge">8.8.8.8</code>(Google) 같은 기본 DNS 서버를 쓰기도 한다. 응용은 로컬 머신의 DNS resolver에게 IP를 묻고, resolver는 자기 데이터에서 못 찾으면 로컬 DNS 서버(여기서는 <strong>recursive resolver</strong>)에게 넘긴다. 로컬 머신 쪽 resolver를 <strong>stub-resolver</strong>라 부른다.</p>

<h3 id="캐싱">캐싱</h3>

<p>어떤 네임 서버든 변환을 한 번 알아내면 그 매핑을 <strong>캐시(cache)</strong>하고, 이후 같은 질의에는 캐시된 답을 즉시 돌려준다. 캐싱은 응답 시간을 크게 줄인다. 캐시 항목은 <strong>TTL(Time To Live)</strong> 이후 사라지며, TLD 서버 정보는 보통 로컬 네임 서버에 캐시된다.</p>

<p>대신 캐시는 <strong>낡을 수 있다(out-of-date)</strong>. 이름이 가리키는 호스트가 IP를 바꿔도, 기존 TTL이 모두 만료되기 전까지는 인터넷 전역이 그 변경을 모를 수 있다. DNS는 최선형(best-effort) 이름-주소 변환이다.</p>

<p>로컬 파일도 변환에 관여한다. <code class="language-plaintext highlighter-rouge">/etc/hosts</code>는 일부 호스트명의 IP를 담고 있고, 머신은 로컬 DNS 서버에 묻기 <strong>전에</strong> 이 파일을 먼저 본다. <code class="language-plaintext highlighter-rouge">/etc/resolv.conf</code>는 로컬 DNS 서버의 IP 정보를 담는데, DHCP가 받아온 로컬 DNS 서버 주소도 여기 저장된다.</p>

<h3 id="iterated-query">Iterated Query</h3>

<p>이름 변환 과정을 보자. <code class="language-plaintext highlighter-rouge">engineering.nyu.edu</code>의 호스트가 <code class="language-plaintext highlighter-rouge">gaia.cs.umass.edu</code>의 IP를 알고 싶다.</p>

<p><img src="/assets/images/posts/http-dns-dhcp/http-dns-dhcp-6.png" alt="DNS iterated query" /></p>

<p><strong>Iterated query(반복 질의)</strong>에서 질의 받은 서버는 “나는 이 이름을 모르지만, 저 서버에 물어봐라”라며 다음에 물어볼 서버 이름을 답으로 준다.</p>

<ol>
  <li>호스트가 로컬 DNS 서버(<code class="language-plaintext highlighter-rouge">dns.nyu.edu</code>)에 질의
2~3. 로컬 서버가 <strong>root DNS 서버</strong>에 묻고, root는 <code class="language-plaintext highlighter-rouge">.edu</code> TLD 서버를 알려줌
4~5. 로컬 서버가 <strong>TLD DNS 서버</strong>에 묻고, TLD는 <code class="language-plaintext highlighter-rouge">umass.edu</code>의 권한 서버를 알려줌
6~7. 로컬 서버가 <strong>권한 DNS 서버</strong>(<code class="language-plaintext highlighter-rouge">dns.cs.umass.edu</code>)에 묻고, IP를 받음</li>
  <li>로컬 서버가 호스트에 최종 답을 전달</li>
</ol>

<h3 id="dns-레코드">DNS 레코드</h3>

<p>DNS는 <strong>자원 레코드(Resource Record, RR)</strong>를 저장하는 분산 DB다. RR 형식은 <code class="language-plaintext highlighter-rouge">(name, value, type, ttl)</code>이다. type에 따라 의미가 달라진다.</p>

<table>
  <thead>
    <tr>
      <th>type</th>
      <th>name</th>
      <th>value</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><strong>A</strong></td>
      <td>호스트명</td>
      <td>IP 주소</td>
    </tr>
    <tr>
      <td><strong>NS</strong></td>
      <td>도메인 (예: <code class="language-plaintext highlighter-rouge">foo.com</code>)</td>
      <td>그 도메인의 권한 네임서버 호스트명</td>
    </tr>
    <tr>
      <td><strong>CNAME</strong></td>
      <td>별칭(alias) 이름</td>
      <td>정규(canonical) 이름. <code class="language-plaintext highlighter-rouge">www.ibm.com</code> → <code class="language-plaintext highlighter-rouge">servereast.backup2.ibm.com</code></td>
    </tr>
    <tr>
      <td><strong>MX</strong></td>
      <td>도메인</td>
      <td>그 도메인의 SMTP 메일 서버 이름</td>
    </tr>
  </tbody>
</table>

<h3 id="dns-프로토콜-메시지">DNS 프로토콜 메시지</h3>

<p>DNS의 <strong>질의(query)</strong>와 <strong>응답(reply)</strong> 메시지는 같은 형식을 쓴다. 헤더에는 <strong>identification</strong>(16비트 식별자, 응답은 같은 번호를 사용)과 <strong>flags</strong>(질의/응답, recursion desired, recursion available, reply is authoritative)가 있다. 그 뒤로 질문 수·답변 RR 수·권한 RR 수·추가 RR 수가 오고, 실제 질문/답변/권한/추가 정보 섹션이 따른다.</p>

<p>DNS 응답에는 네 종류의 섹션이 있다.</p>

<table>
  <thead>
    <tr>
      <th>섹션</th>
      <th>내용</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><strong>Question</strong></td>
      <td>네임서버에 던지는 질문</td>
    </tr>
    <tr>
      <td><strong>Answer</strong></td>
      <td>질문에 답하는 레코드</td>
    </tr>
    <tr>
      <td><strong>Authority</strong></td>
      <td>권한 네임서버를 가리키는 레코드</td>
    </tr>
    <tr>
      <td><strong>Additional</strong></td>
      <td>질의와 관련된 부가 레코드</td>
    </tr>
  </tbody>
</table>

<h3 id="dns에-내-정보-등록하기">DNS에 내 정보 등록하기</h3>

<p>새 스타트업 “Network Utopia”가 <code class="language-plaintext highlighter-rouge">networkutopia.com</code>을 쓰려면, <strong>DNS 등록대행자</strong>(예: Network Solutions)에 이름을 등록하고 권한 네임서버(primary·secondary)의 이름과 IP를 제공한다. 등록대행자는 <code class="language-plaintext highlighter-rouge">.com</code> TLD 서버에 NS·A 레코드를 넣는다.</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>(networkutopia.com, dns1.networkutopia.com, NS)
(dns1.networkutopia.com, 212.212.212.1, A)
</code></pre></div></div>

<p>그리고 IP <code class="language-plaintext highlighter-rouge">212.212.212.1</code>의 권한 서버를 직접 세워, <code class="language-plaintext highlighter-rouge">www.networkutopia.com</code>의 A 레코드와 <code class="language-plaintext highlighter-rouge">networkutopia.com</code>의 MX 레코드를 만든다.</p>

<h2 id="dhcp">DHCP</h2>

<p>호스트가 IP 주소를 얻는 질문은 사실 두 개다 — 호스트가 <strong>자기 네트워크 안에서</strong> 주소(주소의 호스트 부분)를 얻는 법, 그리고 네트워크가 <strong>자신을 위한</strong> 주소(주소의 네트워크 부분)를 얻는 법. 앞의 질문을 푸는 게 DHCP다.</p>

<p>호스트가 IP를 얻는 방법은 두 가지다. 관리자가 설정 파일에 하드코딩하거나(예: UNIX <code class="language-plaintext highlighter-rouge">/etc/rc.config</code>), <strong>DHCP(Dynamic Host Configuration Protocol)</strong>로 서버에서 동적으로 받는 것이다. DHCP는 <strong>plug-and-play</strong>다.</p>

<p>DHCP의 목표는 호스트가 네트워크에 <strong>합류할 때</strong> 서버로부터 IP를 동적으로 얻는 것이다.</p>

<ul>
  <li>사용 중인 주소의 <strong>임대(lease)</strong>를 갱신할 수 있다</li>
  <li>주소 <strong>재사용</strong>이 가능하다 (연결된 동안만 보유)</li>
  <li>네트워크를 드나드는 <strong>모바일 사용자</strong>를 지원한다</li>
</ul>

<h3 id="dhcp-4단계">DHCP 4단계</h3>

<p><img src="/assets/images/posts/http-dns-dhcp/http-dns-dhcp-7.png" alt="DHCP client-server scenario" /></p>

<p>호스트가 막 네트워크에 들어오면 네 단계로 주소를 받는다. 모두 <strong>브로드캐스트(broadcast)</strong>로 오간다.</p>

<ol>
  <li><strong>DHCP discover</strong> — 호스트: “DHCP 서버 있나요?” (선택)</li>
  <li><strong>DHCP offer</strong> — 서버: “내가 DHCP 서버다, 이 IP를 써라” (선택)</li>
  <li><strong>DHCP request</strong> — 호스트: “이 IP를 쓰겠다”</li>
  <li><strong>DHCP ACK</strong> — 서버: “그 IP는 네 것이다”</li>
</ol>

<p>앞의 두 단계(discover/offer)는 클라이언트가 이전에 할당받은 주소를 기억하고 재사용하려는 경우 생략할 수 있다(RFC 2131). 보통 DHCP 서버는 라우터에 함께 들어가 있어, 라우터에 연결된 모든 서브넷을 서비스한다.</p>

<h3 id="dhcp는-ip-주소만-주는-게-아니다">DHCP는 IP 주소만 주는 게 아니다</h3>

<p>DHCP는 할당된 IP 외에도 다음을 함께 돌려준다.</p>

<ul>
  <li>클라이언트의 <strong>first-hop 라우터</strong> 주소</li>
  <li><strong>DNS 서버</strong>의 이름과 IP 주소</li>
  <li><strong>네트워크 마스크(network mask)</strong> (주소의 네트워크 부분과 호스트 부분 구분)</li>
</ul>

<p>동작은 캡슐화로 이뤄진다. DHCP 요청 메시지는 <strong>UDP → IP → Ethernet</strong> 순으로 캡슐화되고, 이더넷 프레임은 브로드캐스트(목적지 <code class="language-plaintext highlighter-rouge">FFFFFFFFFFFF</code>)로 LAN에 뿌려져 DHCP 서버를 돌리는 라우터가 받는다. 받은 쪽은 Ethernet → IP → UDP → DHCP로 역다중화(de-mux)해 올린다. 서버가 만든 DHCP ACK도 같은 방식으로 클라이언트까지 거슬러 올라간다. 끝나면 클라이언트는 자기 IP, DNS 서버 이름·주소, first-hop 라우터 주소를 모두 알게 된다.</p>

<h2 id="웹-요청-한-번에-일어나는-일">웹 요청 한 번에 일어나는 일</h2>

<p>지금까지 본 프로토콜들이 어떻게 한꺼번에 얽히는지 보자. <strong>시나리오</strong>: 학생이 노트북을 학교 네트워크에 연결하고 <code class="language-plaintext highlighter-rouge">www.google.com</code>을 요청한다. 간단해 보이지만 응용·전송·네트워크·링크 계층이 전부 동원된다.</p>

<p><strong>1) DHCP — 네트워크 합류</strong>
노트북은 자기 IP, first-hop 라우터 주소, DNS 서버 주소가 필요하다. DHCP를 쓴다. DHCP 요청이 UDP→IP→802.3 이더넷으로 캡슐화돼 브로드캐스트되고, 라우터의 DHCP 서버가 ACK로 응답한다. 이제 노트북은 IP, DNS 서버, first-hop 라우터를 안다.</p>

<p><strong>2) ARP — 라우터의 MAC 주소 알아내기</strong>
HTTP 요청을 보내려면 <code class="language-plaintext highlighter-rouge">www.google.com</code>의 IP가 필요하고, 그건 DNS로 알아낸다. DNS 질의는 UDP→IP→Ethernet으로 캡슐화되는데, 프레임을 라우터로 보내려면 라우터 인터페이스의 <strong>MAC 주소</strong>가 필요하다. <strong>ARP</strong>를 쓴다. ARP query를 브로드캐스트하면 라우터가 ARP reply로 자기 MAC을 알려준다. 이제 DNS 질의를 담은 프레임을 보낼 수 있다.</p>

<p><strong>3) DNS — 도메인을 IP로</strong>
DNS 질의를 담은 IP 데이터그램이 LAN 스위치를 거쳐 first-hop 라우터로, 다시 학교 네트워크에서 Comcast 네트워크로 라우팅된다(라우팅 테이블은 RIP, OSPF, IS-IS, BGP 같은 프로토콜이 만든다). DNS 서버까지 도달해 역다중화되고, DNS 서버가 <code class="language-plaintext highlighter-rouge">www.google.com</code>의 IP를 답으로 돌려준다.</p>

<p><strong>4) TCP — 연결 수립</strong>
HTTP 요청을 보내기 전에 클라이언트는 웹 서버로 <strong>TCP 소켓</strong>을 연다. TCP <strong>SYN</strong> 세그먼트(3-way handshake 1단계)가 웹 서버로 라우팅되고, 서버가 <strong>SYNACK</strong>(2단계)으로 응답하면 TCP 연결이 수립된다.</p>

<p><strong>5) HTTP — 페이지 받기</strong>
<strong>HTTP 요청</strong>이 TCP 소켓으로 들어가고, 이를 담은 IP 데이터그램이 <code class="language-plaintext highlighter-rouge">www.google.com</code>으로 라우팅된다. 웹 서버가 웹 페이지를 담은 <strong>HTTP 응답</strong>을 돌려주고, 그 데이터그램이 클라이언트로 라우팅되면 브라우저가 마침내 페이지를 그린다.</p>

<p>주소를 입력하고 페이지가 뜨기까지 DHCP, ARP, DNS, TCP, HTTP가 그리고 그 밑의 라우팅 프로토콜들이 순서대로 맞물려 돌아간다. “간단해 보이는” 웹 요청 하나가 응용 계층부터 링크 계층까지 프로토콜 스택 전체를 관통한다.</p>]]></content><author><name>이주한</name></author><category term="Computer-Network" /><category term="http" /><category term="dns" /><category term="dhcp" /><category term="cookie" /><category term="persistent-connection" /><category term="dns-record" /><category term="name-resolution" /><category term="web-request" /><summary type="html"><![CDATA[웹을 지탱하는 HTTP의 연결 방식과 메시지 구조, 이름을 주소로 바꾸는 DNS 계층, 그리고 IP를 자동 할당하는 DHCP까지 응용 계층 프로토콜을 정리하고, 웹 요청 한 번에 이 모두가 어떻게 얽히는지 따라간다.]]></summary></entry><entry><title type="html">Internet Protocol (2) — VLSM·CIDR, Route Aggregation, 라우터 내부, IP Forwarding, NAT</title><link href="https://l2juhan.github.io/computer-network/2026/06/02/internet-protocol-2.html" rel="alternate" type="text/html" title="Internet Protocol (2) — VLSM·CIDR, Route Aggregation, 라우터 내부, IP Forwarding, NAT" /><published>2026-06-02T00:00:00+00:00</published><updated>2026-06-02T00:00:00+00:00</updated><id>https://l2juhan.github.io/computer-network/2026/06/02/internet-protocol-2</id><content type="html" xml:base="https://l2juhan.github.io/computer-network/2026/06/02/internet-protocol-2.html"><![CDATA[<p><a href="/computer-network/2026/05/29/internet-protocol-1/">1편</a>은 IPv4 헤더와 단편화, classful 주소 체계와 서브네팅에서 끝났다. 서브네팅은 한 조직 내부를 나누는 기법이었지만 두 가지 제약이 남는다. 모든 서브넷이 같은 크기여야 하고, 인터넷 코어는 여전히 클래스 단위로 주소를 봤다. 이 제약을 차례로 깨는 것이 VLSM과 CIDR이다.</p>

<h2 id="vlsm-variable-length-subnet-mask">VLSM (Variable-Length Subnet Mask)</h2>

<p>기본 서브넷 마스크는 모든 서브네트워크가 <strong>같은 수의 호스트</strong>를 갖도록 강제한다. site-wide subnet mask를 <code class="language-plaintext highlighter-rouge">255.255.255.0</code>으로 잡으면 256개 서브넷이 각각 254개 호스트를 수용하는 식으로 균일하게 쪼개진다. 그런데 현실의 조직은 그렇지 않다. 어떤 부서는 호스트가 100대고 어떤 링크는 라우터 둘만 연결한다. 균일 분할은 주소를 낭비한다.</p>

<p><strong>VLSM(가변 길이 서브넷 마스크)</strong> [RFC 1878]은 서브넷마다 <strong>다른 길이의 마스크</strong>를 허용한다. 큰 서브넷은 짧은 마스크(/24)로, 라우터 간 점대점 링크는 긴 마스크(/30)로 잘게 쪼갠다.</p>

<p><img src="/assets/images/posts/internet-protocol-2/internet-protocol-2-1.png" alt="VLSM 예시 토폴로지 — 서브넷마다 다른 prefix length" /></p>

<p>위 예시는 <code class="language-plaintext highlighter-rouge">128.32.0.0/16</code> 사이트를 하나의 마스크로 균일하게 나누지 않는다. 호스트가 많은 LAN은 <code class="language-plaintext highlighter-rouge">128.32.1.0/24</code>, <code class="language-plaintext highlighter-rouge">128.32.2.0/24</code>로 두고, 내부 라우터 뒤의 작은 세그먼트는 <code class="language-plaintext highlighter-rouge">128.32.230.128/26</code>, <code class="language-plaintext highlighter-rouge">128.32.2.128/25</code>처럼 서로 다른 prefix length를 부여한다. 외부 인터넷은 여전히 <code class="language-plaintext highlighter-rouge">128.32.0.0/16</code> 하나만 보지만, 내부에서는 필요한 크기에 맞춰 주소를 재단한다.</p>

<h2 id="cidr-classless-inter-domain-routing">CIDR (Classless Inter-Domain Routing)</h2>

<p>VLSM은 ‘site-local’ 네트워크 안에서의 유연성이다. 1990년대 초까지도 <strong>인터넷 코어 라우팅은 여전히 classful</strong>이었다. 라우터는 주소의 첫 비트로 클래스를 판별하고 그에 따라 고정된 마스크를 적용했다.</p>

<p><strong>CIDR(Classless Inter-Domain Routing)</strong> [RFC 4632]은 인터넷 라우팅 시스템에서 <strong>클래스 개념 자체를 제거</strong>한다. 대신 <strong>CIDR 마스크</strong>를 명시해 prefix를 추출한다. 클래스 A/B/C라는 고정 경계가 사라지고, 마스크 길이를 임의로(<code class="language-plaintext highlighter-rouge">/0</code>부터 <code class="language-plaintext highlighter-rouge">/32</code>까지) 지정할 수 있다. 핵심은 이 마스크가 이제 <strong>전역 라우팅 시스템에 노출되어 처리된다</strong>는 점이다.</p>

<table>
  <thead>
    <tr>
      <th>Prefix</th>
      <th>Address Range</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">0.0.0.0/0</code></td>
      <td>0.0.0.0 – 255.255.255.255</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">128.0.0.0/1</code></td>
      <td>128.0.0.0 – 255.255.255.255</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">128.0.0.0/24</code></td>
      <td>128.0.0.0 – 128.0.0.255</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">198.128.128.192/27</code></td>
      <td>198.128.128.192 – 198.128.128.223</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">165.195.130.107/32</code></td>
      <td>165.195.130.107</td>
    </tr>
  </tbody>
</table>

<p>classless의 의미는 이렇게 정리된다. <strong>주소만으로는 그 주소가 속한 블록을 알 수 없다.</strong> 예를 들어 <code class="language-plaintext highlighter-rouge">230.8.24.56</code>은 prefix length에 따라 전혀 다른 블록에 속한다.</p>

<table>
  <thead>
    <tr>
      <th>Prefix length</th>
      <th>Block</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>16</td>
      <td>230.8.0.0 – 230.8.255.255</td>
    </tr>
    <tr>
      <td>20</td>
      <td>230.8.16.0 – 230.8.31.255</td>
    </tr>
    <tr>
      <td>26</td>
      <td>230.8.24.0 – 230.8.24.63</td>
    </tr>
    <tr>
      <td>29</td>
      <td>230.8.24.56 – 230.8.24.63</td>
    </tr>
    <tr>
      <td>31</td>
      <td>230.8.24.56 – 230.8.24.57</td>
    </tr>
  </tbody>
</table>

<p>같은 주소라도 마스크 길이가 함께 주어져야 비로소 어느 블록인지 확정된다. 그래서 classful 시절과 달리 마스크가 라우팅 정보의 일부로 항상 따라다닌다.</p>

<h3 id="주소-할당-예제">주소 할당 예제</h3>

<p>CIDR에서 주소 블록을 나누는 감각을 예제로 잡는다.</p>

<p><strong>예제 1.</strong> 조직이 <code class="language-plaintext highlighter-rouge">130.34.12.64/26</code> 블록을 받았고, 호스트 수가 같은 4개 서브넷이 필요하다.</p>

<p>전체 주소 수는 $N = 2^{32-26} = 64$개다. 첫 주소는 <code class="language-plaintext highlighter-rouge">130.34.12.64</code>, 마지막은 <code class="language-plaintext highlighter-rouge">130.34.12.127</code>이다. 4개로 균등 분할하려면 서브넷당 16개씩 준다($64/16 = 4$, 2의 거듭제곱이라 가능). 서브넷 마스크는</p>

\[n_i = n + \log_2(N/N_i) = 26 + \log_2 4 = 28\]

<p>따라서 각 서브넷은 <code class="language-plaintext highlighter-rouge">/28</code>이고, 시작 주소는 <code class="language-plaintext highlighter-rouge">130.34.12.64</code>, <code class="language-plaintext highlighter-rouge">130.34.12.80</code>, <code class="language-plaintext highlighter-rouge">130.34.12.96</code>, <code class="language-plaintext highlighter-rouge">130.34.12.112</code>가 된다. <strong>각 서브넷의 시작 주소는 그 서브넷의 주소 수로 나누어떨어진다</strong>는 규칙을 기억하면 검산이 쉽다.</p>

<p><strong>예제 2.</strong> <code class="language-plaintext highlighter-rouge">14.24.74.0/24</code> 블록(256개 주소)을 120, 60, 10개짜리 세 서브블록으로 나눈다.</p>

<p>요청 수가 2의 거듭제곱이 아니어도, <strong>이상의 가장 가까운 2의 거듭제곱</strong>을 할당한다.</p>

<ul>
  <li>120개 요청 → 128개 할당 → <code class="language-plaintext highlighter-rouge">/25</code> → <code class="language-plaintext highlighter-rouge">14.24.74.0/25</code></li>
  <li>60개 요청 → 64개 할당 → <code class="language-plaintext highlighter-rouge">/26</code> → <code class="language-plaintext highlighter-rouge">14.24.74.128/26</code></li>
  <li>10개 요청 → 16개 할당 → <code class="language-plaintext highlighter-rouge">/28</code> → <code class="language-plaintext highlighter-rouge">14.24.74.192/28</code></li>
</ul>

<p>합이 208개이므로 48개가 예비로 남는다(<code class="language-plaintext highlighter-rouge">14.24.74.208</code> ~ <code class="language-plaintext highlighter-rouge">14.24.74.255</code>).</p>

<h2 id="route-aggregation">Route Aggregation</h2>

<p>CIDR이 진짜 위력을 발휘하는 지점은 <strong>라우팅 테이블 축소</strong>다. 발상은 계층적 라우팅(hierarchical routing)에서 왔다. 네트워크 토폴로지가 트리 모양이고 주소가 그 토폴로지에 맞게(topology-sensitive) 배정되면, 최단 경로를 유지하면서도 라우팅 테이블을 아주 작게 유지할 수 있다.</p>

<p>RFC 2008은 이렇게 못 박는다. “Hierarchical routing is … the only proven mechanism for scaling routing to the current size of the Internet.” 계층적 라우팅은 현재 규모의 인터넷을 지탱하는 유일하게 검증된 확장 메커니즘이다.</p>

<p><strong>Route aggregation</strong>은 수치적으로 인접한(numerically adjacent) 여러 IP prefix를 <strong>하나의 더 짧은 prefix</strong>(aggregate 또는 summary)로 합쳐 더 넓은 주소 공간을 커버하는 것이다.</p>

<p><img src="/assets/images/posts/internet-protocol-2/internet-protocol-2-2.png" alt="Route aggregation — 인접 prefix를 하나의 짧은 prefix로 합친다" /></p>

<p>예를 들어 R2가 <code class="language-plaintext highlighter-rouge">200.10.0.0/19</code>, <code class="language-plaintext highlighter-rouge">200.10.32.0/19</code>, <code class="language-plaintext highlighter-rouge">200.10.64.0/19</code>, <code class="language-plaintext highlighter-rouge">200.10.96.0/19</code> 네 블록을 R1에게 광고할 때, R1은 이를 <code class="language-plaintext highlighter-rouge">200.10.0.0/17</code> 하나로 묶어 상위 네트워크에 광고한다. R3 쪽의 네 블록도 <code class="language-plaintext highlighter-rouge">200.10.128.0/17</code>로 묶이고, 최종적으로 R1은 바깥세상에 <code class="language-plaintext highlighter-rouge">200.10.0.0/16</code> 하나만 알린다. 수백 개의 세부 경로가 하나의 entry로 압축되는 셈이다. 이렇게 여러 네트워크를 하나의 더 큰 prefix로 광고하는 것을 <strong>supernetting</strong>이라 부른다.</p>

<h2 id="whats-inside-a-router">What’s Inside a Router</h2>

<p>주소 체계를 정리했으니, 그 주소를 보고 실제로 패킷을 옮기는 장치 내부를 본다.</p>

<p><img src="/assets/images/posts/internet-protocol-2/internet-protocol-2-3.png" alt="라우터 구조 — control plane(software)과 data plane(hardware)" /></p>

<p>라우터는 크게 네 부분이다.</p>

<ul>
  <li><strong>입력 포트(input ports)</strong>: 들어오는 링크를 종단하고, 헤더를 보고 어느 출력 포트로 보낼지 결정한다.</li>
  <li><strong>스위칭 패브릭(switching fabric)</strong>: 입력 포트에서 출력 포트로 패킷을 실제로 옮기는 내부 연결망.</li>
  <li><strong>출력 포트(output ports)</strong>: 나가는 링크로 패킷을 전송한다.</li>
  <li><strong>라우팅 프로세서(routing processor)</strong>: 라우팅 프로토콜을 돌리고 포워딩 테이블을 관리한다.</li>
</ul>

<p>여기서 시간 척도가 둘로 갈린다. 라우팅 프로세서가 담당하는 <strong>제어 평면(control plane)</strong>은 소프트웨어로 동작하며 밀리초 단위로 움직인다. 반면 입력/출력 포트와 패브릭이 담당하는 <strong>포워딩 데이터 평면(forwarding data plane)</strong>은 하드웨어로 동작하며 나노초 단위로 패킷을 처리해야 한다. 라우팅은 느려도 되지만 포워딩은 ‘line speed’를 따라가야 한다.</p>

<h3 id="input-port와-decentralized-switching">Input Port와 Decentralized Switching</h3>

<p>입력 포트는 물리 계층(bit-level 수신) → 링크 계층(예: 이더넷) → lookup/forwarding 순으로 처리한다. 마지막 단계가 <strong>분산 스위칭(decentralized switching)</strong>이다.</p>

<ul>
  <li>헤더 필드 값으로 입력 포트 메모리 안의 <strong>포워딩 테이블을 조회</strong>해 출력 포트를 찾는다(“match plus action”).</li>
  <li>목표는 입력 포트 처리를 <strong>line speed</strong>로 완료하는 것이다.</li>
  <li>패킷이 패브릭으로 빠지는 속도보다 빨리 도착하면 <strong>입력 포트 큐잉(input port queuing)</strong>이 발생한다.</li>
</ul>

<p>포워딩 방식은 둘로 나뉜다. <strong>destination-based forwarding</strong>은 목적지 IP 주소만 보고 포워딩하는 전통적 방식이고, <strong>generalized forwarding</strong>은 임의의 헤더 필드 집합을 보고 포워딩한다(SDN의 기반).</p>

<h3 id="longest-prefix-matching">Longest Prefix Matching</h3>

<p>destination-based 포워딩 테이블을 주소 범위(range)로 표현하면, 범위가 깔끔하게 나뉘지 않을 때 문제가 생긴다. 그래서 실제로는 prefix로 표현하고 <strong>longest prefix matching(최장 prefix 일치)</strong>을 쓴다.</p>

<blockquote>
  <p>주어진 목적지 주소에 대해 포워딩 테이블 entry를 찾을 때, <strong>일치하는 가장 긴 prefix</strong>를 사용한다.</p>
</blockquote>

<table>
  <thead>
    <tr>
      <th>Destination Address Range</th>
      <th>Link interface</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">11001000 00010111 00010***  ********</code></td>
      <td>0</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">11001000 00010111 00011000  ********</code></td>
      <td>1</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">11001000 00010111 00011***  ********</code></td>
      <td>2</td>
    </tr>
    <tr>
      <td>otherwise</td>
      <td>3</td>
    </tr>
  </tbody>
</table>

<p>목적지가 <code class="language-plaintext highlighter-rouge">11001000 00010111 00011000 10101010</code>이라 하자. 이 주소는 interface 2의 prefix(<code class="language-plaintext highlighter-rouge">...00011***</code>)에도 맞고 interface 1의 prefix(<code class="language-plaintext highlighter-rouge">...00011000</code>)에도 맞는다. 둘 다 일치하지만 <strong>더 긴</strong> interface 1의 prefix를 택한다. 반면 <code class="language-plaintext highlighter-rouge">...00010110 10100001</code>은 interface 0의 prefix에만 맞으므로 0으로 간다.</p>

<p>longest prefix matching은 라우팅 테이블이 클 때 비싼 연산이다. 그래서 고성능 라우터는 <strong>TCAM(Ternary Content Addressable Memory)</strong>으로 처리한다. 주소를 제시하면 테이블 크기와 무관하게 <strong>한 클럭 사이클</strong>에 매칭 결과를 얻는다. Cisco Catalyst는 약 100만 개의 라우팅 테이블 entry를 TCAM에 담는다.</p>

<h2 id="ip-forwarding">IP Forwarding</h2>

<p>이제 호스트와 라우터 관점에서 포워딩이 어떻게 일어나는지 본다.</p>

<h3 id="호스트에서의-전달-direct-vs-indirect">호스트에서의 전달: Direct vs Indirect</h3>

<ul>
  <li><strong>직접 전달(Direct Delivery)</strong>: 목적지가 링크 계층에서 <strong>직접 연결</strong>되어 있으면, IP 데이터그램을 목적지로 바로 보낸다. <strong>라우터가 전혀 필요 없다.</strong></li>
  <li><strong>간접 전달(Indirect Delivery)</strong>: 그 외의 경우, 호스트는 데이터그램을 <strong>하나의 라우터로 보내고</strong> 그 라우터가 전달을 책임지게 한다. 목적지에 대한 next-hop 라우터가 포워딩 테이블에 있으면 그쪽으로, 없으면 <strong>default 라우터</strong>가 받는다.</li>
</ul>

<h3 id="forwarding-기법--테이블을-줄이는-방법들">Forwarding 기법 — 테이블을 줄이는 방법들</h3>

<p>라우팅 테이블을 작게 유지하기 위해 교과서는 여러 기법을 소개한다.</p>

<ul>
  <li><strong>Next-hop Method</strong>: 전체 경로 대신 <strong>다음 홉 주소만</strong> 저장한다. A는 “Host B로 가려면 R1로”만 알면 되고, 전체 경로 <code class="language-plaintext highlighter-rouge">R1, R2, Host B</code>를 들고 있을 필요가 없다.</li>
  <li><strong>Network-Specific Method</strong>: 같은 물리 네트워크에 연결된 호스트마다 entry를 두지 않고, <strong>목적지 네트워크 주소 하나</strong>로 묶는다. N2의 A, B, C, D를 각각 적는 대신 “N2 → R1” 한 줄로 충분하다.</li>
  <li><strong>Host-Specific Method</strong>: 반대로 <strong>특정 호스트 주소</strong>를 테이블에 명시한다. 비효율적이지만 관리자가 라우팅을 세밀하게 제어하고 싶을 때 쓴다.</li>
  <li><strong>Default Method</strong>: 인터넷의 모든 네트워크를 나열하는 대신 <strong>default(보통 <code class="language-plaintext highlighter-rouge">0.0.0.0</code>)</strong> entry 하나로 나머지를 처리한다.</li>
</ul>

<h3 id="라우터에서의-수신">라우터에서의 수신</h3>

<p>라우터의 포워딩 테이블은 보통 <strong>Destination, Mask, Next-hop, Interface</strong> 컬럼을 갖는다. 포워딩은 longest prefix matching으로 수행하고, 여러 entry가 일치하면 대부분 첫 entry를 쓴다(일부 구현은 부하 분산을 시도한다).</p>

<p>핵심은 IP 포워딩이 <strong>hop-by-hop</strong>으로 이뤄진다는 점이다. 즉 <strong>어떤 라우터도 전체 경로 정보를 갖지 않는다.</strong> 각 라우터는 자기 다음 홉만 결정하고, 전체 경로의 정합성은 RIP·OSPF·BGP·IS-IS 같은 라우팅 프로토콜이 보장한다. 다만 가정용 무선 공유기는 보통 <strong>라우팅 프로토콜을 전혀 돌리지 않는다.</strong></p>

<blockquote>
  <p>Unix 계열의 (커널 IP) 라우팅 테이블은 현대 교과서에서 forwarding table이라 부르고, RFC에서는 <strong>FIB(Forward Information Base)</strong>라 한다.</p>
</blockquote>

<h3 id="multi-homed-호스트와-host-model">Multi-homed 호스트와 Host Model</h3>

<p>인터페이스가 여러 개인 <strong>multi-homed 호스트</strong>에서는 데이터그램 처리가 미묘해진다. [RFC 1122]는 두 가지 모델을 정의한다.</p>

<ul>
  <li><strong>Strong host model</strong>: 데이터그램을 수신/송신할 때 {목적지, 출발지} IP 주소가 그 인터페이스와 <strong>유효하게 대응</strong>해야 한다. 보안상 이점이 있다.</li>
  <li><strong>Weak host model</strong>: 목적지 IP가 <strong>호스트의 아무 인터페이스</strong>와 일치하기만 하면 수신을 받아들인다.</li>
</ul>

<p>OS마다 기본값이 다르다. <strong>Linux는 weak host model</strong>, <strong>현대 Windows는 strong host model</strong>이 기본이다.</p>

<h2 id="nat-network-address-translation">NAT (Network Address Translation)</h2>

<p>IPv4 주소는 32비트라 약 43억 개뿐이고, 일찍부터 고갈이 예견됐다. <strong>NAT</strong>는 이 문제에 대한 실용적 대응이다.</p>

<blockquote>
  <p>NAT: 외부 세계가 보기에, 로컬 네트워크의 <strong>모든 장치가 단 하나의 IPv4 주소를 공유</strong>한다.</p>
</blockquote>

<p>로컬 네트워크의 장치들은 <strong>사설 IP 주소 공간</strong>(<code class="language-plaintext highlighter-rouge">10/8</code>, <code class="language-plaintext highlighter-rouge">172.16/12</code>, <code class="language-plaintext highlighter-rouge">192.168/16</code>)을 쓴다. 이 주소는 로컬 안에서만 유효하다. NAT 라우터는 바깥으로 나갈 때 하나의 공인 주소로 바꿔 내보낸다.</p>

<p><img src="/assets/images/posts/internet-protocol-2/internet-protocol-2-4.png" alt="NAT 동작 — translation table로 (주소, 포트)를 변환한다" /></p>

<p>동작은 <strong>NAT translation table</strong>을 중심으로 돈다.</p>

<ol>
  <li>내부 호스트 <code class="language-plaintext highlighter-rouge">10.0.0.1:3345</code>가 <code class="language-plaintext highlighter-rouge">128.119.40.186:80</code>으로 데이터그램을 보낸다.</li>
  <li>NAT 라우터는 출발지를 <code class="language-plaintext highlighter-rouge">138.76.29.7:5001</code>(NAT 공인 주소 + 새 포트)로 바꾸고, 이 변환 쌍을 테이블에 기록한다.</li>
  <li>응답이 <code class="language-plaintext highlighter-rouge">138.76.29.7:5001</code>을 목적지로 도착한다.</li>
  <li>NAT 라우터는 테이블을 조회해 목적지를 원래의 <code class="language-plaintext highlighter-rouge">10.0.0.1:3345</code>로 되돌려 내부로 전달한다.</li>
</ol>

<p>여기서 <strong>포트 번호</strong>가 핵심이다. 모든 나가는 데이터그램이 같은 출발지 IP를 갖지만 <strong>서로 다른 출발지 포트</strong>를 부여받기 때문에, 응답이 돌아올 때 어느 내부 호스트의 것인지 구분할 수 있다.</p>

<p>NAT의 장점은 분명하다. ISP로부터 <strong>공인 주소 하나만</strong> 받으면 되고, 내부 주소를 바꿔도 외부에 알릴 필요가 없으며, ISP를 바꿔도 내부 장치 주소를 그대로 둔다. 보안 측면에서도 내부 장치가 외부에서 직접 주소 지정되지 않는다.</p>

<p>물론 논쟁도 있다. 라우터는 계층 3까지만 처리해야 하는데 NAT는 포트(계층 4)를 건드린다. 주소 부족은 IPv6로 풀어야 한다는 주장, end-to-end 원칙 위배, NAT 뒤의 서버에 접속하려는 <strong>NAT traversal</strong> 문제 등이 지적된다. 그럼에도 NAT는 가정·기관 네트워크와 4G/5G 셀룰러망에서 광범위하게 쓰이며 사라지지 않는다.</p>

<h2 id="ipv6와-터널링">IPv6와 터널링</h2>

<p>NAT가 임시방편이라면 근본 해법은 <strong>IPv6</strong>다. 처음 동기는 32비트 IPv4 주소 공간 고갈이었고, 부차적으로 40바이트 고정 헤더로 처리 속도를 높이고 ‘flow’ 단위의 차별화된 처리를 가능하게 했다.</p>

<p>IPv6 데이터그램은 IPv4 대비 다음을 <strong>없앴다</strong>.</p>

<ul>
  <li><strong>checksum 없음</strong>: 라우터 처리 속도 향상.</li>
  <li><strong>fragmentation/reassembly 없음</strong>: 중간 라우터에서 단편화 불가.</li>
  <li><strong>options 없음</strong>: 필요하면 next-header로 연결되는 확장 헤더로 처리.</li>
</ul>

<h3 id="transition-tunneling">Transition: Tunneling</h3>

<p>문제는 <strong>모든 라우터를 동시에 업그레이드할 수 없다</strong>는 것이다(‘flag day’가 없다). IPv4와 IPv6 라우터가 섞인 망을 어떻게 운영하는가. 답은 <strong>터널링(tunneling)</strong>이다.</p>

<blockquote>
  <p>터널링: IPv6 데이터그램을 IPv4 데이터그램의 <strong>payload로 실어 나른다</strong>(“packet within a packet”).</p>
</blockquote>

<p><img src="/assets/images/posts/internet-protocol-2/internet-protocol-2-5.png" alt="IPv6 터널링 — IPv4 구간을 IPv6 데이터그램이 payload로 통과한다" /></p>

<p>논리적으로는 IPv6 라우터 B와 E가 직접 연결된 것처럼 보이지만, 물리적으로는 그 사이에 IPv4 라우터 C, D가 있다. B는 A로부터 받은 IPv6 데이터그램(src: A, dest: F)을 통째로 IPv4 데이터그램(src: B, dest: E)의 payload에 넣는다. C와 D는 이를 평범한 IPv4 패킷으로 보고 E까지 전달하고, E가 IPv4 껍질을 벗겨 안의 IPv6 데이터그램을 꺼내 F로 보낸다. <strong>원본 IPv6의 출발지/목적지 주소(A, F)는 터널 내내 보존된다.</strong> 터널링은 IPv6 전환뿐 아니라 4G/5G 등 다른 맥락에서도 폭넓게 쓰인다.</p>

<p>IPv6는 1990년대 중반부터 25년 넘게 배포 중이지만 전환은 느리다. 2023년 기준 Google 클라이언트의 약 40%가 IPv6로 접속하는 수준이다. 같은 기간 응용 계층은 WWW·소셜미디어·스트리밍으로 송두리째 바뀐 것과 대조된다. 네트워크 계층을 바꾸는 일이 그만큼 어렵다는 방증이다.</p>

<h2 id="라우터-큐잉과-패킷-스케줄링">라우터 큐잉과 패킷 스케줄링</h2>

<p>마지막으로 라우터 내부에서 패킷이 어떻게 줄을 서고 선택되는지 본다. 이는 지연·손실과 직결된다.</p>

<h3 id="input-port-queuing과-hol-blocking">Input Port Queuing과 HOL Blocking</h3>

<p>스위칭 패브릭이 입력 포트들의 합산 속도보다 느리면 <strong>입력 큐</strong>에 패킷이 쌓인다. 여기서 고약한 현상이 <strong>HOL(Head-of-the-Line) blocking</strong>이다.</p>

<p><img src="/assets/images/posts/internet-protocol-2/internet-protocol-2-6.png" alt="HOL blocking — 큐 맨 앞 패킷이 뒤를 막는다" /></p>

<p>같은 출력 포트를 두 입력이 동시에 노리면 하나만 통과하고 다른 하나는 막힌다(output port contention). 문제는 <strong>막힌 패킷 뒤에 줄 선 패킷</strong>이다. 그 뒤 패킷의 목적지 출력 포트가 비어 있어도, 앞 패킷이 막혀 있으면 함께 멈춘다. 큐 맨 앞(head-of-line) 패킷이 뒤 전체를 가로막는 것이다.</p>

<h3 id="output-port-queuing">Output Port Queuing</h3>

<p>패브릭에서 출력 링크 전송 속도보다 빨리 패킷이 도착하면 <strong>출력 포트 버퍼</strong>가 필요하다. 버퍼가 가득 차면 <strong>drop policy</strong>에 따라 패킷을 버린다(혼잡·버퍼 부족으로 인한 손실). 얼마나 버퍼링해야 하는가. RFC 3439의 경험칙은 평균 버퍼를 “전형적 RTT(약 250ms) × 링크 용량 $C$”로 잡는다. $N$개 flow가 있으면 더 정교하게</p>

\[\text{buffer} = \frac{\text{RTT} \cdot C}{\sqrt{N}}\]

<p>를 권장한다. 다만 <strong>버퍼가 지나치게 크면</strong> 지연이 늘어 실시간 앱 성능이 나빠지고 TCP 반응이 둔해진다(“bufferbloat”).</p>

<h3 id="패킷-스케줄링">패킷 스케줄링</h3>

<p>출력 큐에서 다음에 보낼 패킷을 고르는 규칙이 <strong>스케줄링</strong>이다.</p>

<ul>
  <li><strong>FCFS (FIFO)</strong>: 도착 순서대로 전송. 가장 단순하다.</li>
  <li><strong>Priority</strong>: 트래픽을 클래스로 분류해 큐에 나눠 담고, <strong>버퍼된 패킷이 있는 최고 우선순위 큐</strong>부터 전송한다. 같은 클래스 안에서는 FCFS.</li>
  <li><strong>Round Robin</strong>: 클래스 큐를 <strong>순환하며</strong> 각 클래스에서 한 패킷씩 돌아가며 보낸다.</li>
  <li><strong>Weighted Fair Queuing (WFQ)</strong>: round robin의 일반화. 클래스 $i$에 가중치 $w_i$를 주고 매 사이클에서 $\frac{w_i}{\sum_j w_j}$만큼의 서비스를 배분한다. <strong>클래스별 최소 대역폭 보장</strong>이 가능하다.</li>
</ul>

<p>이런 스케줄링·버퍼 관리 메커니즘은 곧 <strong>망 중립성(network neutrality)</strong>의 기술적 토대가 된다. ISP가 자원을 어떻게 배분하느냐(누구를 우선할 것인가)는 기술 문제이자 사회·경제·법 문제다.</p>

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

<p>classful 주소의 두 한계 — 균일 서브넷, 클래스 단위 라우팅 — 는 각각 VLSM과 CIDR로 풀렸다. CIDR은 prefix 길이를 자유롭게 하면서 route aggregation으로 라우팅 테이블을 압축했고, 라우터는 longest prefix matching(TCAM)으로 이를 빠르게 조회한다. IP 포워딩은 hop-by-hop이며 어떤 라우터도 전체 경로를 모른다. 주소 고갈에는 NAT가 실용적으로, IPv6가 근본적으로 대응하되 전환에는 터널링이 다리를 놓는다. 그리고 라우터 내부의 큐잉과 스케줄링이 지연·손실, 나아가 망 중립성까지 결정한다.</p>]]></content><author><name>이주한</name></author><category term="Computer-Network" /><category term="network-layer" /><category term="ip" /><category term="vlsm" /><category term="cidr" /><category term="route-aggregation" /><category term="longest-prefix-matching" /><category term="ip-forwarding" /><category term="nat" /><category term="ipv6-tunneling" /><category term="router-architecture" /><summary type="html"><![CDATA[classful 주소의 한계를 넘어선 VLSM·CIDR와 route aggregation부터, 라우터 내부 구조와 longest prefix matching, IP forwarding 기법, NAT, IPv6 터널링까지 정리한다.]]></summary></entry><entry><title type="html">Memory Hierarchy and Caches — 지역성부터 캐시 사상·성능·가상 메모리까지</title><link href="https://l2juhan.github.io/computer-architecture/2026/06/02/memory-hierarchy-caches.html" rel="alternate" type="text/html" title="Memory Hierarchy and Caches — 지역성부터 캐시 사상·성능·가상 메모리까지" /><published>2026-06-02T00:00:00+00:00</published><updated>2026-06-02T00:00:00+00:00</updated><id>https://l2juhan.github.io/computer-architecture/2026/06/02/memory-hierarchy-caches</id><content type="html" xml:base="https://l2juhan.github.io/computer-architecture/2026/06/02/memory-hierarchy-caches.html"><![CDATA[<p>메모리는 빠를수록 비싸고, 클수록 느리다. 이상적인 메모리는 SRAM의 접근 속도에 디스크의 용량과 단가를 갖지만 그런 물건은 존재하지 않는다. 이 모순을 정면으로 깨는 대신 <strong>여러 종류의 메모리를 계층으로 쌓아 빠른 메모리처럼 보이게 속이는</strong> 것이 메모리 계층(memory hierarchy)의 발상이다.</p>

<h2 id="메모리-기술과-계층의-동기">메모리 기술과 계층의 동기</h2>

<p>메모리 소자는 속도-가격 축에서 줄을 세울 수 있다.</p>

<table>
  <thead>
    <tr>
      <th>기술</th>
      <th>접근 속도</th>
      <th>단가</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>SRAM (Static RAM)</td>
      <td>가장 빠름</td>
      <td>가장 비쌈</td>
    </tr>
    <tr>
      <td>DRAM (Dynamic RAM)</td>
      <td>SRAM보다 느림</td>
      <td>SRAM보다 쌈</td>
    </tr>
    <tr>
      <td>자기 디스크(Magnetic disk)</td>
      <td>DRAM보다 느림</td>
      <td>가장 쌈</td>
    </tr>
  </tbody>
</table>

<p>원하는 것은 <strong>SRAM의 속도 + 디스크의 용량/단가</strong>다. 둘 다 가진 단일 소자는 없으므로, 자주 쓰는 데이터는 빠르고 작은 메모리에, 나머지는 느리고 큰 메모리에 두고 필요할 때 끌어올린다.</p>

<p>이 전략이 통하는 근거가 <strong>지역성의 원리(principle of locality)</strong>다. 프로그램은 임의의 순간에 자기 주소 공간의 작은 일부만 접근한다.</p>

<ul>
  <li><strong>시간 지역성(temporal locality)</strong>: 최근 접근한 항목은 곧 다시 접근될 가능성이 높다. 예: 루프 안의 명령어.</li>
  <li><strong>공간 지역성(spatial locality)</strong>: 최근 접근한 항목 근처는 곧 접근될 가능성이 높다. 예: 순차적 명령어 실행, 배열 데이터.</li>
</ul>

<p>자주 쓰는 작은 영역만 빠른 메모리에 올려두면 대부분의 접근이 빠른 메모리에서 끝난다. 계층이 동작하는 이유가 여기에 있다.</p>

<h2 id="계층의-동작과-용어">계층의 동작과 용어</h2>

<p>데이터를 계층 간에 옮기는 단위를 <strong>블록(block)</strong> 또는 <strong>라인(line)</strong>이라 한다. 한 블록은 여러 워드일 수 있다.</p>

<p>CPU가 어떤 데이터를 요청했을 때:</p>

<ul>
  <li><strong>적중(hit)</strong>: 데이터가 상위 계층에 있어 바로 처리된다. 전체 접근 중 적중 비율이 적중률(hit ratio)이다.</li>
  <li><strong>실패(miss)</strong>: 데이터가 없어서 하위 계층에서 블록을 복사해 온다. 이때 걸리는 시간이 <strong>실패 페널티(miss penalty)</strong>다. 실패율(miss ratio)은 $1 - \text{hit ratio}$이고, 블록을 올린 뒤 상위 계층에서 데이터를 공급한다.</li>
</ul>

<p>가장 위, CPU에 가장 가까운 계층이 <strong>캐시 메모리(cache memory)</strong>다. 여기서 두 가지 질문이 생긴다. 데이터가 캐시에 있는지 어떻게 아는가. 있다면 어디를 봐야 하는가. 이 질문의 답이 캐시 사상(mapping) 방식이다.</p>

<h2 id="직접-사상-캐시-direct-mapped-cache">직접 사상 캐시 (Direct Mapped Cache)</h2>

<p>가장 단순한 답은 <strong>각 메모리 블록이 들어갈 캐시 위치를 주소로 딱 하나 정하는</strong> 것이다. 위치는 다음으로 결정된다.</p>

\[\text{Cache index} = (\text{Block address}) \bmod (\text{캐시의 블록 수})\]

<p>캐시 블록 수가 2의 거듭제곱이면 modulo 연산은 <strong>주소의 하위 비트(low-order bits)를 그대로 인덱스로 쓰는</strong> 것과 같다.</p>

<p><img src="/assets/images/posts/memory-hierarchy-caches/memory-hierarchy-caches-1.png" alt="직접 사상 캐시 매핑" /></p>

<p>위 그림에서 메모리 블록 <code class="language-plaintext highlighter-rouge">00001</code>, <code class="language-plaintext highlighter-rouge">01001</code>, <code class="language-plaintext highlighter-rouge">10001</code>, <code class="language-plaintext highlighter-rouge">11001</code>은 모두 하위 3비트가 <code class="language-plaintext highlighter-rouge">001</code>이라 같은 캐시 슬롯으로 사상된다. 즉 여러 블록이 한 자리를 두고 경쟁한다.</p>

<h3 id="태그와-유효-비트">태그와 유효 비트</h3>

<p>한 캐시 위치에 들어올 수 있는 블록이 여럿이므로, <strong>지금 들어있는 게 어떤 블록인지</strong> 구분해야 한다.</p>

<ul>
  <li><strong>태그(tag)</strong>: 블록 주소에서 인덱스로 쓰지 않은 상위 비트. 캐시에 데이터와 함께 저장해 두고 비교한다.</li>
  <li><strong>유효 비트(valid bit)</strong>: 그 자리에 의미 있는 데이터가 있는지 표시한다. <code class="language-plaintext highlighter-rouge">1</code>이면 present, <code class="language-plaintext highlighter-rouge">0</code>이면 비어 있음. 초기값은 <code class="language-plaintext highlighter-rouge">0</code>이다.</li>
</ul>

<h3 id="동작-예시">동작 예시</h3>

<p>8블록, 1워드/블록 직접 사상 캐시를 빈 상태에서 시작해 워드 주소를 차례로 접근한다고 하자. 주소의 하위 3비트가 인덱스, 상위 2비트가 태그다.</p>

<table>
  <thead>
    <tr>
      <th>워드 주소</th>
      <th>2진 주소</th>
      <th>인덱스</th>
      <th>태그</th>
      <th>결과</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>22</td>
      <td><code class="language-plaintext highlighter-rouge">10 110</code></td>
      <td>110</td>
      <td>10</td>
      <td>Miss → 적재</td>
    </tr>
    <tr>
      <td>26</td>
      <td><code class="language-plaintext highlighter-rouge">11 010</code></td>
      <td>010</td>
      <td>11</td>
      <td>Miss → 적재</td>
    </tr>
    <tr>
      <td>22</td>
      <td><code class="language-plaintext highlighter-rouge">10 110</code></td>
      <td>110</td>
      <td>10</td>
      <td>Hit</td>
    </tr>
    <tr>
      <td>26</td>
      <td><code class="language-plaintext highlighter-rouge">11 010</code></td>
      <td>010</td>
      <td>11</td>
      <td>Hit</td>
    </tr>
    <tr>
      <td>16</td>
      <td><code class="language-plaintext highlighter-rouge">10 000</code></td>
      <td>000</td>
      <td>10</td>
      <td>Miss → 적재</td>
    </tr>
    <tr>
      <td>3</td>
      <td><code class="language-plaintext highlighter-rouge">00 011</code></td>
      <td>011</td>
      <td>00</td>
      <td>Miss → 적재</td>
    </tr>
    <tr>
      <td>16</td>
      <td><code class="language-plaintext highlighter-rouge">10 000</code></td>
      <td>000</td>
      <td>10</td>
      <td>Hit</td>
    </tr>
    <tr>
      <td>18</td>
      <td><code class="language-plaintext highlighter-rouge">10 010</code></td>
      <td>010</td>
      <td>10</td>
      <td>Miss → <strong>교체</strong></td>
    </tr>
  </tbody>
</table>

<p>마지막 접근이 핵심이다. 주소 18(<code class="language-plaintext highlighter-rouge">10 010</code>)은 인덱스 <code class="language-plaintext highlighter-rouge">010</code>으로 가는데, 그 자리에는 이미 주소 26(<code class="language-plaintext highlighter-rouge">11 010</code>, 태그 <code class="language-plaintext highlighter-rouge">11</code>)이 있다. 태그가 <code class="language-plaintext highlighter-rouge">10</code>과 <code class="language-plaintext highlighter-rouge">11</code>로 다르므로 실패가 나고, 기존 블록을 쫓아내고 새 블록을 채운다. 직접 사상에서는 선택지가 없으므로 무조건 그 자리를 덮어쓴다.</p>

<h2 id="주소의-분할">주소의 분할</h2>

<p>블록이 여러 바이트/워드를 담으면 주소는 여러 필드로 나뉜다. 1워드(4바이트) 블록이면 하위 2비트는 워드 안에서 바이트를 고르는 <strong>바이트 오프셋(byte offset)</strong>이고, 그 위가 인덱스, 나머지 상위가 태그다.</p>

<p><img src="/assets/images/posts/memory-hierarchy-caches/memory-hierarchy-caches-2.png" alt="Address Subdivision" /></p>

<p>위는 1024(=$2^{10}$)개 엔트리, 1워드 블록인 캐시다. 32비트 주소에서 하위 2비트는 바이트 오프셋, 다음 10비트는 인덱스(어느 줄을 볼지), 상위 20비트는 태그다. 인덱스로 한 줄을 고른 뒤, 그 줄의 태그와 주소의 태그가 같고 유효 비트가 <code class="language-plaintext highlighter-rouge">1</code>이면 적중이다. 이 둘을 AND로 묶은 것이 최종 Hit 신호다.</p>

<p>블록이 여러 워드면 인덱스와 바이트 오프셋 사이에 <strong>블록 오프셋(block offset)</strong>이 추가되어 블록 안의 어느 워드인지 고른다.</p>

<h2 id="블록-크기와-실패율">블록 크기와 실패율</h2>

<p>블록을 키우면 한 번 실패할 때 주변 데이터까지 함께 끌어와 <strong>공간 지역성을 더 활용</strong>한다. 그래서 처음에는 실패율이 떨어진다. 하지만 블록을 무한정 키울 수는 없다.</p>

<p><img src="/assets/images/posts/memory-hierarchy-caches/memory-hierarchy-caches-3.png" alt="Miss rate vs Block Size" /></p>

<p>캐시 전체 크기가 고정된 상태에서 블록을 너무 키우면 캐시에 담기는 블록 수가 줄어, 서로 다른 데이터가 같은 슬롯을 두고 다투는 경쟁이 심해진다(오염, pollution). 그 결과 어느 지점을 지나면 실패율이 다시 올라간다. 그래프의 최저점이 <strong>sweet spot</strong>이다. 게다가 블록이 크면 실패 시 옮길 데이터가 많아 실패 페널티도 커진다.</p>

<h2 id="쓰기-정책">쓰기 정책</h2>

<p>읽기는 캐시 내용을 바꾸지 않지만, 쓰기는 캐시와 메모리의 내용을 어긋나게(inconsistent) 만들 수 있다. 두 가지 정책이 있다.</p>

<p><strong>쓰기 통과(Write-through)</strong>: 캐시에 쓸 때 메모리에도 동시에 쓴다. 항상 일관되지만 매 쓰기가 느린 메모리 접근을 동반한다. 해결책은 <strong>쓰기 버퍼(write buffer)</strong>다. 메모리에 쓸 데이터를 버퍼에 넣어두면 CPU는 즉시 다음 일을 진행하고, 버퍼가 가득 찰 때만 멈춘다.</p>

<p><strong>쓰기 후 기록(Write-back)</strong>: 쓰기 적중 시 캐시 블록만 갱신하고 <strong>더티 비트(dirty bit)</strong>를 세운다. 메모리 갱신은 미룬다. 더티 블록이 교체될 때 비로소 메모리에 기록한다. 이때도 쓰기 버퍼를 쓰면 들어올 블록을 먼저 읽고 쫓겨나는 블록은 나중에 써넣을 수 있다.</p>

<h2 id="캐시-성능-측정">캐시 성능 측정</h2>

<p>CPU 시간은 실행 사이클과 메모리 지연(stall) 사이클로 나뉜다.</p>

\[\text{CPU time} = (\text{CPU 실행 사이클} + \text{메모리 stall 사이클}) \times \text{클럭 주기}\]

<p>메모리 stall 사이클은 주로 캐시 실패에서 온다.</p>

\[\text{Memory stall cycles} = \frac{\text{Memory accesses}}{\text{Program}} \times \text{Miss rate} \times \text{Miss penalty}\]

<p>명령어 기준으로 다시 쓰면 다음과 같다.</p>

\[= \frac{\text{Instructions}}{\text{Program}} \times \frac{\text{Misses}}{\text{Instruction}} \times \text{Miss penalty}\]

<p>여기서 중요한 경향이 나온다. CPU가 빨라지거나(base CPI 감소), 클럭이 올라갈수록 <strong>실패 페널티가 전체 성능에서 차지하는 비중이 커진다</strong>. 같은 실패 페널티라도 빠른 CPU에서는 더 많은 사이클을 까먹기 때문이다. 그래서 시스템 성능을 평가할 때 캐시 동작을 무시할 수 없다.</p>

<h2 id="연관-사상-associative-cache">연관 사상 (Associative Cache)</h2>

<p>직접 사상의 약점은 <strong>한 블록이 갈 자리가 하나뿐</strong>이라는 점이다. 서로 다른 블록이 같은 인덱스로 몰리면 캐시에 빈 자리가 남아도 계속 충돌한다. 이를 완화하는 것이 연관 사상이다.</p>

<ul>
  <li><strong>완전 연관(fully associative)</strong>: 블록이 캐시의 <strong>아무 자리에나</strong> 들어갈 수 있다. 찾을 때는 모든 엔트리를 동시에 비교해야 하므로 엔트리마다 비교기가 필요해 비싸다.</li>
  <li><strong>n-way 집합 연관(set associative)</strong>: 캐시를 여러 집합(set)으로 나누고, 각 집합에 $n$개의 엔트리를 둔다. 블록이 갈 집합은 정해지지만 집합 안에서는 어느 자리든 가능하다.</li>
</ul>

\[\text{Set index} = (\text{Block number}) \bmod (\text{캐시의 집합 수})\]

<p>집합 안의 $n$개만 동시에 비교하면 되므로 비교기가 $n$개로 줄어 완전 연관보다 싸다.</p>

<p><img src="/assets/images/posts/memory-hierarchy-caches/memory-hierarchy-caches-4.png" alt="연관 사상 캐시 3종 비교" /></p>

<p>같은 블록을 찾을 때, 직접 사상은 한 곳만, 집합 연관은 한 집합 안만, 완전 연관은 전체를 탐색한다. 직접 사상은 1-way 집합 연관, 완전 연관은 (엔트리 수)-way 집합 연관으로 볼 수 있다. 즉 세 방식은 연속된 스펙트럼의 양 끝과 중간이다.</p>

<h3 id="충돌-실패-비교">충돌 실패 비교</h3>

<p>블록 접근 순서가 <code class="language-plaintext highlighter-rouge">0, 4, 0, 4, ...</code>이고, 4블록 캐시에서 블록 0과 4가 같은 자리(또는 같은 집합)로 사상된다고 하자.</p>

<ul>
  <li><strong>직접 사상</strong>: 0과 4가 같은 슬롯을 두고 매번 서로를 쫓아낸다. 8번 요청에 8번 실패.</li>
  <li><strong>2-way 집합 연관</strong>: 0과 4가 같은 집합 안의 서로 다른 두 자리에 공존한다. 8번 요청에 2번 실패(처음 두 번의 강제 실패뿐).</li>
</ul>

<p>빈 공간이 있는데도 자리 경쟁으로 발생하는 이런 실패를 <strong>충돌 실패(conflict miss)</strong>라 한다. 연관도를 높이면 충돌 실패가 줄어든다. 다만 효과는 체감한다 — 64KB 데이터 캐시, 16워드 블록 기준 시뮬레이션에서 실패율은 1-way 10.3% → 2-way 8.6% → 4-way 8.3% → 8-way 8.1%로, 연관도를 올릴수록 개선폭이 작아진다.</p>

<h3 id="집합-연관-캐시의-구조">집합 연관 캐시의 구조</h3>

<p><img src="/assets/images/posts/memory-hierarchy-caches/memory-hierarchy-caches-5.png" alt="Set Associative Cache Organization" /></p>

<p>4-way 집합 연관의 구조다. 인덱스로 각 way에서 한 줄씩 고른 뒤, 네 way의 태그를 주소 태그와 <strong>동시에 비교</strong>한다. 일치하면서 유효한 way가 있으면 적중이고, 그 way의 데이터를 멀티플렉서로 골라 내보낸다. 비교기가 way 수만큼 병렬로 동작하는 것이 직접 사상과의 차이다.</p>

<h3 id="교체-정책">교체 정책</h3>

<p>직접 사상은 갈 자리가 하나라 교체할 게 없다. 집합 연관은 집합 안에서 누구를 쫓아낼지 골라야 한다.</p>

<ul>
  <li>우선 <strong>유효하지 않은(빈) 엔트리</strong>가 있으면 그곳을 쓴다.</li>
  <li>다 차 있으면 <strong>LRU(Least Recently Used)</strong>, 즉 가장 오래 안 쓰인 엔트리를 교체한다. 2-way는 구현이 간단하고 4-way까지는 관리할 만하지만, 그 이상은 추적 비용이 너무 커진다.</li>
  <li><strong>Random</strong>: 무작위 교체. 연관도가 높으면 LRU와 성능이 거의 비슷해 구현이 쉬운 쪽을 택할 수 있다.</li>
</ul>

<h2 id="다단계-캐시-multilevel-cache">다단계 캐시 (Multilevel Cache)</h2>

<p>빠른 CPU에서는 단일 캐시만으로 실패 페널티를 감당하기 어렵다. 그래서 캐시를 여러 단계로 둔다.</p>

<ul>
  <li><strong>L1(primary) 캐시</strong>: CPU에 붙어 있고 작지만 빠르다. <strong>적중 시간(hit time) 최소화</strong>에 집중한다. 그래서 작게, 블록도 작게 만들어 실패 페널티를 줄인다.</li>
  <li><strong>L2 캐시</strong>: L1 실패를 처리한다. 더 크고 느리지만 메인 메모리보다는 빠르다. <strong>실패율을 낮춰 메인 메모리 접근을 피하는 것</strong>에 집중한다. 적중 시간의 영향은 상대적으로 작다.</li>
  <li>고급 시스템은 <strong>L3 캐시</strong>까지 둔다.</li>
</ul>

<p>설계 방향이 단계마다 다르다는 점이 핵심이다. L1은 속도, L2는 낮은 실패율. 그래서 보통 <strong>L1 블록이 L2 블록보다 작다</strong>.</p>

<h3 id="성능-효과-예시">성능 효과 예시</h3>

<p>base CPI = 1, 클럭 4GHz(주기 0.25ns), 명령어당 실패율 2%, 메인 메모리 접근 100ns인 시스템을 보자.</p>

<p><strong>L1만 있을 때</strong>: 실패 페널티 = $100\text{ns} / 0.25\text{ns} = 400$ 사이클.</p>

\[\text{Effective CPI} = 1 + 0.02 \times 400 = 9\]

<p><strong>L2 추가</strong>(접근 5ns, 메인 메모리까지 가는 전역 실패율 0.5%): L1 실패하고 L2 적중하면 페널티 = $5\text{ns}/0.25\text{ns} = 20$ 사이클, L2까지 실패하면 추가로 400 사이클.</p>

\[\text{CPI} = 1 + 0.02 \times 20 + 0.005 \times 400 = 3.4\]

<p>성능비는 $9 / 3.4 \approx 2.6$배. L2 하나를 끼워 넣는 것만으로 2.6배 빨라진다.</p>

<h2 id="메모리-대역폭-높이기">메모리 대역폭 높이기</h2>

<p>실패 페널티는 메모리에서 블록을 끌어오는 시간이 좌우한다. 메모리 1버스 사이클(주소 전송) + DRAM 접근 15사이클 + 1사이클(워드 전송), 4워드 블록 조건에서:</p>

<ul>
  <li><strong>1워드 폭 메모리</strong>: 4워드를 한 워드씩 가져온다. 페널티 = $1 + 4 \times 15 + 4 \times 1 = 65$ 사이클.</li>
  <li><strong>4워드 폭 메모리</strong>: 4워드를 한 번에 접근. 페널티 = $1 + 15 + 1 = 17$ 사이클.</li>
  <li><strong>4뱅크 인터리브 메모리</strong>: 뱅크를 병렬 접근하고 워드는 순차 전송. 페널티 = $1 + 15 + 4 \times 1 = 20$ 사이클.</li>
</ul>

<p>폭을 넓히거나 뱅크를 나눠 병렬화하면 같은 블록을 훨씬 적은 사이클에 옮겨 실패 페널티를 줄인다.</p>

<h2 id="가상-메모리-virtual-memory">가상 메모리 (Virtual Memory)</h2>

<p>같은 계층 아이디어를 한 단계 더 적용한 것이 가상 메모리다. <strong>메인 메모리를 디스크(보조 저장장치)의 캐시처럼</strong> 쓴다. CPU 하드웨어와 OS가 함께 관리하며, 실제보다 큰 물리 메모리를 가진 듯한 환상을 준다.</p>

<ul>
  <li>각 프로그램은 자신만의 <strong>가상 주소 공간</strong>을 가져 다른 프로그램으로부터 보호된다.</li>
  <li>CPU와 OS가 가상 주소를 물리 주소로 변환한다.</li>
  <li>가상 메모리의 “블록”을 <strong>페이지(page)</strong>라 하고, 변환 “실패”를 <strong>페이지 폴트(page fault)</strong>라 한다.</li>
</ul>

<h3 id="주소-변환">주소 변환</h3>

<p>페이지는 고정 크기(예: 4KB)다. 가상 주소는 <strong>가상 페이지 번호(virtual page number)</strong>와 <strong>페이지 오프셋(page offset)</strong>으로 나뉜다. 변환은 페이지 번호만 물리 페이지 번호로 바꾸고, 오프셋은 그대로 둔다.</p>

<p><img src="/assets/images/posts/memory-hierarchy-caches/memory-hierarchy-caches-6.png" alt="Address Translation" /></p>

<p>페이지 폴트는 데이터를 디스크에서 가져와야 하므로 수백만 사이클이 걸리고 OS 코드가 처리한다. 페널티가 워낙 커서, 폴트율을 최대한 낮추는 쪽으로 설계한다 — 완전 연관 배치를 쓰고(어떤 페이지든 어느 프레임에나) 똑똑한 교체 알고리즘을 둔다.</p>

<h3 id="페이지-테이블">페이지 테이블</h3>

<p>가상 페이지가 물리 메모리 어디에 있는지를 담는 자료구조가 <strong>페이지 테이블(page table)</strong>이다.</p>

<ul>
  <li>가상 페이지 번호로 인덱싱하는 <strong>페이지 테이블 엔트리(PTE)</strong> 배열이다.</li>
  <li>CPU의 <strong>페이지 테이블 레지스터</strong>가 물리 메모리에 있는 페이지 테이블의 위치를 가리킨다.</li>
  <li>페이지가 메모리에 있으면 PTE는 물리 페이지 번호와 상태 비트(referenced, dirty 등)를 담는다.</li>
  <li>없으면 PTE는 디스크 스왑 공간의 위치를 가리킨다.</li>
</ul>

<p><img src="/assets/images/posts/memory-hierarchy-caches/memory-hierarchy-caches-7.png" alt="Translation Using a Page Table" /></p>

<p>가상 페이지 번호로 페이지 테이블을 인덱싱해 PTE를 읽고, 유효 비트가 <code class="language-plaintext highlighter-rouge">1</code>이면 물리 페이지 번호를 꺼내 페이지 오프셋과 합쳐 물리 주소를 만든다. 유효 비트가 <code class="language-plaintext highlighter-rouge">0</code>이면 그 페이지는 메모리에 없다 — 페이지 폴트다.</p>

<h3 id="tlb로-변환-가속">TLB로 변환 가속</h3>

<p>문제가 하나 있다. 주소 변환마다 페이지 테이블을 읽으려면 <strong>메모리 접근이 한 번 더</strong> 든다(PTE 읽기 + 실제 데이터 접근). 하지만 페이지 테이블 접근에도 지역성이 있다. 그래서 최근 사용한 PTE를 CPU 안의 작고 빠른 캐시에 둔다. 이것이 <strong>TLB(Translation Look-aside Buffer)</strong>다.</p>

<p><img src="/assets/images/posts/memory-hierarchy-caches/memory-hierarchy-caches-8.png" alt="Fast Translation Using a TLB" /></p>

<p>전형적으로 16~512개 PTE를 담고, 적중에 0.5~1사이클, 실패에 10~100사이클, 실패율 0.01~1% 수준이다. 변환할 때 먼저 TLB를 보고, 있으면(TLB hit) 즉시 물리 페이지 번호를 얻는다. TLB 실패 시:</p>

<ul>
  <li><strong>페이지가 메모리에 있으면</strong>: 메모리의 페이지 테이블에서 PTE를 가져와 TLB에 채우고 재시도한다. 하드웨어로 처리할 수 있다.</li>
  <li><strong>페이지가 메모리에 없으면(페이지 폴트)</strong>: OS가 디스크에서 페이지를 가져오고 페이지 테이블을 갱신한 뒤, 폴트를 낸 명령어부터 다시 실행한다.</li>
</ul>

<p>페이지 폴트 핸들러는 폴트 가상 주소로 PTE를 찾고 → 디스크에서 페이지 위치를 알아내고 → 쫓아낼 페이지를 고르고(더티면 디스크에 먼저 기록) → 페이지를 메모리에 읽어 페이지 테이블을 갱신하고 → 프로세스를 다시 실행 가능하게 만든다.</p>

<h3 id="tlb와-캐시의-상호작용">TLB와 캐시의 상호작용</h3>

<p><img src="/assets/images/posts/memory-hierarchy-caches/memory-hierarchy-caches-9.png" alt="TLB and Cache Interaction" /></p>

<p>실제 접근은 두 단계를 거친다. 가상 주소 → (TLB) → 물리 주소 → (캐시) → 데이터. 가상 페이지 번호로 TLB를 조회해 물리 페이지 번호를 얻어 물리 주소를 완성하고, 그 물리 주소로 캐시를 인덱싱해 태그를 비교한다. TLB 적중과 캐시 적중이 모두 맞아야 메모리까지 가지 않고 데이터를 얻는다.</p>

<h2 id="계층을-관통하는-네-가지-질문">계층을 관통하는 네 가지 질문</h2>

<p>캐시든 가상 메모리든 메모리 계층은 같은 네 가지 질문으로 정리된다.</p>

<p><strong>블록을 어디에 두는가 / 어떻게 찾는가.</strong></p>

<table>
  <thead>
    <tr>
      <th>사상 방식</th>
      <th>탐색 방법</th>
      <th>태그 비교 횟수</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>직접 사상</td>
      <td>인덱스로 한 곳</td>
      <td>1</td>
    </tr>
    <tr>
      <td>n-way 집합 연관</td>
      <td>집합 인덱스 후 집합 내 탐색</td>
      <td>$n$</td>
    </tr>
    <tr>
      <td>완전 연관</td>
      <td>전체 탐색</td>
      <td>엔트리 수</td>
    </tr>
  </tbody>
</table>

<p>하드웨어 캐시는 비교 횟수를 줄여 비용을 낮추므로 직접/집합 연관을 쓴다. 반면 가상 메모리는 페이지 테이블이라는 전체 룩업 테이블이 있어 <strong>완전 연관</strong>이 가능하고, 그만큼 실패(폴트)율을 낮추는 이득이 크다.</p>

<p><strong>교체는 누구를.</strong> LRU가 이상적이지만 연관도가 높으면 하드웨어가 복잡하고 비싸다. 그래서 캐시는 Random으로 근사하기도 한다. 가상 메모리는 하드웨어 지원(reference 비트 등)으로 LRU를 근사한다.</p>

<p><strong>쓰기는 어떻게.</strong> 쓰기 통과(상하위 동시 갱신, 쓰기 버퍼 필요)와 쓰기 후 기록(상위만 갱신, 교체 시 하위 기록, 상태 더 보관)이 있다. 가상 메모리는 디스크 쓰기 지연이 커서 <strong>쓰기 후 기록만</strong> 현실적이다.</p>

<h2 id="실패의-세-가지-원인">실패의 세 가지 원인</h2>

<p>실패율을 분석할 때 원인을 셋으로 나눈다(3C).</p>

<ul>
  <li><strong>강제 실패(compulsory miss, cold start)</strong>: 블록에 처음 접근할 때. 피할 수 없다.</li>
  <li><strong>용량 실패(capacity miss)</strong>: 캐시가 유한해서, 쫓겨난 블록을 나중에 다시 찾을 때.</li>
  <li><strong>충돌 실패(conflict miss, collision)</strong>: 완전 연관이 아닌 캐시에서 같은 집합을 두고 다툴 때. <strong>같은 크기의 완전 연관 캐시였다면 안 났을</strong> 실패다.</li>
</ul>

<p>이 분류는 설계 선택과 직결된다.</p>

<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>
  </tbody>
</table>

<p>어느 하나를 공짜로 개선할 수 없고 항상 다른 비용을 치른다는 점이 캐시 설계의 본질이다.</p>

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

<p>빠르고 큰 메모리는 없지만, 작고 빠른 메모리(L1 ↔ L2 ↔ DRAM ↔ disk)를 계층으로 쌓고 지역성을 활용하면 그런 메모리가 있는 것처럼 동작한다. 캐시는 사상 방식(직접/집합 연관/완전 연관), 쓰기 정책(통과/후기록), 교체 정책(LRU/Random)으로 설계되고, 그 효과는 실패율과 실패 페널티로 정량화된다. 가상 메모리는 같은 원리를 메인 메모리-디스크 사이에 적용한 것이며, 페이지 테이블과 TLB가 변환을 담당한다. 멀티프로세서로 갈수록 이 메모리 시스템 설계의 중요성은 더 커진다.</p>]]></content><author><name>이주한</name></author><category term="Computer-Architecture" /><category term="computer-architecture" /><category term="memory-hierarchy" /><category term="cache" /><category term="locality" /><category term="direct-mapped" /><category term="set-associative" /><category term="write-through" /><category term="write-back" /><category term="cache-performance" /><category term="virtual-memory" /><category term="page-table" /><category term="tlb" /><summary type="html"><![CDATA[빠른 메모리는 작고 큰 메모리는 느리다는 딜레마를 메모리 계층과 캐시로 푸는 방법, 직접/연관 사상과 쓰기 정책, 캐시 성능 분석, 그리고 가상 메모리와 TLB까지 정리한다.]]></summary></entry><entry><title type="html">Virtual Memory — 부분 적재부터 페이지 교체·TLB·계층적 페이지 테이블까지</title><link href="https://l2juhan.github.io/operating-system/2026/06/01/virtual-memory.html" rel="alternate" type="text/html" title="Virtual Memory — 부분 적재부터 페이지 교체·TLB·계층적 페이지 테이블까지" /><published>2026-06-01T00:00:00+00:00</published><updated>2026-06-01T00:00:00+00:00</updated><id>https://l2juhan.github.io/operating-system/2026/06/01/virtual-memory</id><content type="html" xml:base="https://l2juhan.github.io/operating-system/2026/06/01/virtual-memory.html"><![CDATA[<p>지금까지 메모리 관리는 프로세스 전체가 메모리에 올라가 있다고 가정했다. 하지만 프로그램의 모든 코드와 데이터가 동시에 필요한 경우는 거의 없다. 에러 처리 루틴이나 드물게 호출되는 함수까지 전부 메모리를 차지할 이유가 없다는 뜻이다. 그렇다면 <strong>필요한 부분만 메모리에 올리고 나머지는 디스크에 둔 채 실행</strong>하면 어떨까. 이 아이디어가 가상 메모리(virtual memory)다.</p>

<h2 id="동기와-근거">동기와 근거</h2>

<p>프로세스를 부분만 적재해도 실행이 가능한 근거는 <strong>지역성(locality)</strong>이다. 코드와 데이터 참조는 시간적·공간적으로 한곳에 뭉치는 경향이 있다. 어떤 시점에 실제로 필요한 페이지는 프로세스 전체 중 극히 일부다. Knuth의 추정에 따르면 실행 시간의 90%가 코드의 10%에서 소비된다.</p>

<p>즉, 전체를 올릴 필요 없이 “지금 참조되는 영역”만 메모리에 있으면 대부분의 시간 동안 문제없이 돌아간다. 이 관찰이 가상 메모리가 실제로 동작하는 이유다. 오늘날 거의 모든 OS는 <strong>페이징(paging)</strong> 또는 <strong>페이징 + 세그먼테이션</strong>에 기반한 가상 메모리를 핵심 구성요소로 쓴다.</p>

<h2 id="가상-메모리란">가상 메모리란</h2>

<p>가상 메모리는 <strong>프로세스가 물리 메모리에 일부만 상주(partially resident)</strong>하도록 허용하는 메모리 관리 기법이다. 정확히는 두 가지 개념을 합친다.</p>

<ul>
  <li><strong>분산 적재</strong>: 프로세스의 페이지들이 물리 메모리 곳곳에 흩어져 배치된다.</li>
  <li><strong>부분 적재</strong>: 프로세스의 일부 페이지만 메모리에 올라가고, 나머지는 보조 저장장치(디스크)에 남는다.</li>
</ul>

<p>핵심은 <strong>보조 저장장치를 마치 주기억장치의 일부인 것처럼 주소 지정</strong>할 수 있다는 점이다. 이로부터 세 가지가 따라온다.</p>

<ul>
  <li>더 많은 프로세스를 동시에 메모리에 유지할 수 있다.</li>
  <li>프로세스 크기가 물리 메모리 크기를 초과해도 된다.</li>
  <li>프로그램 입장에서는 거의 무한한 메모리가 있는 듯한 <strong>착각(illusion)</strong>을 갖는다.</li>
</ul>

<p><img src="/assets/images/posts/virtual-memory/virtual-memory-1.png" alt="가상 메모리 개념: 각 프로세스의 가상 주소 공간이 물리 주소 공간과 swap 공간으로 매핑된다" /></p>

<p>각 프로세스는 자신만의 가상 주소 공간(virtual address space)을 가진다. 이 논리 주소는 <strong>간접 참조(indirection)</strong>를 거쳐 물리(실제) 주소로 변환되며, 메모리에 없는 페이지는 swap 공간(디스크)에 위치한다. 가상 주소 공간 = 주기억장치 + 보조기억장치인 셈이다.</p>

<h3 id="가상-메모리가-주는-이점">가상 메모리가 주는 이점</h3>

<ul>
  <li><strong>더 많은 프로세스 상주</strong>: 각 프로그램이 실행 중 더 적은 메모리를 쓰므로 동시 실행 가능한 프로그램 수가 늘어난다.</li>
  <li><strong>물리 메모리보다 큰 프로세스</strong>: 논리 주소 공간이 물리 주소 공간보다 훨씬 클 수 있다.</li>
  <li><strong>보호와 격리(protection &amp; isolation)</strong>: 프로그래머가 보는 메모리 관점과 시스템이 보는 관점을 분리한다.</li>
  <li><strong>효율적인 공유 메모리와 IPC</strong>: 동적 라이브러리 같은 공유가 쉬워진다.</li>
  <li><strong>적재 I/O 감소</strong>: 쓰지 않는 부분은 swap-in 하지 않으므로 시간이 절약된다.</li>
</ul>

<h2 id="가상-메모리를-위한-os-정책">가상 메모리를 위한 OS 정책</h2>

<p>부분 적재된 페이지 집합을 OS가 어떻게 관리하느냐가 가상 메모리의 성능을 좌우한다. 관리 정책은 다섯 가지 축으로 나뉜다.</p>

<table>
  <thead>
    <tr>
      <th>정책</th>
      <th>결정하는 것</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><strong>Fetch Policy</strong> (반입 정책)</td>
      <td>페이지를 <em>언제</em> 메모리로 가져올지</td>
    </tr>
    <tr>
      <td><strong>Placement Policy</strong> (배치 정책)</td>
      <td>페이지를 메모리의 <em>어디에</em> 둘지</td>
    </tr>
    <tr>
      <td><strong>Replacement Policy</strong> (교체 정책)</td>
      <td>빈 프레임이 없을 때 <em>어떤</em> 페이지를 내보낼지</td>
    </tr>
    <tr>
      <td><strong>Resident Set Management</strong> (상주 집합 관리)</td>
      <td>프로세스마다 <em>몇 개의</em> 프레임을 줄지</td>
    </tr>
    <tr>
      <td><strong>Cleaning Policy</strong> (클리닝 정책)</td>
      <td>수정된 페이지를 <em>언제</em> 디스크에 쓸지</td>
    </tr>
  </tbody>
</table>

<p>아래에서 하나씩 본다.</p>

<h2 id="fetch-policy--반입-정책">Fetch Policy — 반입 정책</h2>

<p>페이지를 <strong>언제 메모리로 가져올지</strong> 결정한다.</p>

<ul>
  <li><strong>요구 페이징(demand paging)</strong>: 페이지가 실제로 참조될 때만 가져온다. 프로세스 시작 직후에는 페이지 폴트(page fault)가 자주 발생하지만, 시간이 지나면 시스템이 안정화되어 폴트 빈도가 매우 낮은 수준으로 떨어진다.</li>
  <li><strong>선반입(pre-paging)</strong>: 예측에 기반해 필요해지기 <em>전에</em> 미리 가져온다. 가져온 페이지가 쓰이지 않으면 I/O 오버헤드와 비효율만 남는다. 보통 다음 페이지들이 순차적으로 접근될 가능성이 높을 때만 쓴다.</li>
</ul>

<p>현대 OS는 기본적으로 <strong>요구 페이징</strong>을 쓰되, 상황에 따라 선반입을 결합한다.</p>

<h2 id="placement-policy--배치-정책">Placement Policy — 배치 정책</h2>

<p>페이지를 <strong>메모리의 어디에</strong> 둘지 결정한다.</p>

<ul>
  <li>순수 세그먼테이션 시스템에서는 first-fit, next-fit, best-fit 같은 알고리즘이 의미를 갖는다.</li>
  <li>순수 페이징(또는 페이징 + 세그먼테이션)에서는 <strong>배치 정책이 일반적으로 덜 중요하다</strong>. 모든 페이지 크기가 같으므로 어떤 페이지든 어떤 프레임에든 들어갈 수 있어, 배치가 성능에 거의 영향을 주지 않는다.</li>
  <li>단, <strong>이종 메모리(heterogeneous memory)</strong>에서는 배치가 다시 관심사가 된다. 대표적으로 NUMA(Non-Uniform Memory Access) 구조가 그렇다.</li>
</ul>

<h2 id="replacement-policy--교체-정책">Replacement Policy — 교체 정책</h2>

<p>빈 프레임이 없거나 가용 메모리가 OS가 정한 워터마크(watermark) 아래로 떨어지면, <strong>메모리의 어떤 페이지를 내보낼지(reclaim)</strong> 결정해야 한다.</p>

<p>교체 정책의 목표는 <strong>가까운 미래에 참조될 가능성이 가장 낮은 페이지를 쫓아내는 것</strong>이다. 이는 다음 가정에 기댄다.</p>

<ul>
  <li>최근에 사용된 페이지는 곧 다시 사용될 가능성이 높다.</li>
  <li>최근 사용 이력과 미래 접근 사이에 높은 상관관계가 있다.</li>
  <li>즉, <strong>과거 행동(past behavior)을 근거로 미래 행동(future behavior)을 예측</strong>한다.</li>
</ul>

<p>그래서 대부분의 정책은 현재 상주 중인 페이지 중 <strong>가장 오랫동안 참조되지 않은(least recently used)</strong> 페이지를 고르려 한다.</p>

<p>한 가지 제약이 있다. <strong>프레임 잠금(frame locking)</strong>이다. 일부 프레임은 잠겨서 교체 대상에서 제외된다. OS는 물리 프레임을 명시적으로 잠그고 푸는 메커니즘을 제공하며, OS 커널의 상당 부분과 핵심 제어 구조는 잠긴 프레임에 보관된다.</p>

<h3 id="hotness--페이지의-뜨거움">Hotness — 페이지의 뜨거움</h3>

<p><strong>Hotness</strong>는 페이지가 얼마나 최근에(혹은 자주) 접근되었는지를 가리킨다.</p>

<ul>
  <li><strong>Hot page</strong>: 최근에 또는 자주 접근된 페이지. UMA에서는 메모리에 유지하고, NUMA에서는 가까운(로컬) 메모리에 두는 것이 바람직하다.</li>
  <li><strong>Cold page</strong>: 최근에 또는 자주 접근되지 않은 페이지. UMA에서는 교체 후보이고, NUMA에서는 먼(원격) 메모리로 보낼 후보다.</li>
</ul>

<p>여기서 두 가지 관점이 갈린다. <strong>LRU는 recency(얼마나 최근)</strong>를, <strong>LFU는 frequency(얼마나 자주)</strong>를 본다.</p>

<p>뜨거운 페이지를 빠른 메모리에 두는 동작은 환경에 따라 이름이 다르다.</p>

<ul>
  <li><strong>UMA(Uniform Memory Access)</strong>: cold 페이지는 보조 저장장치로 교체하고 hot 페이지는 주기억장치에 유지한다. 이를 <strong>replacement</strong>라 한다.</li>
  <li><strong>NUMA</strong>: cold 페이지를 원격 메모리로 강등(demote)하고 hot 페이지를 로컬 메모리로 승격(promote)한다. 이를 <strong>memory tiering</strong>이라 한다.</li>
</ul>

<h3 id="평가-기준">평가 기준</h3>

<p>교체 정책은 성능에 <strong>막대한 영향</strong>을 준다. 잘못된 선택은 페이지 폴트를 빈번하게 만들고, 이는 곧 디스크 I/O로 이어진다. (페이지 폴트는 프로그램이 현재 메모리에 없는 페이지를 접근할 때 발생하는 예외다.)</p>

<p>두 가지로 평가한다.</p>

<ul>
  <li><strong>정확도(accuracy)</strong>: 총 페이지 폴트 수</li>
  <li><strong>오버헤드(overhead)</strong>: 시간 복잡도 또는 평균 실행 시간</li>
</ul>

<p>아래에서 Optimal, FIFO, LRU, Clock, Enhanced Second-Chance를 차례로 본다.</p>

<h3 id="페이지-폴트-카운팅-규약">페이지 폴트 카운팅 규약</h3>

<p>알고리즘을 비교하기 전에 셈하는 규칙을 정한다. 이 프로세스에 <strong>고정 4 프레임</strong>이 할당되었다고 가정한다. 요구 페이징에서 메모리에 없는 페이지를 참조하면 페이지 폴트다.</p>

<p><strong>참조 문자열(reference string)</strong>은 프로세스가 참조하는 페이지 번호의 순서다. 예를 들어 <code class="language-plaintext highlighter-rouge">1, 2, 3, 4, 1, 2, 5, 1, 2, 3, 4, 5</code>. 비어 있는 프레임을 채우는 <strong>초기 페이지 폴트(프레임 수만큼 발생)는 세지 않는다.</strong> 정책 간 차이를 보려는 것이기 때문이다.</p>

<h3 id="optimal--최적-교체">Optimal — 최적 교체</h3>

<p><strong>가장 오랫동안 사용되지 않을 페이지를 교체</strong>한다. 이를 위해서는 미래의 모든 참조를 알아야 하므로 <strong>구현이 불가능</strong>하다. 하지만 페이지 폴트를 <strong>최소(minimum)</strong>로 만들기 때문에, 다른 알고리즘을 비교하는 <strong>기준선(baseline)이자 하한(lower bound)</strong>으로 쓴다. 오프라인 분석 기법으로는 사용 가능하다.</p>

<p>참조 문자열 <code class="language-plaintext highlighter-rouge">1, 2, 3, 4, 1, 2, 5, 1, 2, 3, 4, 5</code>, 4 프레임에서 초기 폴트 이후 <strong>추가 폴트 2회</strong>가 발생한다.</p>

<h3 id="fifo--first-in-first-out">FIFO — First-In First-Out</h3>

<p>버퍼가 꽉 차면 <strong>가장 오래된 페이지를 교체</strong>한다.</p>

<ul>
  <li><strong>장점</strong>: 구현이 단순하다.</li>
  <li><strong>단점</strong>: 자주 쓰이는 페이지가 가장 오래된 페이지일 수 있다(가장 나쁜 성능으로 이어짐). 그리고 <strong>Belady의 이상 현상(Belady’s anomaly)</strong>이 있다.</li>
</ul>

<p>Belady의 이상 현상은 <strong>프레임 수를 늘렸는데 오히려 페이지 폴트가 더 많아지는</strong> 현상이다. 직관적으로는 프레임이 많을수록 폴트가 줄어야 하지만, FIFO는 그렇지 않은 경우가 종종 있다.</p>

<p>같은 참조 문자열 <code class="language-plaintext highlighter-rouge">1, 2, 3, 4, 1, 2, 5, 1, 2, 3, 4, 5</code>에서:</p>

<table>
  <thead>
    <tr>
      <th>프레임 수</th>
      <th>페이지 폴트</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>3 프레임</td>
      <td>3 + 6</td>
    </tr>
    <tr>
      <td>4 프레임</td>
      <td>4 + 6</td>
    </tr>
  </tbody>
</table>

<p>프레임을 3개에서 4개로 늘렸는데 폴트(초기 제외분)가 줄지 않는다. 이것이 Belady의 이상 현상이다.</p>

<h3 id="lru--least-recently-used">LRU — Least Recently Used</h3>

<p><strong>가장 오랫동안 참조되지 않은 페이지를 교체</strong>한다. 과거를 보고 미래를 근사하는, optimal 정책에 대한 근사다. 다만 <strong>구현이 복잡하다.</strong></p>

<p>같은 참조 문자열에서 LRU는 <strong>추가 폴트 4회</strong>가 발생한다(Optimal 2회, FIFO 6회와 비교).</p>

<p><strong>구현 방식</strong>은 두 가지가 대표적이다.</p>

<ul>
  <li><strong>비트 시프팅(bit-shifting)</strong>: 참조될 때마다 페이지에 타임스탬프를 찍는다. 페이지 테이블 엔트리에 참조 시점을 태깅한다.</li>
  <li>
    <p><strong>스택(stack)</strong>: 페이지가 참조될 때마다 스택의 맨 위로 옮긴다.</p>
  </li>
  <li><strong>장점</strong>: Belady의 이상 현상이 없다. Optimal의 근사다.</li>
  <li><strong>단점</strong>: <strong>모든 참조마다 갱신</strong>해야 해서 비싸다.</li>
</ul>

<h3 id="clock--second-chance">Clock — Second Chance</h3>

<p>LRU의 비용 문제 때문에 등장한 <strong>단순화된 근사</strong>다. 프레임 집합을 <strong>순환 버퍼(circular buffer)</strong>로 다룬다.</p>

<ul>
  <li>각 프레임에 <strong>사용 비트(use bit, reference bit)</strong>가 있다. 페이지가 접근되거나 처음 적재될 때 1로 세팅된다.</li>
  <li><strong>이동 프레임 포인터(moving frame pointer)</strong>가 프레임을 원형으로 스캔한다.</li>
</ul>

<p>빈 프레임이 없을 때 교체 동작은 이렇다.</p>

<ol>
  <li>포인터가 현재 프레임의 사용 비트를 확인한다.</li>
  <li><strong>use bit == 1</strong>이면 비트를 0으로 지우고(두 번째 기회) 다음 프레임으로 이동한다.</li>
  <li><strong>use bit == 0</strong>이면 그 프레임을 교체 대상으로 선택한다.</li>
  <li>새 페이지가 들어오면 use bit를 1로 초기화한다.</li>
</ol>

<p><img src="/assets/images/posts/virtual-memory/virtual-memory-4.png" alt="Clock 정책 동작 예시: 새 페이지 727이 들어오면서 use=0인 프레임을 찾아 교체한다" /></p>

<p>위 예시에서 새 페이지 727을 적재할 때, 포인터가 돌면서 use=1인 페이지들에 두 번째 기회를 주며 비트를 0으로 지운다. 최종적으로 use=0이었던 프레임 4의 페이지 556이 727로 교체되고, 새 프레임의 use 비트는 1이 되며 포인터는 프레임 5로 전진한다.</p>

<h3 id="enhanced-second-chance">Enhanced Second-Chance</h3>

<p><strong>사용 비트(use)와 수정 비트(modify)를 한 쌍으로</strong> 고려한다. 2비트 (use, modify) 조합으로 우선순위를 매긴다.</p>

<table>
  <thead>
    <tr>
      <th>(use, modify)</th>
      <th>의미</th>
      <th>비고</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>(0, 0)</td>
      <td>최근 미사용 + clean</td>
      <td><strong>교체에 가장 적합</strong></td>
    </tr>
    <tr>
      <td>(0, 1)</td>
      <td>최근 미사용 + modified</td>
      <td>write-out 필요(dirty) → 추가 I/O</td>
    </tr>
    <tr>
      <td>(1, 0)</td>
      <td>최근 사용 + clean</td>
      <td>곧 다시 쓰일 가능성</td>
    </tr>
    <tr>
      <td>(1, 1)</td>
      <td>최근 사용 + modified</td>
      <td>곧 쓰이고 write-out도 필요</td>
    </tr>
  </tbody>
</table>

<p><strong>2단계 스캔(two-step scan)</strong>으로 동작한다.</p>

<ol>
  <li><strong>1차 스캔</strong>: (0,0) 페이지를 찾되 use 비트는 건드리지 않는다.</li>
  <li><strong>2차 스캔</strong>: (0,1) 페이지를 찾으면서 지나친 use 비트를 0으로 지운다.</li>
  <li>교체 가능한 페이지를 찾을 때까지 반복한다(use=1인 페이지는 수정됨).</li>
</ol>

<p>수정된(dirty) 페이지를 우선적으로 남겨두어 필요한 I/O 횟수를 줄이는 것이 핵심이다.</p>

<h3 id="알고리즘-비교">알고리즘 비교</h3>

<p><img src="/assets/images/posts/virtual-memory/virtual-memory-5.png" alt="고정 할당·지역 교체 알고리즘 비교: 프레임 수가 늘수록 폴트가 줄고, OPT &lt; LRU &lt; CLOCK &lt; FIFO 순으로 좋다" /></p>

<p>같은 프레임 수에서 페이지 폴트는 대체로 <strong>OPT &lt; LRU &lt; CLOCK &lt; FIFO</strong> 순으로 적다. 프레임 수가 충분히 커지면 차이는 줄어든다.</p>

<h3 id="옛-os들의-lru-근사">옛 OS들의 LRU 근사</h3>

<p>정확한 LRU는 <strong>모든 메모리 접근을 추적</strong>해야 하므로 사실상 불가능하다. 그래서 대부분의 OS는 진짜 LRU 대신 근사 알고리즘을 썼다.</p>

<ul>
  <li><strong>BSD 4.3</strong>: 타이머 인터럽트로 주기적으로 각 페이지의 ACCESSED 비트를 스캔하고 지운다. 다음 스캔에서 ACCESSED=1이면 최근 사용되었으므로 age=0, ACCESSED=0이면 age를 증가시킨다. age가 가장 큰 페이지가 reclaim 대상이 된다.</li>
  <li><strong>System V</strong>: 가용 메모리가 부족할 때만 동작한다. 이동하는 clock hand로 순환 큐를 스캔해, ACCESSED=1이면 비트를 지우고 건너뛰며(두 번째 기회), ACCESSED=0이면 즉시 reclaim한다.</li>
</ul>

<h3 id="linux-5x의-2q-lru">Linux 5.x의 2Q LRU</h3>

<p>단일 큐 LRU에는 문제가 있다.</p>

<ul>
  <li>딱 한 번 접근된 페이지가 오래 메모리에 남을 수 있다.</li>
  <li>순차 스캔이 일시적으로 쓰인 페이지로 캐시를 오염시킨다.</li>
  <li>단일 LRU 큐는 일시적 접근과 자주 재사용되는 페이지를 구분하지 못한다.</li>
</ul>

<p><strong>2Q LRU</strong>는 큐를 둘로 나눠 이 문제를 푼다.</p>

<ul>
  <li>새로 폴트된 페이지는 먼저 <strong>inactive 큐</strong>로 들어간다(reclaim 후보).</li>
  <li>다시 접근되면 <strong>active 큐</strong>로 승격된다(자주 재사용되는 페이지).</li>
</ul>

<p>이렇게 일회성 접근 페이지와 빈번히 재사용되는 페이지를 분리한다.</p>

<h4 id="file-backed-vs-anonymous">File-backed vs Anonymous</h4>

<p>Linux는 페이지를 성격에 따라 구분한다.</p>

<ul>
  <li><strong>File-backed 페이지</strong>: 실행 코드·데이터처럼 일반 파일시스템 파일이 뒷받침하는 페이지(clean). clean하면 디스크 I/O 거의 없이 내보낼 수 있고, 필요하면 원본 파일에서 다시 읽으면 된다.</li>
  <li><strong>Anonymous 페이지</strong>: 파일이 뒷받침하지 않고 swap을 쓰는 페이지(힙, 스택 등). reclaim하려면 보통 swap-out I/O가 필요하다. anonymous 페이지는 보통 file-backed보다 강한 시간 지역성을 보이므로 처음부터 active 리스트에 넣는다.</li>
  <li><strong>Unevictable 페이지</strong>: 정상적인 reclaim에서 제외되는 페이지.</li>
</ul>

<p>여기서 <strong>swappiness</strong>가 등장한다. swappiness는 file-backed 페이지를 reclaim할지 anonymous 페이지를 swap할지의 균형을 조절하는 커널 파라미터다. swappiness가 낮으면 Linux는 anonymous보다 file-backed 페이지 reclaim을 선호한다.</p>

<h4 id="promotion과-demotion">Promotion과 Demotion</h4>

<p>2Q LRU에서 inactive/active 리스트는 다음 규칙으로 관리된다.</p>

<p><img src="/assets/images/posts/virtual-memory/virtual-memory-7.png" alt="2단계 LRU에서 inactive/active 리스트 간 promotion과 demotion, 그리고 kswapd의 reclaim" /></p>

<ul>
  <li>새 페이지는 inactive 리스트의 MRU 쪽으로 삽입된다.</li>
  <li>다시 접근된 페이지는 inactive → active로 <strong>승격(promotion)</strong>된다.</li>
  <li>메모리 압박이 생기면 Linux는 active 페이지를 줄이고(shrink) cold inactive 페이지를 second-chance 정책으로 reclaim한다.</li>
</ul>

<p>메모리 압박(워터마크 아래)이 발생하면 <strong>kswapd</strong>가 동작한다. kswapd는 가용 메모리가 워터마크 임계값 아래로 떨어질 때 페이지를 reclaim하는 Linux 커널 데몬이다. kswapd는 (1) inactive 리스트를 스캔해 참조되지 않은 페이지를 reclaim하고, (2) active 리스트를 스캔해 참조되지 않은 페이지를 demote한다.</p>

<h4 id="refault-distance">Refault distance</h4>

<p>새로 폴트된 페이지는 inactive 리스트로 들어간다. 그런데 한 번 쫓겨났다가 <strong>다시 폴트된(refault)</strong> 페이지도 inactive에서 시작해야 할까. Linux는 그 eviction이 옳았는지 추정해서 판단한다.</p>

<p>판단 척도가 <strong>refault distance</strong>다.</p>

\[\text{Refault distance} = (R - E) + nr\_inactive(t_r)\]

<ul>
  <li>refault distance가 <strong>작으면</strong> 페이지가 빨리 재사용된 것이고, 원래 active 리스트에 속했어야 했다는 뜻이다.</li>
  <li>refault distance가 <strong>크면</strong> 오랫동안 안 쓰였으므로 좋은 eviction 후보였다는 뜻이다.</li>
</ul>

<p>조건 $\text{Refault distance} &lt; nr_active(t_r) + nr_inactive(t_r)$ 를 만족하면, 즉 큐 전체 길이 안에 들어오면 곧바로 active 리스트로 보낼 수 있다. eviction 시점의 페이지 활동 정보는 <strong>shadow</strong>(xarray 키-값 엔트리)에 저장해 둔다.</p>

<h2 id="cleaning-policy--클리닝-정책">Cleaning Policy — 클리닝 정책</h2>

<p>페이지 교체는 원래 <strong>두 번의 I/O</strong>가 필요하다. 내보낼 페이지를 디스크에 쓰는 page-out, 새 페이지를 읽어오는 page-in이다. 그런데 내보낼 페이지가 <strong>수정되지 않았다면(clean)</strong> 디스크에 다시 쓸 필요가 없어 <strong>I/O 한 번</strong>으로 끝난다.</p>

<p>그래서 페이지가 수정되었는지 추적하는 것이 중요하다. <strong>수정 비트(modify/dirty bit)</strong>가 그 역할을 한다. 클리닝 정책은 <strong>수정된 페이지를 언제 디스크에 쓸지</strong> 결정한다.</p>

<ul>
  <li><strong>선청소(pre-cleaning)</strong>: 페이지를 미리 디스크에 써둔다.
    <ul>
      <li>장점: 교체 시점의 지연을 줄인다(I/O 한 번).</li>
      <li>단점: 자주 쓰기 접근되는 페이지는 clean하게 만들어도 곧 다시 dirty가 되어 I/O를 낭비한다.</li>
    </ul>
  </li>
  <li><strong>요구 청소(demand cleaning)</strong>: 교체 대상으로 선택될 때만 디스크에 쓴다.
    <ul>
      <li>장점: 불필요한 쓰기를 줄이고 구현이 단순하다.</li>
      <li>단점: 교체 시점에 높은 지연이 생길 수 있다.</li>
    </ul>
  </li>
  <li><strong>페이지 버퍼링(page buffering)</strong>: 즉시 디스크에 쓰지 않고 페이지 버퍼에 둔다. 곧 재사용되는 경우 불필요한 디스크 I/O를 줄일 수 있다.</li>
</ul>

<h2 id="resident-set-policy--상주-집합-관리">Resident Set Policy — 상주 집합 관리</h2>

<p><strong>상주 집합(resident set)</strong>은 어떤 시점에 실제로 주기억장치에 올라가 있는 프로세스의 페이지들이다. 상주 집합 정책은 <strong>프로세스마다 몇 개의 프레임을 할당할지</strong> 정한다.</p>

<ul>
  <li><strong>고정 할당(fixed-allocation)</strong>: 프로세스에 고정된 수의 프레임을 준다. 새 페이지가 필요하면 그 프로세스의 페이지 중 하나를 반드시 교체한다.</li>
  <li><strong>가변 할당(variable-allocation)</strong>: 프로세스에 할당하는 프레임 수를 시간에 따라 변동시킨다.</li>
</ul>

<p>여기에 <strong>교체 범위(replacement scope)</strong>가 결합된다.</p>

<table>
  <thead>
    <tr>
      <th> </th>
      <th>Local Replacement</th>
      <th>Global Replacement</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><strong>Fixed Allocation</strong></td>
      <td>프레임 수 고정, 교체 페이지는 그 프로세스 프레임 중에서 선택</td>
      <td>불가능</td>
    </tr>
    <tr>
      <td><strong>Variable Allocation</strong></td>
      <td>working set 유지를 위해 프레임 수를 조정, 교체는 그 프로세스 내에서</td>
      <td>교체 페이지를 메모리 전체에서 선택, 프로세스별 상주 집합 크기가 변동</td>
    </tr>
  </tbody>
</table>

<h3 id="thrashing--스래싱">Thrashing — 스래싱</h3>

<p>주기억장치에 상주시킬 프로세스 수를 정하는 문제와 직결된다. 멀티프로그래밍 수준(동시 실행 프로세스 수)이 올라가면 처음에는 CPU 이용률이 오른다. 하지만 너무 많은 프로세스가 상주하면, <strong>평균적으로 각 프로세스의 상주 집합이 부족해져</strong> 페이지 폴트가 빈번해진다.</p>

<p><strong>스래싱(thrashing)</strong>은 프로세스가 실행보다 페이징에 더 많은 시간을 쓰는 상태다. 멀티프로그래밍 수준이 어느 지점을 넘으면 CPU 이용률이 급격히 떨어진다.</p>

<p>스래싱을 막으려면 프로세스에 <strong>필요한 만큼의 프레임</strong>을 줘야 한다. 문제는 “얼마가 필요한지”를 어떻게 아느냐다.</p>

<h3 id="working-set-model">Working Set Model</h3>

<p>Peter Denning의 <strong>working set 모델</strong>은 지역성 가정에 기반한다. 시점 $t$에서 윈도우 크기 $\Delta$ 동안 참조된 페이지 집합을 working set으로 정의한다.</p>

\[W_i(t, \Delta) = \text{직전 } \Delta \text{ 동안 참조된 페이지 집합}\]

<ul>
  <li>$\Delta$는 시간 윈도우 크기다($t$는 참조 횟수로 측정).</li>
  <li>
    <table>
      <tbody>
        <tr>
          <td>$1 \le</td>
          <td>W_i(t, \Delta)</td>
          <td>\le \min(N, \Delta)$</td>
        </tr>
      </tbody>
    </table>
  </li>
  <li>$W(t, \Delta) \subseteq W(t, \Delta + 1)$ — 윈도우가 커지면 집합도 커진다(혹은 같다).</li>
</ul>

<p>정책은 이렇다. <strong>working set에 속하지 않는 페이지를 상주 집합에서 주기적으로 제거</strong>한다. 더 이상 쓰이지 않는 페이지는 마지막 참조 후 $\Delta$ 시간이 지나면 working set에서 빠진다. 본질적으로 LRU 정책이며, 프로세스마다 시간 순서 큐를 유지해야 한다.</p>

<p>현실적 문제가 있다.</p>

<ul>
  <li>프로세스마다 working set을 실제로 측정하는 것은 비현실적이다.</li>
  <li>최적의 $\Delta$ 값을 알 수 없다. $\Delta$가 너무 작으면 지역성을 다 담지 못하고, 너무 크면 여러 지역성이 겹친다. 극단적으로 $\Delta$가 무한하면 working set은 프로세스 실행 중 접근한 모든 페이지가 된다.</li>
</ul>

<h3 id="page-fault-frequency-pff">Page-Fault Frequency (PFF)</h3>

<p>working set 전략을 근사하는 또 다른 방법으로, <strong>페이지 폴트 빈도(PFF) 자체에 초점</strong>을 맞춘다. 두 개의 임계값(상한·하한)으로 “허용 가능한” 폴트율을 유지한다.</p>

<ul>
  <li>실제 폴트율이 <strong>상한보다 높으면</strong>: 프로세스에 프레임을 더 준다.</li>
  <li>실제 폴트율이 <strong>하한보다 낮으면</strong>: 프로세스에서 프레임을 회수한다.</li>
</ul>

<h2 id="가상-메모리를-위한-하드웨어제어-구조">가상 메모리를 위한 하드웨어·제어 구조</h2>

<p>가상 메모리는 하드웨어 지원 없이는 동작하지 않는다.</p>

<h3 id="페이지-테이블-베이스-레지스터와-pte">페이지 테이블 베이스 레지스터와 PTE</h3>

<ul>
  <li><strong>페이지 테이블 베이스 레지스터(PTBR)</strong>: 예를 들어 x86의 <code class="language-plaintext highlighter-rouge">cr3</code>. 페이지 테이블 전체를 레지스터에 담을 수는 없으므로, <strong>페이지 테이블은 주기억장치에 두고</strong> PTBR이 현재 프로세스의 페이지 테이블 주소를 가리킨다.</li>
  <li><strong>페이지 테이블 엔트리(PTE)</strong>: 프로세스의 일부 페이지만 메모리에 있을 수 있으므로 상태 비트가 필요하다.
    <ul>
      <li><strong>존재 비트(present/valid bit, P)</strong>: 해당 페이지가 메모리에 있는지 여부. P=0이면 메모리에 없으므로 페이지 폴트 처리가 필요하다.</li>
      <li><strong>수정 비트(modified/dirty bit, M)</strong>: 마지막 적재 이후 페이지 내용이 변경되었는지 여부. M=0이면 메모리에서 변경되지 않았으므로 교체 시 디스크에 다시 쓸 필요가 없다.</li>
    </ul>
  </li>
</ul>

<h3 id="tlb--translation-lookaside-buffer">TLB — Translation Lookaside Buffer</h3>

<p>페이지 테이블이 메모리에 있다는 것은, 주소 변환마다 메모리 접근이 추가된다는 뜻이다. 즉 <strong>MMU가 메모리를 두 번 접근</strong>할 수 있다(페이지 테이블 한 번 + 실제 데이터 한 번). 이 성능 저하를 막기 위해 PTE를 캐싱하는 작은 하드웨어 캐시가 <strong>TLB</strong>다.</p>

<p><img src="/assets/images/posts/virtual-memory/virtual-memory-2.png" alt="TLB와 MMU의 주소 변환 흐름: CPU가 VA를 MMU에 전달하면 MMU가 TLB를 조회해 PA를 만든다" /></p>

<ul>
  <li>TLB는 PTE를 캐싱하는 작은 하드웨어 캐시다(현대 Intel 프로세서는 128~256 엔트리).</li>
  <li>메모리에서 페이지 테이블을 조회하는 것보다 훨씬 빠르다.</li>
</ul>

<p>변환 과정은 이렇다. 입력은 페이지 번호, 출력은 프레임 번호다.</p>

<ul>
  <li><strong>TLB Hit</strong>: 변환을 가져와 프레임 번호를 반환한다.</li>
  <li><strong>TLB Miss</strong>: 메모리의 페이지 테이블을 확인한다.
    <ul>
      <li>present 비트 = 1: 페이지 테이블 엔트리를 TLB에 적재하고 프레임 번호를 반환한다.</li>
      <li>present 비트 = 0: <strong>페이지 폴트 예외</strong>가 발생한다.</li>
    </ul>
  </li>
</ul>

<h3 id="주소-변환-흐름">주소 변환 흐름</h3>

<p><img src="/assets/images/posts/virtual-memory/virtual-memory-3.png" alt="페이징과 TLB의 동작: TLB 조회 → 페이지 테이블 접근 → 메모리에 없으면 디스크에서 읽고, 메모리가 꽉 찼으면 페이지 교체" /></p>

<p>MMU가 페이지 폴트 예외를 일으키면 다음 순서로 처리된다.</p>

<ol>
  <li>CPU가 I/O 요청을 시작한다.</li>
  <li>디스크에서 메모리로 페이지를 옮긴다(메모리가 꽉 찼으면 페이지 교체 수행).</li>
  <li>해당 페이지 테이블 엔트리를 갱신한다.</li>
  <li>엔트리를 TLB에 적재한다.</li>
</ol>

<h3 id="context-switch와-asid">Context Switch와 ASID</h3>

<p>TLB에는 컨텍스트 스위치 문제가 있다. 프로세스마다 페이지 테이블이 다르므로, 컨텍스트 스위치가 일어나면 직전 프로세스의 TLB 변환 정보는 <strong>의미가 없어진다</strong>. 같은 가상 페이지 번호(VPN 10)가 프로세스마다 다른 물리 프레임을 가리키기 때문이다.</p>

<p>두 가지 해법이 있다.</p>

<ul>
  <li><strong>Solution 1 — Flush</strong>: 모든 valid 비트를 0으로 만든다. 단순하지만 컨텍스트 스위치마다 TLB miss가 발생해 비용이 크다.</li>
  <li><strong>Solution 2 — ASID(Address Space Identifier)</strong>: 프로세스 식별자(PID)처럼 생각할 수 있다. TLB가 서로 다른 프로세스의 변환을 <strong>동시에 보유</strong>할 수 있게 해, flush 없이 구분한다.</li>
</ul>

<h2 id="가상-메모리-이슈">가상 메모리 이슈</h2>

<h3 id="페이지-크기">페이지 크기</h3>

<p><strong>페이지 크기를 키우면</strong> 어떻게 될까.</p>

<ul>
  <li>페이지 테이블 엔트리 수가 줄어든다(예: 1M 엔트리 → 256K 엔트리).</li>
  <li>페이지 테이블 크기가 작아진다.</li>
</ul>

<p>하지만 문제도 생긴다.</p>

<ul>
  <li>페이지 내부 낭비, 즉 <strong>내부 단편화(internal fragmentation)</strong>가 커진다.</li>
  <li>큰 페이지로 메모리가 빨리 채워진다.</li>
</ul>

<p>실제 페이지 크기는 아키텍처마다 다르다. 4 KB(IBM 370, Pentium, POWER)가 흔하고, MIPS·Itanium처럼 4 KB ~ 수십·수백 MB 범위를 지원하는 경우도 있다.</p>

<h3 id="더-빠른-변환">더 빠른 변환</h3>

<p>페이지 테이블 접근에 따른 추가 메모리 접근이 성능을 떨어뜨린다는 문제는 앞서 본 <strong>TLB로 PTE를 캐싱</strong>해서 해결한다. 컨텍스트 스위치 시 TLB 처리는 <strong>ASID</strong>로 해결한다(위 참조).</p>

<h3 id="더-작은-테이블">더 작은 테이블</h3>

<p>페이지 테이블 자체가 메모리를 너무 많이 쓰는 문제가 있다.</p>

<blockquote>
  <p>32비트 컴퓨터에 4 KB 페이지면 $2^{20}$개의 엔트리가 필요하다. PTE가 4 B라면 페이지 테이블은 $2^{20} \times 4 = 4\text{ MB}$가 된다. 프로세스마다 자기 페이지 테이블이 있어야 하므로, 프로세스가 100개면 400 MB다.</p>
</blockquote>

<p>64비트로 가면 상황은 훨씬 심각하다. 해법은 세 가지다.</p>

<h4 id="결합-세그먼테이션--페이징">결합 세그먼테이션 + 페이징</h4>

<p>각 사용자 주소 공간을 여러 세그먼트로 나누고, 각 세그먼트를 다시 고정 크기 페이지로 나눈다.</p>

<ul>
  <li><strong>세그먼트 테이블</strong>: Base(해당 세그먼트의 페이지 테이블 위치), Bound(페이지 테이블 엔트리 수), 그리고 공유·보호용 제어 비트를 담는다.</li>
  <li>PTE는 순수 페이징과 본질적으로 같다.</li>
  <li><strong>장점</strong>: 페이지 테이블의 메모리 오버헤드를 줄이고, 메모리 할당을 단순화하며, 외부 단편화가 없다(내부 단편화는 존재).</li>
  <li><strong>단점</strong>: 순수 페이징보다 복잡도가 훨씬 높다(MMU 지원 필요).</li>
</ul>

<h4 id="계층적-페이징hierarchical-paging">계층적 페이징(Hierarchical Paging)</h4>

<p>페이지 테이블을 <strong>페이지 크기 단위의 작은 청크들로 나눈다.</strong> 어떤 PTE 페이지 전체가 invalid라면 그 청크는 아예 할당하지 않는다.</p>

<p><img src="/assets/images/posts/virtual-memory/virtual-memory-6.png" alt="2단계 계층적 페이지 테이블: page directory가 사용자 페이지 테이블을, 그것이 다시 주소 공간을 가리킨다" /></p>

<ul>
  <li>2단계 페이지 테이블(64비트 CPU에서는 4단계)을 쓴다.</li>
  <li>페이지 테이블 자체도 페이징된다.</li>
  <li><strong>장점</strong>: 낭비가 적다(할당 공간이 실제 사용하는 주소 공간 크기에 비례). 페이지 테이블이 물리 메모리에서 <strong>연속일 필요가 없다.</strong></li>
  <li><strong>단점</strong>: 메모리에서 추가 적재(상위 테이블 조회)가 필요하다.</li>
</ul>

<h4 id="역-페이지-테이블inverted-page-tables">역 페이지 테이블(Inverted Page Tables)</h4>

<p><strong>메모리 프레임마다 하나의 엔트리</strong>를 둔다.</p>

<ul>
  <li>PTE 수 == PFN 수. 가상 페이지마다가 아니라 실제 메모리 프레임마다 엔트리가 하나씩 있다.</li>
  <li>프레임 번호를 인덱스로 쓴다. 시스템 전체에 페이지 테이블이 <strong>하나</strong>뿐이다.</li>
  <li>각 엔트리는 페이지 번호와 그 페이지를 소유한 PID를 담는다.</li>
  <li><strong>장점</strong>: 페이지 테이블 저장에 필요한 메모리가 줄어든다.</li>
  <li><strong>단점</strong>: 페이지 참조 시 테이블을 검색하는 시간이 늘어난다. 이를 한 번으로 제한하기 위해 <strong>해시 테이블</strong>을 사용한다.</li>
</ul>

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

<p>가상 메모리의 출발점은 “프로세스를 통째로 올리지 않아도 된다”는 지역성 관찰이다. 부분 적재된 페이지 집합을 OS는 반입·배치·교체·상주 집합·클리닝의 다섯 정책으로 관리한다. 교체 알고리즘은 Optimal을 하한 기준으로 FIFO·LRU·Clock·Enhanced Second-Chance가 트레이드오프를 달리하며, Linux는 2Q LRU와 refault distance로 일회성 접근과 재사용을 구분한다. 상주 집합이 부족하면 스래싱이 발생하므로 working set과 PFF로 프레임 수를 조절한다. 이 모든 것은 PTBR·PTE·TLB라는 하드웨어 위에서 동작하며, 페이지 테이블의 메모리 부담은 계층적 페이징과 역 페이지 테이블로 덜어낸다.</p>]]></content><author><name>이주한</name></author><category term="Operating-System" /><category term="operating-system" /><category term="virtual-memory" /><category term="demand-paging" /><category term="page-replacement" /><category term="lru" /><category term="clock" /><category term="fifo" /><category term="optimal" /><category term="working-set" /><category term="thrashing" /><category term="tlb" /><category term="page-table" /><category term="hierarchical-paging" /><category term="inverted-page-table" /><category term="numa" /><summary type="html"><![CDATA[프로세스를 통째로 적재하지 않고 일부만 올려 실행하는 가상 메모리의 원리와 OS의 다섯 가지 정책, 페이지 교체 알고리즘, 그리고 TLB·계층적/역 페이지 테이블 같은 하드웨어 구조까지 정리한다.]]></summary></entry><entry><title type="html">Memory Management — 메모리 모델부터 페이징·세그먼테이션까지</title><link href="https://l2juhan.github.io/operating-system/2026/05/31/memory-management.html" rel="alternate" type="text/html" title="Memory Management — 메모리 모델부터 페이징·세그먼테이션까지" /><published>2026-05-31T00:00:00+00:00</published><updated>2026-05-31T00:00:00+00:00</updated><id>https://l2juhan.github.io/operating-system/2026/05/31/memory-management</id><content type="html" xml:base="https://l2juhan.github.io/operating-system/2026/05/31/memory-management.html"><![CDATA[<p>메모리는 빠를수록 비싸고 클수록 느리다. 게다가 여러 프로세스가 한정된 물리 메모리를 나눠 써야 한다. 운영체제가 프로세스를 시간·공간 양면에서 효율적으로 메모리에 배치하는 일, 그것이 <strong>메모리 관리(memory management)</strong>다. 그 전에 멀티코어 환경에서 메모리가 어떻게 동작하는지를 규정하는 메모리 모델부터 짚는다.</p>

<h2 id="메모리-모델">메모리 모델</h2>

<p><strong>메모리 모델(memory model)</strong>은 메모리가 어떻게 동작하는지를 정의하는 방식이다. 프로그래머와 시스템 사이의 일종의 계약이며, <strong>메모리 일관성 모델(memory consistency model)</strong>이라고도 부른다. 모델은 프로세서 종류에 따라 다르다.</p>

<ul>
  <li><strong>순차 일관성 모델(sequential consistency model)</strong>: 각 코어에서 명령어 재배치(reordering)를 전혀 허용하지 않는 보수적인 모델. 안전하지만 최적화를 막아 성능이 떨어진다. (예: LEON4)</li>
  <li><strong>완화된 일관성 모델(relaxed/weaker consistency model)</strong>: 메모리 명령어의 재배치를 허용한다. 대신 프로그래머가 <strong>펜스 명령어(fence instruction)</strong>로 프로그램 순서를 직접 지정해야 한다. (예: ARM, RISC-V) — 오늘날의 기본값이다.</li>
</ul>

<h3 id="재배치가-일으키는-문제">재배치가 일으키는 문제</h3>

<p>전역 변수 <code class="language-plaintext highlighter-rouge">x = 0, flag = false</code>에서 두 스레드가 다음과 같이 동작한다고 하자.</p>

<div class="language-c highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// Thread 1          // Thread 2</span>
<span class="n">x</span> <span class="o">=</span> <span class="mi">100</span><span class="p">;</span>             <span class="k">while</span> <span class="p">(</span><span class="n">flag</span> <span class="o">==</span> <span class="nb">false</span><span class="p">);</span>
<span class="n">flag</span> <span class="o">=</span> <span class="nb">true</span><span class="p">;</span>         <span class="n">print</span><span class="p">(</span><span class="n">x</span><span class="p">);</span>
</code></pre></div></div>

<p>직관적으로는 <code class="language-plaintext highlighter-rouge">100</code>이 출력될 것 같다. 하지만 <strong>어떤 현대 프로세서도 순차 일관성을 구현하지 않는다.</strong> 프로그램 순서(program order)와 실행 순서(execution order)가 일치하지 않고, 명령어를 적극적으로 재배치하기 때문이다. 현대 프로세서는 비동기 메모리 연산을 위해 내부적으로 <strong>load/store buffer</strong>를 사용하고, 캐시 일관성(cache-coherence) 타이밍 차이로 메모리 연산이 코어마다 다른 순서로 보일 수 있다.</p>

<p>그래서 Thread 1에서 <code class="language-plaintext highlighter-rouge">x = 100</code>과 <code class="language-plaintext highlighter-rouge">flag = true</code>의 순서가 뒤바뀌어, Thread 2가 <code class="language-plaintext highlighter-rouge">flag = true</code>를 <code class="language-plaintext highlighter-rouge">x = 100</code>보다 먼저 관측하면 <code class="language-plaintext highlighter-rouge">0</code>을 출력한다. 이런 일이 실제로 일어난다.</p>

<h3 id="메모리-배리어">메모리 배리어</h3>

<p><strong>메모리 배리어(memory barrier)</strong>는 메모리 연산을 직렬화하는 특수 명령어다. <strong>메모리 펜스(memory fence)</strong>라고도 한다. 불필요한 순서 강제 오버헤드를 줄이기 위해 읽기·쓰기 배리어로 세분화된다.</p>

<ul>
  <li><strong>쓰기 메모리 배리어(write memory barrier)</strong>: store 순서를 보장하고 store-store 재배치를 막는다.</li>
  <li><strong>읽기 메모리 배리어(read memory barrier)</strong>: load 순서를 보장하고 load-load 재배치를 막는다.</li>
</ul>

<p>앞의 예제는 배리어로 고칠 수 있다.</p>

<div class="language-c highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">x</span> <span class="o">=</span> <span class="mi">100</span><span class="p">;</span>
<span class="n">memory_barrier</span><span class="p">();</span>   <span class="c1">// 이전의 모든 쓰기가 전역적으로 보이도록 강제</span>
<span class="n">flag</span> <span class="o">=</span> <span class="nb">true</span><span class="p">;</span>
</code></pre></div></div>

<p>리눅스는 <code class="language-plaintext highlighter-rouge">rmb()</code>(읽기 배리어), <code class="language-plaintext highlighter-rouge">wmb()</code>(쓰기 배리어), <code class="language-plaintext highlighter-rouge">mb()</code>(읽기·쓰기 모두), 컴파일러 재배치만 막는 <code class="language-plaintext highlighter-rouge">barrier()</code> 등 메모리 배리어 기능을 제공한다.</p>

<h2 id="메모리-관리-개요">메모리 관리 개요</h2>

<p>메인 메모리는 크게 두 부분으로 나뉜다.</p>

<ul>
  <li><strong>OS part</strong>: 커널(kernel)이 사용하는 영역</li>
  <li><strong>User part</strong>: 각 프로세스가 사용하는 영역. 여러 프로세스를 수용하려면 더 잘게 나뉘어야 한다.</li>
</ul>

<p>메모리 관리는 프로세스를 <strong>시간과 공간 양면에서 효율적으로</strong> 메모리에 할당하는 일이며, 멀티프로그래밍 시스템의 핵심 기능이다. 페이징은 페이지(불변 크기) 단위로, 세그먼테이션은 세그먼트(가변 크기) 단위로 관리하며, 어느 영역에 메모리를 할당했는지는 페이지 테이블·세그먼트 테이블에 기록한다.</p>

<h3 id="메모리-관리-요구사항">메모리 관리 요구사항</h3>

<p>메모리 관리는 높은 성능 효율뿐 아니라 다음 기능적 요구사항도 만족해야 한다.</p>

<p><strong>재배치(Relocation)</strong> — 물리 위치를 미리 알 수 없으므로 동적 주소 변환이 필요하다. 즉 논리 주소와 물리 주소를 분리해야 한다. 프로세스가 다른 메모리 영역으로 옮겨지거나, 실행 중 스와핑(swapping)이 일어날 수 있기 때문이다. 현대 범용 OS에서는 프로세스가 논리 주소로 메모리에 접근하고 <strong>MMU(Memory Management Unit)</strong>가 이를 물리 주소로 변환한다. 단순 임베디드 OS에서는 프로그램이 물리 주소에 직접 접근하기도 한다.</p>

<p><strong>보호(Protection)</strong> — OS는 프로세스 간 불법 메모리 접근을 막아야 한다. 프로세스는 실행 중 재배치될 수 있으므로 컴파일 시점에 절대 주소를 검사하는 것은 불가능하다. 따라서 <strong>모든 메모리 참조는 런타임에 검사</strong>되어야 한다.</p>

<p><img src="/assets/images/posts/memory-management/memory-management-1.png" alt="재배치를 위한 하드웨어 지원" /></p>

<p>위 그림이 재배치·보호를 지원하는 하드웨어다. 프로세스의 상대 주소에 <strong>베이스 레지스터(base register)</strong>를 더해(Adder) 절대 주소를 만들고, <strong>바운드 레지스터(bounds register)</strong>와 비교(Comparator)해 영역을 벗어나면 인터럽트를 건다. 이 두 동작이 MMU에서 런타임에 이뤄진다.</p>

<p><strong>공유(Sharing)</strong> — 여러 프로세스가 같은 프로그램(또는 라이브러리)을 실행할 때, 각자 복사본을 올리는 대신 메모리의 같은 코드를 공유하게 할 수 있다. 커널 코드·데이터도 공유 주소 공간에 매핑되어, 모든 사용자 프로세스가 모드 전환(mode switch)을 통해 동일한 커널 메모리에 접근한다. 중복 메모리 사용을 줄인다.</p>

<p><strong>논리/물리 조직(Logical/Physical organization)</strong> — 실제 물리 메모리는 용량·속도·비용이 다른 여러 계층으로 구성된다. 논리 조직과 물리 조직을 분리하면 메모리 관리가 단순해진다. 물리 메모리를 직접 관리하기엔 너무 복잡하기 때문이다.</p>

<p><img src="/assets/images/posts/memory-management/memory-management-2.png" alt="논리/물리 조직 매핑" /></p>

<p>OS는 각 프로세스에 독립적인 논리 주소 공간을 제공하고, 논리 주소와 물리 위치의 매핑을 관리하며, DRAM·CXL 메모리·스토리지(NVMe/SSD/HDD) 같은 메모리 계층 사이의 데이터 이동을 처리한다.</p>

<h2 id="주소의-종류와-변환">주소의 종류와 변환</h2>

<p>주소는 추상화 수준에 따라 세 가지로 나뉜다.</p>

<table>
  <thead>
    <tr>
      <th>주소</th>
      <th>다른 이름</th>
      <th>의미</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>심볼릭 주소(symbolic address)</td>
      <td>—</td>
      <td>소스 코드에서 쓰는 주소. 변수명·상수·레이블</td>
    </tr>
    <tr>
      <td>상대 주소(relative address)</td>
      <td>논리(logical)·가상(virtual) 주소</td>
      <td>프로세스 내 위치. 0에서 시작. (페이지 번호+오프셋으로 표현되기도)</td>
    </tr>
    <tr>
      <td>절대 주소(absolute address)</td>
      <td>물리(physical)·실제(real) 주소</td>
      <td>메인 메모리의 실제 위치</td>
    </tr>
  </tbody>
</table>

<p>컴파일 시점에 컴파일러가 심볼릭 주소를 논리 주소로 변환한다. 논리 주소는 프로세스 입장에서 보는 주소로, x86이라면 주소 공간이 0번지에서 시작한다.</p>

<p><strong>동적 주소 변환(dynamic address translation)</strong>은 가상(논리) 주소를 실제(물리) 주소로 변환하는 것이다. 프로세서와 메모리 사이에 위치한 하드웨어 장치 <strong>MMU</strong>가 담당한다. 예를 들어 프로세스가 물리 베이스 주소 <code class="language-plaintext highlighter-rouge">0x8000</code>에 재배치됐다면, 논리 주소 <code class="language-plaintext highlighter-rouge">0x0080</code>은 런타임에 <code class="language-plaintext highlighter-rouge">0x0080 + 0x8000</code>으로 변환된다. (이 글에서 논리 주소와 가상 주소는 같은 의미로 쓴다.)</p>

<h2 id="메모리-할당">메모리 할당</h2>

<p>할당 단위는 시스템에 따라 프로세스, 커널 객체, 파일 캐시 등으로 다양하다. 할당 방식은 크게 둘로 나뉜다.</p>

<ul>
  <li><strong>연속 할당(contiguous allocation)</strong>: 하나의 연속된 물리 메모리 블록으로 할당. 단순하고 예측 가능한 워크로드에 적합. → 고정 분할, 동적 분할, 버디 시스템</li>
  <li><strong>비연속 할당(non-contiguous allocation)</strong>: 여러 분리된 물리 메모리 영역에 흩어서 할당. 복잡하고 동적인 워크로드에 적합. → 페이징, 세그먼테이션</li>
</ul>

<h2 id="고정-분할-fixed-partitioning">고정 분할 (Fixed Partitioning)</h2>

<p>연속 할당의 가장 단순한 방식이다. 메모리를 <strong>고정된 경계(fixed boundaries)</strong>로 미리 나눠 둔다.</p>

<p><strong>균등 크기 분할(equal-size partitions)</strong>은 모든 파티션을 같은 크기로 만든다. 어느 파티션을 고르든 상관없으므로 배치 정책(placement policy)이 필요 없어 단순하고 빠르며, 구현이 비교적 쉽다.</p>

<p>단점도 명확하다.</p>

<ol>
  <li><strong>내부 단편화(internal fragmentation)</strong>: 적재된 데이터가 파티션보다 작으면 남는 공간이 낭비된다. 아무리 작은 프로그램도 파티션 하나를 통째로 차지한다.</li>
  <li>활성 프로세스 수가 파티션 개수로 제한된다.</li>
  <li>프로그램 크기가 파티션 크기로 제한된다. 더 크면 <strong>오버레이(overlay)</strong> 기법으로 설계해야 한다.</li>
</ol>

<p><strong>비균등 크기 분할(unequal-size partitions)</strong>은 파티션 크기를 다양하게 둬서 균등 분할의 약점을 일부 보완하지만, 워크로드에 따라 효율이 달라진다. 프로세스를 파티션에 배정하는 방식은 두 가지다.</p>

<p><img src="/assets/images/posts/memory-management/memory-management-3.png" alt="고정 분할에서의 프로세스 배정" /></p>

<ul>
  <li><strong>파티션마다 큐(multiple queue)</strong>: 들어갈 수 있는 가장 작은 파티션에 배정 → 메모리 공간 효율 중시</li>
  <li><strong>전체 공용 큐 하나(single queue)</strong>: 들어갈 수 있는 사용 가능한 가장 작은 파티션에 배정 → 할당 시간 효율 중시</li>
</ul>

<p>고정 분할은 구현이 쉽고 OS 오버헤드가 작지만, 내부 단편화로 메모리 이용이 비효율적이고 멀티프로그래밍 정도와 프로세스 크기가 제한된다. (예: IBM OS/360)</p>

<h2 id="동적-분할-dynamic-partitioning">동적 분할 (Dynamic Partitioning)</h2>

<p>고정 분할의 한계를 극복하기 위해 등장했다. 파티션을 <strong>런타임에 동적으로</strong> 생성하며, 길이와 개수가 가변적이다. 프로세스가 메모리를 요청하면 <strong>필요한 만큼 정확히</strong> 할당한다. (예: IBM OS/360 MVT)</p>

<p>내부 단편화는 사라지지만 새로운 문제가 생긴다.</p>

<p><img src="/assets/images/posts/memory-management/memory-management-4.png" alt="동적 분할의 효과" /></p>

<p>프로세스가 적재·제거(swap out)되기를 반복하면, 메모리 곳곳에 작은 빈 공간이 흩어진다. 이것이 <strong>외부 단편화(external fragmentation)</strong>다. 전체 빈 공간을 합치면 충분한데도 연속된 블록이 없어 할당하지 못하는 상황이다. 해결책은 <strong>메모리 압축(memory compaction)</strong>으로, OS가 프로세스들을 한쪽으로 몰아 빈 공간을 하나의 큰 블록으로 합친다. 단, 이 작업은 CPU 시간을 소모한다.</p>

<h3 id="배치-알고리즘">배치 알고리즘</h3>

<p>동적 분할에서는 여러 빈 블록 중 어디에 할당할지 정하는 <strong>배치 알고리즘(placement algorithm)</strong>이 필요하다.</p>

<p><img src="/assets/images/posts/memory-management/memory-management-5.png" alt="16MB 블록 할당 전후" /></p>

<table>
  <thead>
    <tr>
      <th>알고리즘</th>
      <th>동작</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>First-fit</td>
      <td>메모리 맨 앞부터 스캔해 처음 만나는 충분한 블록에 할당</td>
    </tr>
    <tr>
      <td>Next-fit</td>
      <td>마지막으로 할당한 위치부터 스캔</td>
    </tr>
    <tr>
      <td>Best-fit</td>
      <td>요청 크기에 가장 가까운 블록을 선택</td>
    </tr>
  </tbody>
</table>

<p>흥미롭게도 <strong>best-fit은 이름과 달리 보통 가장 나쁜 성능</strong>을 낸다. 요청에 딱 맞는 블록을 고르다 보니 쓸모없이 작은 자투리 블록을 양산해 외부 단편화를 악화시키기 때문이다.</p>

<h2 id="버디-시스템-buddy-system">버디 시스템 (Buddy System)</h2>

<p>고정 분할과 동적 분할의 절충안이다. 메모리 블록은 $2^k$ 워드 크기로만 존재한다.</p>

<p><strong>버디 할당(buddy allocation)</strong>의 핵심은 분할(split)과 병합(coalesce)이다. 크기 $s$인 요청이 $2^{U-1} &lt; s \leq 2^U$를 만족하면 크기 $2^U$ 블록을 통째로 할당하고, 그렇지 않으면 블록을 크기 $2^{U-1}$인 두 개의 <strong>버디(buddy)</strong>로 쪼갠다. 블록이 반환되면 인접한 버디와 다시 병합한다.</p>

<p>예를 들어 7KB 블록을 요청하면, 64KB → 32KB → 16KB → 8KB로 계속 절반씩 쪼개 8KB 버디 하나를 할당한다.</p>

<p><img src="/assets/images/posts/memory-management/memory-management-6.png" alt="버디 시스템 예시" /></p>

<p>위는 1MB 블록에서 여러 요청·반환이 일어나는 과정이다. 반환 시 인접 버디가 비어 있으면 병합해 더 큰 블록으로 되돌린다. 리눅스를 비롯한 UNIX 커널의 메모리 할당에 변형된 버디 시스템이 쓰인다.</p>

<h2 id="페이징-paging">페이징 (Paging)</h2>

<p>여기서부터 비연속 할당이다. 페이징과 세그먼테이션의 공통 돌파구는 이것이다 — 프로세스를 여러 조각(페이지 또는 세그먼트)으로 쪼개고, 그 조각들이 <strong>메모리에 연속으로 있지 않아도</strong> 실행할 수 있게 한다.</p>

<p>페이징은 프로세스를 <strong>고정된 균등 크기 조각</strong>으로 나눈다.</p>

<ul>
  <li><strong>페이지(page)</strong>: 프로세스를 쪼갠 조각</li>
  <li><strong>프레임(frame)</strong>: 물리 메모리를 페이지와 같은 크기로 쪼갠 조각</li>
  <li><strong>페이지 테이블(page table)</strong>: 프로세스의 각 페이지가 어느 프레임에 있는지 기록. OS가 프로세스마다 관리한다.</li>
</ul>

<p><img src="/assets/images/posts/memory-management/memory-management-7.png" alt="페이징 — 프레임에 적재된 프로세스" /></p>

<p>위 그림에서 프로세스 A·C·D의 페이지들이 메모리 프레임에 흩어져 적재돼 있다. 연속될 필요가 없으므로 <strong>외부 단편화가 없다</strong>. 내부 단편화는 마지막 페이지의 일부에서만, 그것도 아주 조금 생긴다.</p>

<h3 id="페이징-주소-변환">페이징 주소 변환</h3>

<p>페이지 크기는 <strong>반드시 2의 거듭제곱</strong>이어야 한다. 그래야 논리 주소를 그냥 비트 단위로 잘라 페이지 번호와 오프셋으로 나눌 수 있다.</p>

<p><img src="/assets/images/posts/memory-management/memory-management-8.png" alt="페이징 주소 변환" /></p>

<p>페이지 번호 $n$비트, 오프셋 $m$비트라고 하면 변환 단계는 이렇다.</p>

<ol>
  <li>논리 주소의 <strong>왼쪽 $n$비트</strong>를 페이지 번호로 추출한다.</li>
  <li>페이지 번호를 페이지 테이블의 인덱스로 써서 <strong>프레임 번호 $k$</strong>를 찾는다.</li>
  <li>프레임의 시작 물리 주소는 $k \times 2^m$이고, 참조하는 바이트의 물리 주소는 거기에 오프셋을 더한 값이다.</li>
  <li>결국 <strong>프레임 번호 뒤에 오프셋을 그대로 이어 붙이면(append)</strong> 된다.</li>
</ol>

<p>구체적인 예로, 프레임 크기가 1KB이고 프로세스 P1의 페이지 1이 프레임 12에 매핑돼 있다면, 논리 주소 $\langle 1, 222 \rangle$의 물리 주소는 다음과 같다.</p>

\[12 \times 1024 + 222 = 12510\]

<h2 id="세그먼테이션-segmentation">세그먼테이션 (Segmentation)</h2>

<p>세그먼테이션도 비연속 할당이지만, 프로세스를 <strong>가변 크기(unequal-size)의 세그먼트</strong>로 나눈다. 코드, 힙(heap), 스택, 데이터처럼 <strong>의미 단위</strong>로 쪼갠다는 점이 페이징과 다르다.</p>

<p><strong>세그먼트 테이블(segment table)</strong>은 각 세그먼트의 <strong>limit(길이)와 base(시작 주소)</strong>를 담는다. 역시 OS가 프로세스마다 관리한다.</p>

<p><img src="/assets/images/posts/memory-management/memory-management-9.png" alt="세그먼테이션 구조" /></p>

<p>세그먼테이션은 동적 분할처럼 내부 단편화가 없지만, 가변 크기이므로 <strong>외부 단편화</strong>를 겪는다.</p>

<p>세그먼테이션의 강점은 <strong>메모리 영역의 의미를 보존</strong>한다는 데 있다. 페이징이 고정 크기 페이지 단위로 메모리를 관리하는 반면, 세그먼테이션은 논리적 프로그램 모듈 단위로 관리하므로 <strong>공유와 보호가 쉽다</strong>. 예를 들어 세그먼트마다 읽기·쓰기·실행 권한(permission)을 부여하고, 두 프로세스가 같은 코드 세그먼트를 같은 물리 주소로 공유하게 할 수 있다.</p>

<h3 id="세그먼테이션-주소-변환">세그먼테이션 주소 변환</h3>

<p><img src="/assets/images/posts/memory-management/memory-management-10.png" alt="세그먼테이션 주소 변환" /></p>

<p>세그먼트 번호 $n$비트, 오프셋 $m$비트일 때 변환 단계는 이렇다.</p>

<ol>
  <li>논리 주소의 왼쪽 $n$비트를 세그먼트 번호로 추출한다.</li>
  <li>세그먼트 번호로 세그먼트 테이블을 인덱싱해 세그먼트의 시작 물리 주소(base)를 찾는다.</li>
  <li>오른쪽 $m$비트의 오프셋을 세그먼트 길이(limit)와 비교한다. <strong>오프셋이 길이 이상이면 잘못된 주소</strong>(segment fault)다.</li>
  <li>물리 주소는 세그먼트 시작 주소(base)에 오프셋을 더한 값이다.</li>
</ol>

<p>페이징은 오프셋을 단순히 이어 붙였지만, 세그먼테이션은 base에 오프셋을 <strong>더하고</strong> limit 검사를 거친다는 점이 다르다.</p>

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

<table>
  <thead>
    <tr>
      <th>기법</th>
      <th>설명</th>
      <th>강점</th>
      <th>약점</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>고정 분할</td>
      <td>시스템 생성 시점에 정적 파티션으로 분할</td>
      <td>구현 단순, OS 오버헤드 작음</td>
      <td>내부 단편화, 활성 프로세스 수 고정</td>
    </tr>
    <tr>
      <td>동적 분할</td>
      <td>프로세스 크기에 맞춰 동적 생성</td>
      <td>내부 단편화 없음, 메모리 효율 ↑</td>
      <td>외부 단편화, 압축에 CPU 소모</td>
    </tr>
    <tr>
      <td>단순 페이징</td>
      <td>균등 크기 프레임/페이지로 분할</td>
      <td>외부 단편화 없음</td>
      <td>약간의 내부 단편화</td>
    </tr>
    <tr>
      <td>단순 세그먼테이션</td>
      <td>가변 크기 세그먼트로 분할</td>
      <td>내부 단편화 없음, 동적 분할보다 효율적</td>
      <td>외부 단편화</td>
    </tr>
  </tbody>
</table>

<p>연속 할당(고정·동적 분할)은 단편화 문제를 근본적으로 피하지 못한다. 비연속 할당인 페이징은 외부 단편화를, 세그먼테이션은 내부 단편화를 각각 제거한다. 둘의 장점을 합친 것이 현대 OS가 쓰는 세그먼테이션 기반 페이징이다.</p>]]></content><author><name>이주한</name></author><category term="Operating-System" /><category term="operating-system" /><category term="memory-management" /><category term="memory-model" /><category term="memory-barrier" /><category term="mmu" /><category term="address-translation" /><category term="fixed-partitioning" /><category term="dynamic-partitioning" /><category term="buddy-system" /><category term="paging" /><category term="segmentation" /><category term="fragmentation" /><summary type="html"><![CDATA[메모리 일관성 모델과 메모리 배리어, 메모리 관리 요구사항과 주소 변환, 고정·동적 분할과 버디 시스템, 그리고 페이징·세그먼테이션까지 메모리 관리 전반을 정리한다.]]></summary></entry></feed>