<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[dev_roach]]></title><description><![CDATA[Tech blog written by dev_roach, related to web, AI, data, Python]]></description><link>https://roach-wiki.com</link><image><url>https://cdn.hashnode.com/res/hashnode/image/upload/v1771451252947/32a95f01-8657-48b1-93cb-ba80885990c4.png</url><title>dev_roach</title><link>https://roach-wiki.com</link></image><generator>RSS for Node</generator><lastBuildDate>Tue, 07 Apr 2026 20:17:15 GMT</lastBuildDate><atom:link href="https://roach-wiki.com/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[RDB 에서 큰 컬럼을 인덱스로 잡으면 안되는 이유]]></title><description><![CDATA[B-Tree 는 기본적으로 페이지 사이즈 와 저장할 수 있는 원소의 개수를 고정값으로 사용한다. 하지만 우리가 실제로 페이지에 저장하는 값은 가변적인 크기를 가지고 있기 때문에 필연적으로 물리적으로 저장해야할 개수가 다 차기도 전에 페이지가 넘치는 상황에 부딪히게 된다.


예를 들어 100KB 를 저장하는 페이지에 위와 같이 데이터를 저장한 상태이다. 여]]></description><link>https://roach-wiki.com/rdb</link><guid isPermaLink="true">https://roach-wiki.com/rdb</guid><category><![CDATA[RDBMS]]></category><category><![CDATA[btree]]></category><category><![CDATA[indexing]]></category><dc:creator><![CDATA[roach]]></dc:creator><pubDate>Thu, 26 Feb 2026 11:45:52 GMT</pubDate><content:encoded><![CDATA[<p>B-Tree 는 기본적으로 <strong>페이지 사이즈</strong> 와 저장할 수 있는 원소의 개수를 고정값으로 사용한다. 하지만 우리가 실제로 페이지에 저장하는 값은 가변적인 크기를 가지고 있기 때문에 필연적으로 물리적으로 저장해야할 개수가 다 차기도 전에 페이지가 넘치는 상황에 부딪히게 된다.</p>
<img src="https://cdn.hashnode.com/uploads/covers/67fe45dbb4cbfd9e7e0f9b47/ca9b72d8-5586-4c24-84fb-dcd172b71008.png" alt="" style="display:block;margin:0 auto" />

<p>예를 들어 100KB 를 저장하는 페이지에 위와 같이 데이터를 저장한 상태이다. 여기서 데이터 40KB 짜리를 하나 더 넣으면 어떻게 될까? 물리적인 한계로 이 페이지에는 아마 저장할 수 없을 것이다. 그렇다면 어떻게 동작해야 할까?</p>
<h3>Page Split</h3>
<p>일단 <strong>페이지를 분할(Page Split)</strong> 하는 방법이 있다. 물리적인 페이지가 가득차게 되면 HDD 나 SDD 로부터 새로운 페이지를 할당 받아 데이터의 절반을 이주 시킵니다.</p>
<blockquote>
<p>여기서 넘치는 것만 이주시키는 것이 아니라 반반해서 이주시키는 이유는 만약 Page 1 에 데이터가 또다시 들어오게 되는 경우 빈번하게 Page Split 이 일어날 확률이 높기 때문입니다.</p>
</blockquote>
<img src="https://cdn.hashnode.com/uploads/covers/67fe45dbb4cbfd9e7e0f9b47/579f75cd-11d6-4e02-9780-95d484467e2f.png" alt="" style="display:block;margin:0 auto" />

<p>예를 들어 40KB 를 또 삽입시켰다고 해봅시다. 이제 새로운 Page2 가 생기고 기존 Page 1 에서 5:5 로 나눠진 #2 가 Page 2 로 오게 됩니다. 즉 <strong>Page 1 에서</strong> 다음 데이터를 받기 위한 여유 공간들이 생기게 됩니다. 그리고 삭제/업데이트 등등이 되면서 더더욱 단편화가 많이 일어날 수도 있겠죠?</p>
<img src="https://cdn.hashnode.com/uploads/covers/67fe45dbb4cbfd9e7e0f9b47/d39b00ed-dc86-42b6-b5f8-f52c67c7b14d.png" alt="" style="display:block;margin:0 auto" />

<p>그렇기 때문에 <strong>인덱스를 리빌드</strong> 하는 과정이 필요합니다. 리빌드를 하게되면 어느정도 단편화가 해소됩니다. 만약에 너무 큰 데이터가 오면 어떻게 될까요? 예를 들면 95KB 같은 데이터가 들어오게 되는 경우 입니다. 그 경우에는 페이지에 하나의 원소밖에 저장되지 않게 되고, 이는 B-Tree 내부 페이지의 성능을 악화시키게 됩니다.</p>
<p>그래서 대부분의 데이터 베이스에서는 <code>max_payload_size</code> 이하인 것들을 페이지 내부에 저장하고 <code>max_payload_size</code> 를 초과하는 데이터의 경우에는 <strong>Overflow Page</strong> 에 저장하게 됩니다.</p>
<h3>오버 플로우 페이지(Overflow Page)</h3>
<img src="https://cdn.hashnode.com/uploads/covers/67fe45dbb4cbfd9e7e0f9b47/6cf47364-fca2-4483-90b2-426a4bd098ae.png" alt="" style="display:block;margin:0 auto" />

<h5>너무 큰 데이터가 들어오면 위와 같이 페이지를 망가트리는 경우가 있게 될 수 있으므로 Linked List 를 통해서 <strong>원본 Page(Primary Page) 에서 link 를 통해 Overflow Page 로 갈수 있도록 연결</strong>시켜 둡니다. 이렇게 함으로써 페이지에는 최대한 정렬된 순서로 많은 원소가 들어갈 수 있게 됩니다.</h5>
<h2>마치며</h2>
<h5>결과적으로 <strong>메인 페이지(Primary Page)를 가볍게 유지</strong>하면, 하나의 페이지에 들어갈 수 있는 인덱스 <strong>키(Key)의 개수(Fanout)를 최대로 확보</strong>할 수 있습니다. 이는 거대한 데이터가 들어오더라도 B-Tree의 <strong>전체 트리 깊이(Tree Depth)가 깊어지는 것을 방지</strong>하며, 데이터베이스 검색 성능의 핵심인 '디스크 I/O 횟수'를 최소화하는 결정적인 역할을 합니다.</h5>
<p>결론적으로 B-Tree는 데이터가 점진적으로 늘어나는 일반적인 상황은 **'페이지 분할(Page Split)'**을 통해 트리의 균형을 맞추며 확장하고, 트리의 구조 자체를 위협하는 비정상적으로 큰 데이터는 '오버플로우 페이지(Overflow Page)'로 격리하는 투트랙(Two-track) 전략을 통해 빠르고 안정적인 검색 성능을 유지한다고 볼 수 있습니다</p>
]]></content:encoded></item><item><title><![CDATA[Slotted Page]]></title><description><![CDATA[데이터베이스와 관련된 기술을 보다보면 어떻게 데이터를 관리하고 저장하지? 특히 단편화(Fragmentation) 이 일어나는 것을 어떻게 통제하고 관리할까? 혹은 정렬된 자료구조 내부에서 데이터의 순서를 보존하기 위해 어떠한 행위들을 할까? 궁금해집니다. 오늘은 조금 더 데이터베이스 내부에 쓰이는 자료구조를 들여다보며 연관된 행위를 공부해보려고 합니다.
F]]></description><link>https://roach-wiki.com/slotted-page</link><guid isPermaLink="true">https://roach-wiki.com/slotted-page</guid><category><![CDATA[btree]]></category><category><![CDATA[Slotted Page]]></category><dc:creator><![CDATA[roach]]></dc:creator><pubDate>Sun, 22 Feb 2026 10:27:39 GMT</pubDate><enclosure url="https://cloudmate-test.s3.us-east-1.amazonaws.com/uploads/covers/67fe45dbb4cbfd9e7e0f9b47/6c6abd67-f7c1-4085-a8e4-f8002ea8f22a.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>데이터베이스와 관련된 기술을 보다보면 어떻게 데이터를 관리하고 저장하지? 특히 <strong>단편화(Fragmentation)</strong> 이 일어나는 것을 어떻게 통제하고 관리할까? 혹은 정렬된 자료구조 내부에서 데이터의 순서를 보존하기 위해 어떠한 행위들을 할까? 궁금해집니다. 오늘은 조금 더 데이터베이스 내부에 쓰이는 자료구조를 들여다보며 연관된 행위를 공부해보려고 합니다.</p>
<h2>Fixed-size</h2>
<p>데이터를 넣을때 저희가 넣는 데이터는 보통 사이즈가 안정해져있는 경우가 많습니다. 이를 <strong>variable-size records</strong> 라고 호칭하는데요. 이러한 데이터를 넣게 되면 <strong>가변 크기의 Page 를 읽거나 쓰는데 오버헤드</strong>가 생기거나 복잡해져 <strong>Fixed-size 의 Page 로 read, write 를 하는 방식이 대부분</strong>의 데이터베이스에서 이뤄집니다. (물론 variable-size 로 저장하는 방식도 있습니다)</p>
<p>Fixed-size 의 경우 좋아보이지만 아래와 같이 <strong>내부 단편화(Internal Fragementation)</strong> 문제가 발생합니다.</p>
<blockquote>
<p>가변 길이 데이터를 저장하기 위해 페이지 내부를 N byte 단위의 **고정 크기 슬롯(또는 세그먼트)**으로 쪼개어 관리한다고 가정해 봅시다. 이때 M byte의 데이터를 저장한다면? <code>N - (M modulo N)</code> byte 만큼의 공간이 낭비됩니다.</p>
</blockquote>
<p>실제로 64 byte 를 N 으로 우리가 저장하려는 레코드의 사이즈 M 을 70 으로 잡으면 <strong>58 byte 만큼의 공간이 낭비</strong>됩니다. 대부분 실생활의 어플리케이션에서 저장되는 데이터들은 사이즈가 가변인 경우가 많으므로 내부 단편화가 지속적으로 생기게 됩니다.</p>
<p>이러한 문제를 어떻게 해결할 수 있을까요? 가장 간단한 방법으로는 부족한 공간을 기억하고 있다가 하나의 Page 로 치환할 정도의 공간이 나온다면 레코드를 여유가 되는 위치에 삽입하는 방법입니다. 하지만 이렇게 되면 실제로 저장된 레코드의 오프셋이 이동이 되어 메타데이터를 저장하고 있는 부분에 베타적인 Lock 을 거는 행위등이 발생 할 수 있고 꽤나 큰 오버헤드가 발생할 수 있습니다.</p>
<h2>Slotted Page</h2>
<p>이러한 문제를 해결하기 위해 <code>Slotted Page</code> 라는 개념이 도입되게 됩니다. Slotted Page 는 <strong>Pointer 영역</strong>과 <strong>Cell 영역</strong>을 나누어 관리합니다. (Page Header 영역도 있습니다)</p>
<h3>Pointer Array</h3>
<p><strong>포인터 배열</strong> 영역은 실제 데이터가 저장된 위치(Offset) 을 가르키는 포인터의 배열입니다. 페이지의 앞부분인 Header 바로 뒷 부분에 위치합니다.</p>
<p>Postgresql 을 공부해보셨다면 이 개념을 Heap 에서 보셨을 거라 생각이 듭니다.</p>
<h4>장점</h4>
<p>왜 Pointer 영역이 존재할까요? 위에도 언급했지만 실제 저장된 <strong>Record 는 사이즈가 크기 때문에 재 정렬을 위한 이동과정에서 많은 오버헤드</strong>가 발생합니다. 하지만 실제 저장된 데이터는 가만히 있고, 참조하는 <strong>Pointer 의 위치만 바꾸게 되면 실제 데이터는 움직이지 않았지만 정렬</strong> 된 것 처럼 보이게 되는 것이죠.</p>
<p>또한 외부에서 실제 Actual Record 를 참조하게 된다면 실제 Record 가 저장된 offset 을 기억해야 합니다. 즉, 이 offset 관리에 또 overhead 가 발생됩니다. 이는 단편화가 발생한 지역을 청소하는 시점에 또 다른 오버헤드로 부가됩니다.</p>
<p>지금 처럼 Pointer 로 관리되는 구조에서는 외부에서는 <strong>Pointer 를 통해 간접 참조</strong>만 시행하면 됩니다. 즉, 실제 값의 Actual offset 을 참조할 일이 없어지는 것이죠.</p>
<h4>단점</h4>
<p>단점으로는 아래와 같이 크게 두가지가 존재합니다.</p>
<ul>
<li><p>actual offset 을 참조하지 않고 pointer 를 통해 참조하므로 간접 참조 비용 발생</p>
</li>
<li><p>pointer array 를 저장하기 위한 추가 저장공간 필요</p>
</li>
</ul>
<p>위와 같은 단점이 있지만 단점을 상쇄할만큼의 이점이 있어 Postgresql 과 같은 데이터베이스에서는 Pointer Array 를 운용합니다.</p>
<h3>Cell 영역</h3>
<p>Cell 영역은 페이지의 맨 뒷 부분부터 시작되어 앞쪽을 향해 실제로 채워지는 데이터입니다. Pointer 영역과 역방향으로 성장하는 이유는 둘이 같은 방향으로 성장하게되면 빈 공간이 여러 공간으로 쪼개질 수 있는데, <strong>역방향으로 성장하게 되면 빈 공간은 이 두 영역의 중간 공간에만 생기기 때문</strong>입니다.</p>
<p>실제 데이터는 <strong>가변 크기(variable size) 의 레코드(Postgresql 의 경우 Tuple) 형태로 저장</strong>됩니다. Pointer 배열의 특정 Slot 이 이 Cell 의 시작지점을 가리키게 됩니다.</p>
<p>위에서 설명한대로 데이터가 가변적이다 보니 내부에 단편화 현상이 발생하게 됩니다. 가변 데이터의 특성상 이 구멍의 사이즈에 맞는 데이터가 들어오지 않는다면, 이 구멍은 영원히 채워지지 않은 상태로 존재하게 됩니다.</p>
<h4>빈 공간 회수(Defragmentation / Compaction)</h4>
<p>그래서 윈도우 사용자라면 익숙한 <strong>빈공간 회수를 위한 조각 모음(Compaction)</strong> 이 이뤄집니다. Cell 영역에 빈 공간이 많아지면 시스템에서 유효한 Cell 들을 모아 맨 끝쪽으로 재배치를 진행합니다. 이 과정에서 <code>offset</code> 이 변경되지만 외부에서는 pointer 를 통해 간접 참조하므로 문제가 발생하지 않습니다.</p>
<h2>그림으로 이해하기</h2>
<img src="https://cloudmate-test.s3.us-east-1.amazonaws.com/uploads/covers/67fe45dbb4cbfd9e7e0f9b47/b6f1c7a6-511b-4e83-980d-201d22745de4.png" alt="" style="display:block;margin:0 auto" />

<p>위 그림을 보면 첫번째 레코드를 삽입하면 <strong>전단 부분에 Slot (Pointer)</strong> 이 생성되고 실제 Record 가 저장된 Offset 을 가리키고 있는 것을 확인할 수 있습니다. 중간 부분은 Free Space 이고, Record 는 맨 뒷 부분에 기록됩니다.</p>
<img src="https://cloudmate-test.s3.us-east-1.amazonaws.com/uploads/covers/67fe45dbb4cbfd9e7e0f9b47/70efaa5f-d629-4bd6-aedd-7339511435a7.png" alt="" style="display:block;margin:0 auto" />

<p>데이터를 추가할때 마다 중간 Free Space 가 줄어듭니다. <code>Record 2</code> 를 만약 위 그림 처럼 삭제한다면 어떨까요? <strong>Record 1 과 3 사이에 구멍(Hole)</strong> 이 생기며 단편화가 발생하게 됩니다. 낭비된 공간의 회수를 위해 빈공간 회수를 해봅시다.</p>
<img src="https://cloudmate-test.s3.us-east-1.amazonaws.com/uploads/covers/67fe45dbb4cbfd9e7e0f9b47/5615aaff-ca06-4e91-9e00-b3fa608aefdb.png" alt="" style="display:block;margin:0 auto" />

<p>빈 공간 회수를 하면 위에서 설명했던 것과 같이 유효한 Cell 들만 모아 끝쪽으로 재배치하며 유효하지 않은 부분에 대한 공간을 회수하게 됩니다.</p>
<h2>마치며</h2>
<p>확실히 Database Internals 를 읽으면서 그간 배웠던 Postgresql 에 대한 내용도 정리되는 것 같다. 그리고 Gemini 3.1 은 확실히 전작보다 시각 적인 부분에서 코딩을 잘한다. 위의 예시들은 전부 Gemini 에게 시각화를 시키며 학습하였다.</p>
]]></content:encoded></item><item><title><![CDATA[TF-IDF 와 BM25]]></title><description><![CDATA[최근 벡터 데이터베이스 설계와 구축이라는 책을 스터디하고 있는데, 거기서 TF-IDF 라는 개념을 배우게 되었다. 이전에 ES 를 쓰고 있어서 어림잡아 알고 있긴했는데, 이번 기회에 확실히 코드로 작성하며 숙달하고 이해하고 넘어가려고 한다. 오늘은 TF-IDF 의 의미를 알아보고 코드로 작성하며 이해해보자.

이 글에서 corpus 라는 용어를 많이 쓰게 ]]></description><link>https://roach-wiki.com/tf-idf-bm25</link><guid isPermaLink="true">https://roach-wiki.com/tf-idf-bm25</guid><category><![CDATA[bm25]]></category><category><![CDATA[TF-IDF]]></category><dc:creator><![CDATA[roach]]></dc:creator><pubDate>Sat, 21 Feb 2026 11:37:52 GMT</pubDate><content:encoded><![CDATA[<p>최근 <strong>벡터 데이터베이스 설계와 구축</strong>이라는 책을 스터디하고 있는데, 거기서 TF-IDF 라는 개념을 배우게 되었다. 이전에 ES 를 쓰고 있어서 어림잡아 알고 있긴했는데, 이번 기회에 확실히 코드로 작성하며 숙달하고 이해하고 넘어가려고 한다. 오늘은 <strong>TF-IDF</strong> 의 의미를 알아보고 코드로 작성하며 이해해보자.</p>
<blockquote>
<p>이 글에서 <strong>corpus</strong> 라는 용어를 많이 쓰게 될텐데 이글 내부에서는 <strong>우리가 가지고 있는 문서의 콜렉션</strong>으로 해석하면 된다.</p>
</blockquote>
<p>* 이 글에서 <strong>용어(Term) 의 단위는 공백(뛰어쓰기) 기준</strong>으로 나눈다. 실제로는 사용하는 Tokenizer 에 따라 계산이 다르게 될 수 있다.</p>
<h2>TF(Term Frequency)</h2>
<p>TF 는 용어 그대로 <strong>문서(Document) 에 나타나는 단어의 빈도수</strong>를 의미한다. 기호로는 <code>TF(t, d)</code> 로 표기한다. 간단하게 <code>t(Term)</code> 이 얼마나 <code>d(Document)</code> 에 많이 등장하는가를 나타내는 함수이다.</p>
<pre><code class="language-plaintext">TF(t, d) = t 가 d 에서 나타나는 빈도수 / d 의 모든 용어 수
</code></pre>
<p>아주 간단하게 위와 같이 계산된다. TF 의 <strong>분자값은 t 의 빈도수에 영향</strong>을 받고, <strong>분모값은 용어의 총 개수의 영향</strong>을 받는다. 즉, 단어가 document 에서 많이 등장할수록 점수가 비례하여 높아지는 것이다.</p>
<p>파이썬 코드로 작성하면 아마 아래와 같이 작성해볼수 있을 것이다.</p>
<pre><code class="language-python">def tf(t: str, d: Document) -&gt; float:
    tokens = d.text.split()
    frequency = tokens.count(t)
    return frequency / len(tokens)
</code></pre>
<p>아주 심플하다. 그런데 코드를 작성하다보니 아래와 같은 의문이 든다.</p>
<blockquote>
<p>왜 분모에 <code>d 의 모든 용어 수</code> 라는 부분이 있을까? 그냥 단순하게 빈도수만 보면 안되나?</p>
</blockquote>
<p>이렇게 분모에 단어수와 같은 제약을 두는 이유는 아래와 같다. 만약에 아래와 같은 문서 두개가 있다고 해보자.</p>
<ul>
<li><p>문서 A : 사과 농장에서 자라는 사과는 정말 맛있어요.</p>
</li>
<li><p>문서 B : 사과하고 싶지만 영수는 사과를 할수 없었고, .... (1000 단어 가량의 문서 길이)</p>
</li>
</ul>
<p>두 문서를 봤을때 <strong>"사과"</strong> 라고 검색한다고 하면 <code>문서 A</code> 는 <code>TF(2, 6)</code> 으로 나오고 문서 B 는 생략된 부분에 사과가 한개 더 등장해서 <code>TF(3, 1000)</code> 이 되었다고 해보자. 만약 빈도수만 본다면 문서 B 가 나오는 것이 정상이다.</p>
<p>하지만 빈도수가 높기만 한걸로 측정한다면 해당 용어가 그 문서내에서 얼마나 중요한 단어인지는 판단할수가 없다. A 문서에서는 6개의 단어중 2개인 1/3 이 "사과" 이므로 1000개 중에 3번 나오는 문서 B 에 비해 더 "사과" 가 중요한 비중을 차지하고 있다고 해석해볼 수 있다. 이것이 TF 의 분모에 문서 내에서 용어의 총 개수가 존재하는 이유이다.</p>
<h2>IDF(<strong>Inverse Document Frequency)</strong></h2>
<p><strong>IDF</strong> 는 흔한 단어는 가중치를 낮추고, corpus 내부에서 빈도수가 낮은 단어의 가중치를 올려주는 역할이다. 영어 문서를 예로 들자면 "the", "a" 와 같은 관사들은 주로 등장하므로 가중치가 낮아지고, "API" 와 같은 단어들은 개발문서에만 등장하므로 상대적으로 가중치가 높아진다. 가중치를 낮춰야 하므로 <strong>역함수의 형태</strong>를 뛰어 이번엔 아래와 같이 빈도수가 분모로 가게 된다.</p>
<pre><code class="language-plaintext">IDF(t, D) = log (corpus 내부의 문서 개수 / t 를 포함하는 문서의 개수)
</code></pre>
<p><code>log</code> 를 씌우는 이유는 스케일을 완만하게 만들기 때문이다. 직관적으로 와닿지 않을 수 있으니 아래와 같은 예시가 있다고 해보자.</p>
<ul>
<li><p>the =&gt; 1000개의 문서 집합에서 1000번 등장 <code>N / DF = 1</code></p>
</li>
<li><p>quantum =&gt; 1000개의 문서 집합에서 1번 등장 <code>N / df = 1000</code></p>
</li>
</ul>
<p>위의 문서 셋에서 quantum 은 1000배나 중요하다고 판단된다. 즉, 과대평가가 되어 검색어의 결과의 하한(threshold) 를 설정하는데 문제가 될수도 있다. 그래서 log 를 씌우게 되면 quantum 의 중요도는 6.9가 되어 스케일이 줄어들게 된다.</p>
<blockquote>
<p>AI 와 대화해보니 정보이론적 근거가 있다고 하긴 하는데.. 그 부분은 내 분야가 아니므로 한번 궁금하면 공부해보길 바란다. 아마 엔트로피 개념과 연관되어 있지 않을까 싶다.</p>
</blockquote>
<p>IDF 는 파이썬으로 코드를 작성해보면 아래와 같이 작성해볼 수 있을 것이다.</p>
<pre><code class="language-python">def idf(t: str, corpus: list[Document]) -&gt; float:
    n = len(corpus)
    df = sum(1 for doc in corpus if t in doc.text.split())
    return math.log(n / df)
</code></pre>
<p>아직까지는 이해하기 쉽다. 이제 이 둘을 섞은 <code>TF-IDF</code> 에 대해서 알아보자.</p>
<h2>TF-IDF</h2>
<p><strong>TF-IDF</strong> 는 더 단순하다 <strong>TF * IDF</strong> 를 한것이다. 실제로 아래 수식처럼 그냥 곱하기를 하면 된다.</p>
<pre><code class="language-plaintext">TF-IDF(t, d, D) = TF(t, d) * IDF(t, D)
</code></pre>
<p>일단 이 수식이 뭔지 이해하기 전에 TF 와 IDF 를 한번만 더 짚고 넘어가보자.</p>
<ul>
<li><p><strong>TF: 이 용어(Term) 이 문서(d) 내부에서 얼마나 자주 등장하는가?</strong></p>
</li>
<li><p><strong>IDF: 이 용어가 corpus 내부에서 얼마나 희귀한가?</strong></p>
</li>
</ul>
<p>이 둘을 곱한 것이므로 <strong>빈도수가 높지만 흔한 단어(the 와 같은) 들은 IDF 점수가 낮으므로 상대적으로 낮은 점수</strong>를 받게 될 수 있고, 빈도수가 낮지만 희귀한 단어들은 IDF 점수가 높으므로 상대적으로 높은 점수를 받을 수 있게 된다.</p>
<p>즉, corpus 내부에서 내가 검색하려는 용어(t) 에 대해 중요도와 빈도수를 어느정도 조합하여 검색하는 것이라 보면 된다. 하지만 위의 수식을 보면 알듯이 <code>TF(t, d)</code> <strong>가 압도적으로 높다면 아무리 희귀하지 않다고 해도 점수가 높게 측정될 수도 있다</strong>.</p>
<p>이렇게 되면 상관없는 문서들이 나오게 될 수 있으므로 <strong>별도의 가중치</strong>를 두어야 하나? 아니면 <strong>상한(maximum threshold)</strong> 를 두어서 막아야 하나? 등의 고민을 해볼만 하다. 하지만 이런 방법론을 연구하고 실험하는 것 또한 어렵다. 이를 해결하기 위한 유명한 솔루션은 <strong>BM25</strong> 라는 방식을 이용하는 거라고 한다.</p>
<h2>BM25</h2>
<img src="https://cloudmate-test.s3.us-east-1.amazonaws.com/uploads/covers/67fe45dbb4cbfd9e7e0f9b47/8bc82346-8fb5-4739-9ef5-e9ecf1c86862.png" alt="" style="display:block;margin:0 auto" />

<p><strong>BM25</strong> 의 수식은 위와 같다. 엄청 복잡해 보이지만 복잡하게 적은 것일 뿐 하나하나 뜯어보면 그렇게 어렵지는 않다.</p>
<table style="min-width:50px"><colgroup><col style="min-width:25px"></col><col style="min-width:25px"></col></colgroup><tbody><tr><th><p>Q</p></th><th><p>검색하려는 쿼리의 토큰 으로 분리한 집합 ({q_1, q_2, q_3, ...})</p></th></tr><tr><td><p>D</p></td><td><p>점수를 매기려는 개별 문서</p></td></tr><tr><td><p>TF(q_i, D)</p></td><td><p>Term Frequency 위에서 설명했으니 여기에선 별도로 안함</p></td></tr><tr><td><p>|D|</p></td><td><p>문서 D 의 총 토큰 수(긴 문서는 너무 커지니 보정 필요)</p></td></tr><tr><td><p>avgdl</p></td><td><p>전체 corpus 의 평균 토큰 수</p></td></tr><tr><td><p>N</p></td><td><p>corpus 내부의 전체 문서 수</p></td></tr><tr><td><p>df(q_i)</p></td><td><p>토큰 q_i 가 등장하는 문서의 수</p></td></tr><tr><td><p>k_1</p></td><td><p>TF 의 포화속도를 결정하는 값(튜닝해야 하는 값임)</p></td></tr></tbody></table>

<p>여기서 대부분은 우리가 이해할 수 있지만 <code>k_1</code> 은 새롭게 보는 개념이다. 이는 포화를 위한 계수인데 보통 1.2 ~ 2.0 사이의 값을 채택한다고 한다.</p>
<h2>K1 (term frequency saturation)</h2>
<pre><code class="language-plaintext">score = (k_1 + 1) * tf / (k_1 + tf)
</code></pre>
<p>위와 같은 수식일때 tf 가 커지면 커질수록 <strong>분자/분모</strong>가 모두 커지므로 일정값에 수렴하게 된다. 예를 들어, <code>k_1</code> 이 <code>1</code> 이라고 해보면 아래와 같은 <code>tf</code> 값을 대입해가며 나오는 값을 확인해볼 수 있다.</p>
<pre><code class="language-plaintext">  tf = 1  →  2·1/(1+1) = 1.00
  tf = 2  →  2·2/(2+1) = 1.33  (+0.33)
  tf = 3  →  2·3/(3+1) = 1.50  (+0.17)
  tf = 5  →  2·5/(5+1) = 1.67  (+0.17 for 2 steps)
  tf = 100 → 2·100/101 = 1.98  (+0.31 for 95 steps)
</code></pre>
<p>위의 결과를 보면 <strong>1 -&gt; 2 로 갈때는 0.33</strong> 이나 늘었지만 <strong>5 -&gt; 100 일때는 0.31</strong> 밖에 오르지 않았다. 즉, 분자가 아무리 커져도 분모도 커지므로 점점 영향력이 약해지는 것이다. 즉, <code>k_1</code> 의 값이 높아지면 높아질수록 느리게 포화되고 상한선 또한 높아진다.</p>
<p>그래프로 동일한 corpus 에서 k_1 = 1.0 일때와 k_1 = 3.0 일때를 그래프로 비교해보자.</p>
<img src="https://cloudmate-test.s3.us-east-1.amazonaws.com/uploads/covers/67fe45dbb4cbfd9e7e0f9b47/0e4099cd-0007-4080-98c9-9b769fdaeabf.png" alt="k1 = 1.0" style="display:block;margin:0 auto" />

<img src="https://cloudmate-test.s3.us-east-1.amazonaws.com/uploads/covers/67fe45dbb4cbfd9e7e0f9b47/21ea4f91-d436-4380-8c5d-b9fda644f8e5.png" alt="" style="display:block;margin:0 auto" />

<p>위 그래프를 보면 k_1 이 3.0 일때 점수자체가 좀 더 높은것을 알수 있고 일정스코어 이상에 수렴하기 까지 걸리는 시간도 오래걸림을 알 수 있다. 조금 더 해석을 해보자면 <strong>TF 점수를 얼마나 믿을 것 인가?</strong> 로도 해석해 볼 수 있다.</p>
<h2>b (length normalization parameter)</h2>
<img src="https://cloudmate-test.s3.us-east-1.amazonaws.com/uploads/covers/67fe45dbb4cbfd9e7e0f9b47/97ca04a9-e3fd-4848-9f00-46f5ba993a88.png" alt="" style="display:block;margin:0 auto" />

<p>b 또한 가중치인데요. 여기서는 <code>(1 - b + b * |d|/avgdl)</code> 에만 집중하면 조금 더 쉽게 이해할 수 있습니다. 일단 <code>|D| * avgdl</code> 을 먼져 해석해보면 <strong>이 문서가 평균적으로 얼마나 긴가?</strong> 를 나타내는 값으로 볼 수 있습니다. 이 값이 분모에 있으므로 <strong>문서가 길면 길수록 점수가 낮아지는 구조</strong>가 됩니다. 즉, 문서가 길면 어떤 단어가 나올 확률도 높다고 생각해서 정규화를 해준다고 생각하면 됩니다.</p>
<p>그러면 수식을 쉽게 이해하기 위해 b 가 0 일때를 가정해보겠습니다. b 가 0 이면 <code>1 - 0 + 0 * |d|/avgdl</code> 이 되므로 사실상 문서 문서길이에 대한 패널티를 안보겠다는 것과 같습니다. 반대로 1일때는 어떨까요? <code>1 - 1 + 1 * |d|/avgdl</code> 이 되므로 "문서 길이에 대한 패널티만 적용하겠다" 와 같습니다. 즉, <strong>1 에 가까우면 가까울수록 문서 길이에 비례한 점수 페널티가 커집</strong>니다.</p>
<p>이것도 그래프로 한번 살펴보도록 하겠습니다.</p>
<img src="https://cloudmate-test.s3.us-east-1.amazonaws.com/uploads/covers/67fe45dbb4cbfd9e7e0f9b47/58b97a11-70d4-4489-a2fa-243296035a2b.png" alt="" style="display:block;margin:0 auto" />

<p>b 가 0 일때는 <code>|d|/avgdl</code> 에 대한 패널티가 없는 상태입니다. 따라서 x 축의 값인 <code>|d| / avgdl</code> 이 올라가도 그래프가 변하지 않습니다.</p>
<img src="https://cloudmate-test.s3.us-east-1.amazonaws.com/uploads/covers/67fe45dbb4cbfd9e7e0f9b47/5a58b665-38fc-4722-a8e2-caac5be04cfd.png" alt="" style="display:block;margin:0 auto" />

<p>b 가 1 일때는 <code>|d| / avgdl</code> 이 커지면 커질수록 우하향 하는 모습을 확인할 수 있습니다. 즉, 분모의 값이 커지므로 점수가 빠르게 내려가는 것을 확인할 수 있습니다.</p>
<h2>정리</h2>
<p>BM25 수식쪽에와서 살짝 복잡해진 부분은 있지만 하나하나 뜯어봤을때 아래와 같은 부분들을 커버한다고 이해하면 될 것 같습니다.</p>
<ul>
<li><p>TF saturation 은 TF 일정 단어가 반복되는 스팸문서에 대한 페널티를 적용합니다.</p>
</li>
<li><p>Length Normalization 은 긴 문서일수록 분모가 커져서 점수를 깎아 긴 문서에 대한 보정을 적용합니다.</p>
</li>
<li><p>IDF 희귀한 단어일수록 가중치를 줍니다</p>
</li>
</ul>
<p>Openclaw 나 LLM 메모리에도 적용해 볼수 있을까? 근데 대화내에서 문서를 어떤 단위로 잡아야 할지.. 뭐 이런것들이 고민이라 쉽지 않을것 같다.</p>
]]></content:encoded></item><item><title><![CDATA[MCP 를 통한 workflow 자동화]]></title><description><![CDATA[AI native

최근에 LinkedIn 이나 여러 소셜 플랫폼들의 글을 보면 AI native 회사 라는 워딩들이 많이 보입니다. IBM 의 정의에 따르면 AI native 를 아래와 같이 정의한다고 하는데요.

“AI를 사고와 업무 방식에 끊임없이 내재화하는 상태”

그렇다면 팀원들이 계속해서 AI 를 사고와 업무 방식에 끊임 없이 내재화 하려면 어떻게 해야할까요? 개발자들은 이미 Claude code 나 Codex 등 여러 AI Tool...]]></description><link>https://roach-wiki.com/mcp-workflow</link><guid isPermaLink="true">https://roach-wiki.com/mcp-workflow</guid><dc:creator><![CDATA[roach]]></dc:creator><pubDate>Sat, 14 Feb 2026 12:35:44 GMT</pubDate><content:encoded><![CDATA[<h2 id="heading-ai-native">AI native</h2>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1771071648205/95232f7d-c89e-42f8-9d96-dfdbdc40cb44.png" alt class="image--center mx-auto" /></p>
<p>최근에 LinkedIn 이나 여러 소셜 플랫폼들의 글을 보면 <strong>AI native 회사</strong> 라는 워딩들이 많이 보입니다. IBM 의 정의에 따르면 AI native 를 아래와 같이 정의한다고 하는데요.</p>
<blockquote>
<p><em>“<strong><strong>AI를 사고와 업무 방식에 끊임없이 내재화하는 상태</strong></strong>”</em></p>
</blockquote>
<p>그렇다면 팀원들이 계속해서 AI 를 사고와 업무 방식에 끊임 없이 내재화 하려면 어떻게 해야할까요? 개발자들은 이미 Claude code 나 Codex 등 여러 AI Tool 들을 사용하는데 익숙하지만 대부분의 비개발자 직군의 사람들은 별도로 공부를 하지 않는다면 사용하는 사람들을 찾기 어렵습니다. 또한 금전적인 문제 또한 이 분야에 가장 큰 이슈이기도 하기 때문입니다.</p>
<p>이러한 생각을 하다 사내에서 비개발자 직군분들이 Claude code 를 알려주면 잘 쓸수 있을까? 라는 고민을 하기 시작했고, 이를 기반으로 Claude code 를 어떻게 사용해야 하는지에 대한 <strong>아래처럼 장표를 만들고 이를 공유하는 자리</strong>를 가지게 되었습니다.</p>
<h2 id="heading-workflow-claude-code">Workflow 로서의 Claude code</h2>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1771061887814/782a4e4d-747b-4084-b9b4-44d635d73833.png" alt class="image--center mx-auto" /></p>
<p>세션 진행간 Terminal 보다는 GUI 기반의 Google Antigravity 를 통해 Claude code 를 이용하도록 했고, Context 와 SKILL 의 개념 그리고 워크플로우에 어떻게 적용해야 하는지 등등을 공유했습니다.</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1771067700669/b37920b8-b5a5-41dd-9334-2e769e2d2c55.png" alt class="image--center mx-auto" /></p>
<p>세션에서 가장 중요하게 공유했던 부분은 <strong>SKILL</strong> 에 관한 부분인데요. 이 SKILL 을 가장 중요하게 생각하는 이유는 <strong>개인이 특정한 Task 를 수행할 때 수행하는 일련의 작업이나 지식들을 담는 곳</strong> 이기 때문입니다. claude code 와 같은 도구들은 이미 많이 SOTA 모델을 잘 오케스트레이션하여 사용하여 이미 똑똑하지만 학습되지 않은 연속적인 작업들을 수행하는데는 어려움을 겪으므로 SKILL 을 통해 지식을 전수해줘야 하기 때문입니다.</p>
<p>그래서 SKILL 의 예시로 세션에서 <code>playwright</code> 를 통해 브라우저 자동화를 하는 방법에 대해 공유하였는데 이후에 어드민의 일부 작업들을 <code>playwright</code> 를 통해 자동화 하기 시작했다고 말씀해주셨습니다. 자동화 해준 이야기들을 듣다보니 계속해서 아래와 같은 고민이 들기 시작했습니다.</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1771068660722/c94ba813-7bd3-4fb0-ac19-e7d7f56c24a6.png" alt class="image--center mx-auto" /></p>
<blockquote>
<p>우리가 기존에 운영하던 내부 어드민은 정말 HTML 로 계속해서 유지되어야 하나?</p>
</blockquote>
<p>사실 특정 작업들을 AI 도구들이 쉽게 작업하도록 도와주기 위해서는 <strong><em>“기존의 사람 친화적인 interface 보다는 LLM Model 에 친화적인 Interface 가 낫지않을까?”</em></strong> 라는 생각이 들었습니다. 기존 HTML 형식의 GUI 는 사람에게 정보를 정리해서 가독성이 좋게 전달하기 위한 수단인데, 사실 LLM 에게는 그것보단 구조화된 JSON, YAML 등의 형태가 더 이해하기 쉬울수도 있겠다 라는 생각이 들었습니다.</p>
<p>그래서 <strong>AI 가 필요한 정보나 액션만 취할 수 있는 도구를 쥐어준다는 느낌으로</strong> <code>mcp</code> <strong>로 제공해주면 어떨까?</strong> 라는 생각이 들었습니다. 그래서 <code>mcp</code> 로 어드민에서 사람에게 보여주던 정보들을 기존의 API 를 통해 이용할 수 있도록 내부에 배포하여 이용할수 있도록 하였더니 꽤나 이것들을 이용해서 이것저것 많이 시도해보시는 느낌을 받았습니다.</p>
<p>이러한 현상을 보면서 AI native 로 변해간다는건 조직원들이 AI Tool 에 대해 익숙해지고 더 많이 사용하게 되고, 원래 사람위주의 Interface 들이 점차 <strong>AI native 하게 정말 구조적인 정보 또는 액션만 제공하는 구조로 워크플로우 자체가 변해가는 것이구나</strong> 라는 생각이 들었습니다. 위 작업을 하면서 기존에는 크게 잘 사용하지 않던 <code>mcp</code> 에 대한 시각도 많이 바뀌게 된거 같습니다.</p>
<h2 id="heading-66ei7lmy66mw">마치며</h2>
<p>아직은 AI 초기라 잘 모르겠지만 이후에는 모두가 Claude code 와 같은 에이전트 형태의 커스터마이징이 가능한 Tool 을 이용하게 될 것이고 <strong>이제 항상 반복되던 Admin 작업들은 LLM 성능이 좋아지면 좋아질수록, 잘 관리된 도메인 지식인 SKILL 과 내부 데이터 및 액션에 대한 도구를 제공해주는</strong> <code>mcp</code> <strong>로 자동화 하는 방식으로 발전해나갈 것</strong>이라는 생각이 들었습니다.</p>
<p>개인적으로 비개발자 분들이 여러 업무에 자동화를 하는 것을 보면서 제 개인적으로도 인싸이트를 많이 얻었던 것 같습니다. Mixpanel 의 로그를 분석한다거나, 동영상 편집에 이용한다거나 등등. 이 글을 읽으시는 개발자 분들도 일상생활을 자동화 하기위해 클로드 코드를 많이 이용해보시길 바랍니다.</p>
]]></content:encoded></item><item><title><![CDATA[파이썬 톺아보기 2화 - Ast 와 바이트코드]]></title><description><![CDATA[식(Expression) 과 문장(Statement)
프로그래밍을 공부하다보면 위 두 단어를 반드시 마주하게 된다. 가끔 헷갈려하는 경우가 많은데 오늘은 python 에서 기본 모듈인 ast 모듈을 공부하며 이를 알아보도록 하자.
식(Expression)
기본적으로 식(Expression) 이란 평가되면 값이 나오는 코드 조각을 뜻한다. 파이썬에서는 어떠한 부분들이 있을까?




노드 타입설명예시



BinOp이항 연산a + b, x * y...]]></description><link>https://roach-wiki.com/2-ast</link><guid isPermaLink="true">https://roach-wiki.com/2-ast</guid><category><![CDATA[Python]]></category><category><![CDATA[PVM]]></category><dc:creator><![CDATA[roach]]></dc:creator><pubDate>Fri, 06 Feb 2026 14:36:21 GMT</pubDate><content:encoded><![CDATA[<h2 id="heading-expression-statement">식(Expression) 과 문장(Statement)</h2>
<p>프로그래밍을 공부하다보면 위 두 단어를 반드시 마주하게 된다. 가끔 헷갈려하는 경우가 많은데 오늘은 python 에서 기본 모듈인 <code>ast</code> 모듈을 공부하며 이를 알아보도록 하자.</p>
<h2 id="heading-expression">식(Expression)</h2>
<p>기본적으로 <strong>식(Expression)</strong> 이란 평가되면 값이 나오는 코드 조각을 뜻한다. 파이썬에서는 어떠한 부분들이 있을까?</p>
<div class="hn-table">
<table>
<thead>
<tr>
<td>노드 타입</td><td>설명</td><td>예시</td></tr>
</thead>
<tbody>
<tr>
<td><code>BinOp</code></td><td>이항 연산</td><td><code>a + b</code>, <code>x * y</code></td></tr>
<tr>
<td><code>UnaryOp</code></td><td>단항 연산</td><td><code>-x</code>, <code>not flag</code></td></tr>
<tr>
<td><code>BoolOp</code></td><td>논리 연산</td><td><code>a and b</code>, <code>x or y</code></td></tr>
<tr>
<td><code>Compare</code></td><td>비교 연산</td><td><code>x &gt; 0</code>, <code>a == b</code></td></tr>
<tr>
<td><code>Call</code></td><td>함수 호출</td><td><code>print("hi")</code></td></tr>
<tr>
<td><code>Name</code></td><td>변수 이름</td><td><code>x</code>, <code>foo</code></td></tr>
<tr>
<td><code>Constant</code></td><td>상수</td><td><code>42</code>, <code>"hello"</code></td></tr>
<tr>
<td><code>Attribute</code></td><td>속성 접근</td><td><code>obj.method</code></td></tr>
<tr>
<td><code>Subscript</code></td><td>첨자 접근</td><td><code>lst[0]</code>, <code>dict["key"]</code></td></tr>
</tbody>
</table>
</div><p>바로 위와 같은 코드 조각들이 존재한다. 특징 들을 보면 <code>1 + 2</code> 를 실행시키면 바로 <code>3</code> 이라는 값이 나오듯. 코드 조각들이 평가되는 순간에 바로 **값(valude)**이 나오게 된다. 이를 한번 <code>ast</code> 모듈을 통하여 파싱해보자.</p>
<blockquote>
<p>ast 모듈의 parse 함수에는 mode 라는 값이 존재하는데, eval 로 하게 되면 단일 표현식만 파싱이 가능하다</p>
</blockquote>
<pre><code class="lang-python">expressions = {
    <span class="hljs-string">'BinOp'</span>: <span class="hljs-string">'1 + 2'</span>,
    <span class="hljs-string">'UnaryOp'</span>: <span class="hljs-string">'-x'</span>,
    <span class="hljs-string">'BoolOp'</span>: <span class="hljs-string">'a and b'</span>,
    <span class="hljs-string">'Compare'</span>: <span class="hljs-string">'x &gt; 0'</span>,
    <span class="hljs-string">'Call'</span>: <span class="hljs-string">'print("hello")'</span>,
    <span class="hljs-string">'Name'</span>: <span class="hljs-string">'x'</span>,
    <span class="hljs-string">'Constant'</span>: <span class="hljs-string">'42'</span>,
    <span class="hljs-string">'Attribute'</span>: <span class="hljs-string">'obj.method'</span>,
    <span class="hljs-string">'Subscript'</span>: <span class="hljs-string">'lst[0]'</span>,
}

<span class="hljs-keyword">for</span> expr_type, code <span class="hljs-keyword">in</span> expressions.items():
    print(<span class="hljs-string">f"\n<span class="hljs-subst">{<span class="hljs-string">'='</span>*<span class="hljs-number">40</span>}</span>"</span>)
    print(<span class="hljs-string">f"<span class="hljs-subst">{expr_type}</span>: <span class="hljs-subst">{code}</span>"</span>)
    print(<span class="hljs-string">f"<span class="hljs-subst">{<span class="hljs-string">'='</span>*<span class="hljs-number">40</span>}</span>"</span>)
    tree = ast.parse(code, mode=<span class="hljs-string">'eval'</span>)  <span class="hljs-comment"># 표현식 모드로 파싱</span>
    print(ast.dump(tree, indent=<span class="hljs-number">2</span>))
</code></pre>
<p>이를 파싱하면 아래와 같은 출력값이 나온다.</p>
<pre><code class="lang-python">========================================
BinOp: <span class="hljs-number">1</span> + <span class="hljs-number">2</span>
========================================
Expression(
  body=BinOp(
    left=Constant(value=<span class="hljs-number">1</span>),
    op=Add(),
    right=Constant(value=<span class="hljs-number">2</span>)))

========================================
UnaryOp: -x
========================================
Expression(
  body=UnaryOp(
    op=USub(),
    operand=Name(id=<span class="hljs-string">'x'</span>, ctx=Load())))

(생략...)
</code></pre>
<p>보면 전부 <code>Expression</code> 이라는 큰 그룹으로 묶여 있음을 알 수 있다. 즉, AST 가 이 코드 조각들을 식으로 인식하고 있음을 알 수 있다. 이제 대략적으로 식(Expression) 에 대한 감은 왔을 것이다. 그렇다면 문장은 또 어떤 것이 있을까? 한번 알아보도록 하자.</p>
<h2 id="heading-statement">문장(Statement)</h2>
<div class="hn-table">
<table>
<thead>
<tr>
<td>노드 타입</td><td>설명</td><td>예시</td></tr>
</thead>
<tbody>
<tr>
<td><code>FunctionDef</code></td><td>함수 정의</td><td><code>def foo(): ...</code></td></tr>
<tr>
<td><code>ClassDef</code></td><td>클래스 정의</td><td><code>class Foo: ...</code></td></tr>
<tr>
<td><code>If</code></td><td>조건문</td><td><code>if x &gt; 0: ...</code></td></tr>
<tr>
<td><code>For</code></td><td>for 루프</td><td><code>for i in range(10): ...</code></td></tr>
<tr>
<td><code>While</code></td><td>while 루프</td><td><code>while x &lt; 10: ...</code></td></tr>
<tr>
<td><code>Return</code></td><td>반환문</td><td><code>return x + 1</code></td></tr>
<tr>
<td><code>Assign</code></td><td>할당문</td><td><code>x = 1</code></td></tr>
<tr>
<td><code>AugAssign</code></td><td>복합 할당</td><td><code>x += 1</code></td></tr>
<tr>
<td><code>Import</code></td><td>임포트</td><td><code>import os</code></td></tr>
<tr>
<td><code>ImportFrom</code></td><td>from 임포트</td><td><code>from os import path</code></td></tr>
</tbody>
</table>
</div><p><strong>문장(Statement)</strong> 는 위와 같이 “<strong>무언가를 한다/흐름을 만든다</strong>” 에 가까운 하나의 실행 단위이다. 뭐 분기 흐름을 만든다, 클래스를 정의한다 등등과 같은 무언가 특정 행위를 만들거나 정의하는 코드 조각의 모음이다. 이 코드 조각들 또한 <code>ast</code> 를 이용해서 <code>parsing</code> 하는 것이 가능하다.</p>
<pre><code class="lang-python">statements = {
    <span class="hljs-string">'FunctionDef'</span>: <span class="hljs-string">'''
def greet(name):
    return f"Hello, {name}!"
'''</span>,
    <span class="hljs-string">'If'</span>: <span class="hljs-string">'''
if x &gt; 0:
    print("positive")
else:
    print("non-positive")
'''</span>,
    <span class="hljs-string">'For'</span>: <span class="hljs-string">'''
for i in range(5):
    print(i)
'''</span>,
    <span class="hljs-string">'Return'</span>: <span class="hljs-string">'''
return x + y
'''</span>,
}

<span class="hljs-keyword">for</span> stmt_type, code <span class="hljs-keyword">in</span> statements.items():
    print(<span class="hljs-string">f"\n<span class="hljs-subst">{<span class="hljs-string">'='</span>*<span class="hljs-number">50</span>}</span>"</span>)
    print(<span class="hljs-string">f"<span class="hljs-subst">{stmt_type}</span> 예제:"</span>)
    print(<span class="hljs-string">f"<span class="hljs-subst">{<span class="hljs-string">'='</span>*<span class="hljs-number">50</span>}</span>"</span>)
    tree = ast.parse(code)
    <span class="hljs-comment"># 첫 번째 문장의 타입 확인</span>
    first_stmt = tree.body[<span class="hljs-number">0</span>]
    print(<span class="hljs-string">f"첫 번째 문장 타입: <span class="hljs-subst">{type(first_stmt).__name__}</span>"</span>)
    print(<span class="hljs-string">f"\nAST 구조:"</span>)
    print(ast.dump(first_stmt, indent=<span class="hljs-number">2</span>))
</code></pre>
<pre><code class="lang-python">==================================================
FunctionDef 예제:
==================================================
첫 번째 문장 타입: FunctionDef

AST 구조:
FunctionDef(
  name=<span class="hljs-string">'greet'</span>,
  args=arguments(
    args=[
      arg(arg=<span class="hljs-string">'name'</span>)]),
  body=[
    Return(
      value=JoinedStr(
        values=[
          Constant(value=<span class="hljs-string">'Hello, '</span>),
          FormattedValue(
            value=Name(id=<span class="hljs-string">'name'</span>, ctx=Load()),
            conversion=<span class="hljs-number">-1</span>),
          Constant(value=<span class="hljs-string">'!'</span>)]))])

==================================================
If 예제:
==================================================
첫 번째 문장 타입: If

AST 구조:
If(
  test=Compare(
    left=Name(id=<span class="hljs-string">'x'</span>, ctx=Load()),
    ops=[
      Gt()],
    comparators=[
      Constant(value=<span class="hljs-number">0</span>)]),
  body=[
    Expr(
      value=Call(
        func=Name(id=<span class="hljs-string">'print'</span>, ctx=Load()),
        args=[
          Constant(value=<span class="hljs-string">'positive'</span>)]))],
  orelse=[
    Expr(
      value=Call(
        func=Name(id=<span class="hljs-string">'print'</span>, ctx=Load()),
        args=[
          Constant(value=<span class="hljs-string">'non-positive'</span>)]))])

(생략 ...)
</code></pre>
<p>도중에 생략하긴 했는데 위와 같이 나오게 된다. <code>If</code> 와 같은 문장들은 식(Expression) 과 다르게 <code>Statement</code>로 감싸져 있지 않음을 확인할 수 있다. 이는 자리가 중요하기 때문이다. <strong>문장 자리(stmt position)</strong> 에서는 Expression 이 들어갈 수 없기 때문에 ast.Expr 로 감싸게 된다.</p>
<pre><code class="lang-python"><span class="hljs-function"><span class="hljs-keyword">def</span> <span class="hljs-title">f</span>():</span>
    <span class="hljs-keyword">return</span> <span class="hljs-number">1</span> + <span class="hljs-number">2</span>  <span class="hljs-comment"># ← Return(value=BinOp(...)) (BinOp를 Expr로 감싸지 않음)</span>
</code></pre>
<p>하지만, 만약 문장 자리가 아닌 <strong>표현식 자리(expr position)</strong> 이라면 위와 같이 Expr 로 감싼 상태로 나오지 않게 된다.</p>
<h2 id="heading-67cu7j207yq47l2u65oc">바이트코드</h2>
<p>이렇게 AST 로 해석되고 나면 어떻게 될까? 바로 컴파일 되게 된다. 파이썬도 Java 처럼 플랫폼 독립적이기 위해 이를 <strong>파이썬 가상 머신(PVM)</strong> 이 해석할 수 있는 구조인 바이트코드로 해석한다. 이를 코드로 확인해보기 위해서는 <code>dis</code> 모듈을 사용해보면 된다.</p>
<pre><code class="lang-python"><span class="hljs-function"><span class="hljs-keyword">def</span> <span class="hljs-title">add</span>(<span class="hljs-params">a, b</span>):</span>
    <span class="hljs-keyword">return</span> a + b

print(<span class="hljs-string">"=== dis.dis() 출력 ==="</span>)
dis.dis(add)
</code></pre>
<pre><code class="lang-python">=== dis.dis() 출력 ===
  <span class="hljs-number">2</span>           RESUME                   <span class="hljs-number">0</span>

  <span class="hljs-number">3</span>           LOAD_FAST_LOAD_FAST      <span class="hljs-number">1</span> (a, b)
              BINARY_OP                <span class="hljs-number">0</span> (+)
              RETURN_VALUE
</code></pre>
<p>위와 같이 첫번째로 <code>2</code> 와 <code>3</code> 같은 소스코드의 줄 번호가 나오고, <strong>RESUME, LOAD_FAST, BINARY_OP, RETURN_VALUE</strong> 와 같은 <code>opcode(명령어)</code> 그리고 <code>0</code>,<code>1</code>,<code>0</code> 과 같은 피연산자 인덱스가 나오게 된다. 위와 같이 <code>dis</code> 모듈을 통해 코드의 바이트 코드를 출력할 수 있다는 사실을 알 수 있다.</p>
<h2 id="heading-67cu7j207yq4ioy9loutncdsmijsi5w">바이트 코드 예시</h2>
<p>몇가지 바이트 코드를 한번 알아보도록 하자.</p>
<ul>
<li><p><code>LOAD_CONST</code> : 상수를 스택에 푸시</p>
</li>
<li><p><code>BINARY_OP</code> : 이항 연산 수행</p>
</li>
<li><p><code>STORE_FAST</code>: 스택에서 값을 꺼내 지역변수에 저장</p>
</li>
</ul>
<pre><code class="lang-python"><span class="hljs-function"><span class="hljs-keyword">def</span> <span class="hljs-title">simple_math</span>():</span>
    x = <span class="hljs-number">1</span> + <span class="hljs-number">2</span>
    <span class="hljs-keyword">return</span> x

print(<span class="hljs-string">"=== x = 1 + 2 의 바이트코드 ==="</span>)
dis.dis(simple_math)

print(<span class="hljs-string">"\n=== 상수 테이블 ==="</span>)
print(<span class="hljs-string">f"co_consts: <span class="hljs-subst">{simple_math.__code__.co_consts}</span>"</span>)
</code></pre>
<p>이 코드를 실행하면 어떻게 될까? 일단 결과를 보기보다 예측해보자.</p>
<ul>
<li><p><strong>LOAD_CONST</strong> 1 (1) → 스택 = [1]</p>
</li>
<li><p><strong>LOAD_CONST</strong> 2 (2) → 스택 = [1, 2]</p>
</li>
<li><p><strong>BINARY_OP</strong> 0 (+) → 스택 = [3] (1과 2를 팝하고 3을 푸시)</p>
</li>
<li><p><strong>STORE_FAST</strong> 0 (x) → 스택 = [] (3을 팝하여 x에 저장)</p>
</li>
<li><p><strong>LOAD_FAST</strong> 0 (x) → 스택 = [3] (x의 값을 푸시)</p>
</li>
<li><p><strong>RETURN_VALUE</strong> → 스택 = [] (3을 반환)</p>
</li>
</ul>
<p>위와 같이 생각해 볼수 있다. 가장 첫번째로 <code>1</code> 과 <code>2</code> 를 스택에 넣어두고 BINARY_OP 를 통해 Pop 해서 3을 밀어넣고 이 값을 지역변수에 저장하는 것들을 생각해볼 수 있다. 실제로 실행하면 어떨까?</p>
<pre><code class="lang-python">=== x = <span class="hljs-number">1</span> + <span class="hljs-number">2</span> 의 바이트코드 ===
  <span class="hljs-number">2</span>           RESUME                   <span class="hljs-number">0</span>

  <span class="hljs-number">3</span>           LOAD_CONST               <span class="hljs-number">1</span> (<span class="hljs-number">3</span>)
              STORE_FAST               <span class="hljs-number">0</span> (x)

  <span class="hljs-number">4</span>           LOAD_FAST                <span class="hljs-number">0</span> (x)
              RETURN_VALUE

=== 상수 테이블 ===
co_consts: (<span class="hljs-literal">None</span>, <span class="hljs-number">3</span>)
</code></pre>
<p>실제로 실행하게 되면 위와 같은 결과를 얻게 된다. 그 이유는 Cpython 의 상수 폴딩(constant folding) 때문인데 <code>1+2</code> 같이 사실상 컴파일시점에 값을 알 수 있는 <strong>식(Expression)</strong> 들은 <code>3</code> 하나만 상수테이블에 넣고 바이트 코드는 <strong>LOAD_CONST 3</strong> 만 남기게 된다.</p>
<h2 id="heading-bytecode-tracer">Bytecode tracer</h2>
<p>위와 같이 다른 바이트코드들도 많지만 굳이 다뤄야 할 정도로 유익하진 않다고 생각해서 <code>bytecode_tracer</code> 라는 <code>tool</code> 을 소개하고 이글을 마치려고 한다. 만약 스택 상태를 추적하고 싶다거나, 강의 목적으로 스택이 변화하는걸 보여주고 싶다면 아래와 같이 bytecode_tracer 를 이용하면 쉽게 시각화 할 수 있다.</p>
<pre><code class="lang-python"><span class="hljs-keyword">import</span> sys
sys.path.insert(<span class="hljs-number">0</span>, <span class="hljs-string">'/home/roach/python-debug'</span>)

<span class="hljs-keyword">from</span> tools.bytecode_tracer <span class="hljs-keyword">import</span> trace_execution

<span class="hljs-comment"># 간단한 함수 추적</span>
<span class="hljs-function"><span class="hljs-keyword">def</span> <span class="hljs-title">add</span>(<span class="hljs-params">a, b</span>):</span>
    <span class="hljs-keyword">return</span> a + b

print(<span class="hljs-string">"=== 스택 상태 추적: add(1, 2) ==="</span>)
trace_execution(add, (<span class="hljs-number">1</span>, <span class="hljs-number">2</span>))
</code></pre>
<pre><code class="lang-python">┏━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
┃ Offset ┃ Opcode                ┃ Arg            ┃ Stack Before                  ┃ Stack After                   ┃
┡━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┩
│      <span class="hljs-number">0</span> │ RESUME                │                │ []                            │ []                            │
│      <span class="hljs-number">2</span> │ LOAD_FAST_LOAD_FAST   │ a, b           │ []                            │ [<span class="hljs-number">1</span>, <span class="hljs-number">2</span>]                        │
│      <span class="hljs-number">4</span> │ BINARY_OP             │ +              │ [<span class="hljs-number">1</span>, <span class="hljs-number">2</span>]                        │ [<span class="hljs-number">3</span>]                           │
│      <span class="hljs-number">8</span> │ RETURN_VALUE          │                │ [<span class="hljs-number">3</span>]                           │ []                            │
└────────┴───────────────────────┴────────────────┴───────────────────────────────┴───────────────────────────────┘
</code></pre>
<h2 id="heading-cfg">CFG</h2>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1770387961072/653b36c9-a8d0-4e64-87e6-046cc8007993.png" alt class="image--center mx-auto" /></p>
<pre><code class="lang-python"><span class="hljs-keyword">from</span> tools.cfg_visualizer <span class="hljs-keyword">import</span> visualize_cfg

<span class="hljs-function"><span class="hljs-keyword">def</span> <span class="hljs-title">test_loop</span>(<span class="hljs-params">n</span>):</span>
    total = <span class="hljs-number">0</span>
    <span class="hljs-keyword">for</span> i <span class="hljs-keyword">in</span> range(n):
        total += i
    <span class="hljs-keyword">return</span> total

print(<span class="hljs-string">"=== for 루프의 CFG 생성 ==="</span>)
output_path = visualize_cfg(test_loop, <span class="hljs-string">'outputs/cfg/test_loop.png'</span>)
print(<span class="hljs-string">f"CFG 저장됨: <span class="hljs-subst">{output_path}</span>"</span>)
</code></pre>
<p><code>cfg</code> 라는 tool 을 설치하면 위와 같이 바이트 코드의 흐름도 또한 확인해볼 수 있다.</p>
<h2 id="heading-7jew7iq1iousuoygna">연습 문제</h2>
<pre><code class="lang-python"><span class="hljs-function"><span class="hljs-keyword">def</span> <span class="hljs-title">loop_with_range</span>(<span class="hljs-params">n</span>):</span>
    total = <span class="hljs-number">0</span>
    <span class="hljs-keyword">for</span> i <span class="hljs-keyword">in</span> range(n):
        total += i
    <span class="hljs-keyword">return</span> total

<span class="hljs-function"><span class="hljs-keyword">def</span> <span class="hljs-title">loop_with_while</span>(<span class="hljs-params">n</span>):</span>
    total = <span class="hljs-number">0</span>
    i = <span class="hljs-number">0</span>
    <span class="hljs-keyword">while</span> i &lt; n:
        total += i
        i += <span class="hljs-number">1</span>
    <span class="hljs-keyword">return</span> total
</code></pre>
<p>n 회 기준으로 <code>for-loop</code> 와 <code>while</code> 루프가 위 처럼 코드가 존재할때 과연 바이트 코드가 같을까? 아니면 누가 더 빠를까? 한번 바이트 코드를 보면 아래와 같이 컴파일된다 (python 3.13 기준이다)</p>
<pre><code class="lang-python">=== <span class="hljs-keyword">for</span> + range ===
  <span class="hljs-number">2</span>           RESUME                   <span class="hljs-number">0</span>

  <span class="hljs-number">3</span>           LOAD_CONST               <span class="hljs-number">1</span> (<span class="hljs-number">0</span>)
              STORE_FAST               <span class="hljs-number">1</span> (total)

  <span class="hljs-number">4</span>           LOAD_GLOBAL              <span class="hljs-number">1</span> (range + NULL)
              LOAD_FAST                <span class="hljs-number">0</span> (n)
              CALL                     <span class="hljs-number">1</span>
              GET_ITER
      L1:     FOR_ITER                 <span class="hljs-number">7</span> (to L2)
              STORE_FAST               <span class="hljs-number">2</span> (i)

  <span class="hljs-number">5</span>           LOAD_FAST_LOAD_FAST     <span class="hljs-number">18</span> (total, i)
              BINARY_OP               <span class="hljs-number">13</span> (+=)
              STORE_FAST               <span class="hljs-number">1</span> (total)
              JUMP_BACKWARD            <span class="hljs-number">9</span> (to L1)

  <span class="hljs-number">4</span>   L2:     END_FOR
              POP_TOP

  <span class="hljs-number">6</span>           LOAD_FAST                <span class="hljs-number">1</span> (total)
              RETURN_VALUE

=== <span class="hljs-keyword">while</span> ===
  <span class="hljs-number">8</span>           RESUME                   <span class="hljs-number">0</span>

  <span class="hljs-number">9</span>           LOAD_CONST               <span class="hljs-number">1</span> (<span class="hljs-number">0</span>)
              STORE_FAST               <span class="hljs-number">1</span> (total)

 <span class="hljs-number">10</span>           LOAD_CONST               <span class="hljs-number">1</span> (<span class="hljs-number">0</span>)
              STORE_FAST               <span class="hljs-number">2</span> (i)

 <span class="hljs-number">11</span>           LOAD_FAST_LOAD_FAST     <span class="hljs-number">32</span> (i, n)
              COMPARE_OP              <span class="hljs-number">18</span> (bool(&lt;))
              POP_JUMP_IF_FALSE       <span class="hljs-number">16</span> (to L2)

 <span class="hljs-number">12</span>   L1:     LOAD_FAST_LOAD_FAST     <span class="hljs-number">18</span> (total, i)
              BINARY_OP               <span class="hljs-number">13</span> (+=)
              STORE_FAST               <span class="hljs-number">1</span> (total)

 <span class="hljs-number">13</span>           LOAD_FAST                <span class="hljs-number">2</span> (i)
              LOAD_CONST               <span class="hljs-number">2</span> (<span class="hljs-number">1</span>)
              BINARY_OP               <span class="hljs-number">13</span> (+=)
              STORE_FAST               <span class="hljs-number">2</span> (i)

 <span class="hljs-number">11</span>           LOAD_FAST_LOAD_FAST     <span class="hljs-number">32</span> (i, n)
              COMPARE_OP              <span class="hljs-number">18</span> (bool(&lt;))
              POP_JUMP_IF_FALSE        <span class="hljs-number">2</span> (to L2)
              JUMP_BACKWARD           <span class="hljs-number">16</span> (to L1)

 <span class="hljs-number">14</span>   L2:     LOAD_FAST                <span class="hljs-number">1</span> (total)
              RETURN_VALUE
</code></pre>
<p>바이트 코드의 양만 봐도 알 수 있듯이 <code>while</code> 문에 조금 더 많은 바이트 코드가 존재한다. 그 이유는 <strong>아래 연산이 매 반복의 분기마다 이뤄지기 때문</strong>이다.</p>
<ul>
<li><p><strong>비교(COMPARE_OP) + 분기(POP_JUMP_IF_FALSE)</strong></p>
</li>
<li><p><strong>증가를 위한(LOAD_CONST/BINARY_OP/STORE_FAST)</strong></p>
</li>
</ul>
<p>실제 어느정도 크지 않다면 비슷하겠지만 바이트 코드를 보게 된다면 위와 같이 미세한 차이들도 발견해볼 수 있다. 이러한 지식은 언젠가 알아두면 도움이 되니 파이썬을 사용하고 있다면 한번정도는 공부해보면 좋은 것 같다.</p>
]]></content:encoded></item><item><title><![CDATA[Python 톺아보기 1화 - 토큰화(Tokenization)]]></title><description><![CDATA[Python 에서는 코드를 의미 있는 단위로 나누기 위한 토큰화 작업을 거친다. 이 작업을 거치면 코드는 토큰으로 분해된다. 오늘은 tokenize 모듈을 사용해서 이를 한번 눈으로 보고 확인해보도록 하자.
import tokenize
import io

# 토큰 타입 이름 확인
print("주요 토큰 타입:")
print(f"  NAME: {tokenize.NAME} - 변수명, 함수명 등")
print(f"  NUMBER: {tokenize...]]></description><link>https://roach-wiki.com/python-1-tokenization</link><guid isPermaLink="true">https://roach-wiki.com/python-1-tokenization</guid><dc:creator><![CDATA[roach]]></dc:creator><pubDate>Fri, 06 Feb 2026 12:47:39 GMT</pubDate><content:encoded><![CDATA[<p>Python 에서는 코드를 의미 있는 단위로 나누기 위한 <strong>토큰화</strong> 작업을 거친다. 이 작업을 거치면 코드는 토큰으로 분해된다. 오늘은 <code>tokenize</code> 모듈을 사용해서 이를 한번 눈으로 보고 확인해보도록 하자.</p>
<pre><code class="lang-python"><span class="hljs-keyword">import</span> tokenize
<span class="hljs-keyword">import</span> io

<span class="hljs-comment"># 토큰 타입 이름 확인</span>
print(<span class="hljs-string">"주요 토큰 타입:"</span>)
print(<span class="hljs-string">f"  NAME: <span class="hljs-subst">{tokenize.NAME}</span> - 변수명, 함수명 등"</span>)
print(<span class="hljs-string">f"  NUMBER: <span class="hljs-subst">{tokenize.NUMBER}</span> - 숫자 리터럴"</span>)
print(<span class="hljs-string">f"  STRING: <span class="hljs-subst">{tokenize.STRING}</span> - 문자열 리터럴"</span>)
print(<span class="hljs-string">f"  OP: <span class="hljs-subst">{tokenize.OP}</span> - 연산자"</span>)
print(<span class="hljs-string">f"  NEWLINE: <span class="hljs-subst">{tokenize.NEWLINE}</span> - 줄바꿈"</span>)
print(<span class="hljs-string">f"  INDENT: <span class="hljs-subst">{tokenize.INDENT}</span> - 들여쓰기 시작"</span>)
print(<span class="hljs-string">f"  DEDENT: <span class="hljs-subst">{tokenize.DEDENT}</span> - 들여쓰기 종료"</span>)
print(<span class="hljs-string">f"  ENDMARKER: <span class="hljs-subst">{tokenize.ENDMARKER}</span> - 파일 끝"</span>)
</code></pre>
<p>이건 python 에서 주로 쓰이는 <strong>토큰 타입</strong>들이다. 이를 출력해보면 아래와 같이 출력된다.</p>
<pre><code class="lang-python">주요 토큰 타입:
  NAME: <span class="hljs-number">1</span> - 변수명, 함수명 등
  NUMBER: <span class="hljs-number">2</span> - 숫자 리터럴
  STRING: <span class="hljs-number">3</span> - 문자열 리터럴
  OP: <span class="hljs-number">55</span> - 연산자
  NEWLINE: <span class="hljs-number">4</span> - 줄바꿈
  INDENT: <span class="hljs-number">5</span> - 들여쓰기 시작
  DEDENT: <span class="hljs-number">6</span> - 들여쓰기 종료
  ENDMARKER: <span class="hljs-number">0</span> - 파일 끝
</code></pre>
<h3 id="heading-7yag7ygw7zmuio2vqoyima">토큰화 함수</h3>
<p>기본적으로 <code>tokenize.generate_tokens(readline)</code> 함수를 사용합니다.</p>
<ul>
<li><p><strong>readline</strong>: 한 줄씩 읽어오는 함수</p>
</li>
<li><p><strong>반환</strong>: 토큰 네임드튜플 (type, string, start, end, line)</p>
</li>
</ul>
<pre><code class="lang-python"><span class="hljs-comment"># 간단한 코드 토큰화 예시</span>
code = <span class="hljs-string">"x = 1 + 2"</span>
print(<span class="hljs-string">f"코드: <span class="hljs-subst">{code!r}</span>"</span>)
print(<span class="hljs-string">"\n토큰 목록:"</span>)
print(<span class="hljs-string">"-"</span> * <span class="hljs-number">60</span>)

tokens = tokenize.generate_tokens(io.StringIO(code).readline)
<span class="hljs-keyword">for</span> tok <span class="hljs-keyword">in</span> tokens:
    tok_name = tokenize.tok_name[tok.type]
    print(<span class="hljs-string">f"<span class="hljs-subst">{tok.type:<span class="hljs-number">3</span>}</span> <span class="hljs-subst">{tok_name:<span class="hljs-number">12</span>}</span> <span class="hljs-subst">{tok.string!r:<span class="hljs-number">15</span>}</span> 위치: <span class="hljs-subst">{tok.start}</span>-<span class="hljs-subst">{tok.end}</span>"</span>)
</code></pre>
<pre><code class="lang-python">코드: <span class="hljs-string">'x = 1 + 2'</span>

토큰 목록:
------------------------------------------------------------
  <span class="hljs-number">1</span> NAME         <span class="hljs-string">'x'</span>             위치: (<span class="hljs-number">1</span>, <span class="hljs-number">0</span>)-(<span class="hljs-number">1</span>, <span class="hljs-number">1</span>)
 <span class="hljs-number">55</span> OP           <span class="hljs-string">'='</span>             위치: (<span class="hljs-number">1</span>, <span class="hljs-number">2</span>)-(<span class="hljs-number">1</span>, <span class="hljs-number">3</span>)
  <span class="hljs-number">2</span> NUMBER       <span class="hljs-string">'1'</span>             위치: (<span class="hljs-number">1</span>, <span class="hljs-number">4</span>)-(<span class="hljs-number">1</span>, <span class="hljs-number">5</span>)
 <span class="hljs-number">55</span> OP           <span class="hljs-string">'+'</span>             위치: (<span class="hljs-number">1</span>, <span class="hljs-number">6</span>)-(<span class="hljs-number">1</span>, <span class="hljs-number">7</span>)
  <span class="hljs-number">2</span> NUMBER       <span class="hljs-string">'2'</span>             위치: (<span class="hljs-number">1</span>, <span class="hljs-number">8</span>)-(<span class="hljs-number">1</span>, <span class="hljs-number">9</span>)
  <span class="hljs-number">4</span> NEWLINE      <span class="hljs-string">''</span>              위치: (<span class="hljs-number">1</span>, <span class="hljs-number">9</span>)-(<span class="hljs-number">1</span>, <span class="hljs-number">10</span>)
  <span class="hljs-number">0</span> ENDMARKER    <span class="hljs-string">''</span>              위치: (<span class="hljs-number">2</span>, <span class="hljs-number">0</span>)-(<span class="hljs-number">2</span>, <span class="hljs-number">0</span>)
</code></pre>
<p>실제로 실행시켜보면 해당 토큰의 <strong>타입과 이름과 위치정보 등등이 표기</strong> 된다. 이를 통해 토큰이 파일내에서 어떤 위치에 있는지 등등을 판단할 수 있다.</p>
<h3 id="heading-indent-dedent">INDENT, DEDENT</h3>
<pre><code class="lang-python">code2 = <span class="hljs-string">'''def greet(name):
    print(f"Hello, {name}!")
    return True
'''</span>

print(<span class="hljs-string">"=== 실습 2: 함수 정의 토큰화 ==="</span>)
print(<span class="hljs-string">f"\n원본 코드:"</span>)
print(code2)
print(<span class="hljs-string">"="</span> * <span class="hljs-number">70</span>)
print(<span class="hljs-string">f"<span class="hljs-subst">{<span class="hljs-string">'타입'</span>:&lt;<span class="hljs-number">15</span>}</span> <span class="hljs-subst">{<span class="hljs-string">'값'</span>:&lt;<span class="hljs-number">20</span>}</span> <span class="hljs-subst">{<span class="hljs-string">'줄'</span>:&lt;<span class="hljs-number">5</span>}</span> <span class="hljs-subst">{<span class="hljs-string">'열'</span>:&lt;<span class="hljs-number">5</span>}</span>"</span>)
print(<span class="hljs-string">"="</span> * <span class="hljs-number">70</span>)

tokens = list(tokenize.generate_tokens(io.StringIO(code2).readline))
<span class="hljs-keyword">for</span> tok <span class="hljs-keyword">in</span> tokens:
    tok_name = tokenize.tok_name[tok.type]
    line, col = tok.start
    value = tok.string[:<span class="hljs-number">18</span>] + <span class="hljs-string">'...'</span> <span class="hljs-keyword">if</span> len(tok.string) &gt; <span class="hljs-number">20</span> <span class="hljs-keyword">else</span> tok.string
    print(<span class="hljs-string">f"<span class="hljs-subst">{tok_name:&lt;<span class="hljs-number">15</span>}</span> <span class="hljs-subst">{value!r:&lt;<span class="hljs-number">20</span>}</span> <span class="hljs-subst">{line:&lt;<span class="hljs-number">5</span>}</span> <span class="hljs-subst">{col:&lt;<span class="hljs-number">5</span>}</span>"</span>)

print(<span class="hljs-string">"\n주목할 점:"</span>)
print(<span class="hljs-string">"- INDENT: 함수 본문의 들여쓰기가 시작됨을 표시"</span>)
print(<span class="hljs-string">"- DEDENT: 들여쓰기가 종료됨을 표시 (return 문 이후)"</span>)
print(<span class="hljs-string">"- f-string의 경우 FSTRING_START, FSTRING_MIDDLE, FSTRING_END로 분리됨"</span>)
</code></pre>
<pre><code class="lang-python">=== 실습 <span class="hljs-number">2</span>: 함수 정의 토큰화 ===

원본 코드:
<span class="hljs-function"><span class="hljs-keyword">def</span> <span class="hljs-title">greet</span>(<span class="hljs-params">name</span>):</span>
    print(<span class="hljs-string">f"Hello, <span class="hljs-subst">{name}</span>!"</span>)
    <span class="hljs-keyword">return</span> <span class="hljs-literal">True</span>

======================================================================
타입              값                    줄     열    
======================================================================
NAME            <span class="hljs-string">'def'</span>                <span class="hljs-number">1</span>     <span class="hljs-number">0</span>    
NAME            <span class="hljs-string">'greet'</span>              <span class="hljs-number">1</span>     <span class="hljs-number">4</span>    
OP              <span class="hljs-string">'('</span>                  <span class="hljs-number">1</span>     <span class="hljs-number">9</span>    
NAME            <span class="hljs-string">'name'</span>               <span class="hljs-number">1</span>     <span class="hljs-number">10</span>   
OP              <span class="hljs-string">')'</span>                  <span class="hljs-number">1</span>     <span class="hljs-number">14</span>   
OP              <span class="hljs-string">':'</span>                  <span class="hljs-number">1</span>     <span class="hljs-number">15</span>   
NEWLINE         <span class="hljs-string">'\n'</span>                 <span class="hljs-number">1</span>     <span class="hljs-number">16</span>   
INDENT          <span class="hljs-string">'    '</span>               <span class="hljs-number">2</span>     <span class="hljs-number">0</span>    
NAME            <span class="hljs-string">'print'</span>              <span class="hljs-number">2</span>     <span class="hljs-number">4</span>    
OP              <span class="hljs-string">'('</span>                  <span class="hljs-number">2</span>     <span class="hljs-number">9</span>    
FSTRING_START   <span class="hljs-string">'f"'</span>                 <span class="hljs-number">2</span>     <span class="hljs-number">10</span>   
FSTRING_MIDDLE  <span class="hljs-string">'Hello, '</span>            <span class="hljs-number">2</span>     <span class="hljs-number">12</span>   
OP              <span class="hljs-string">'{'</span>                  <span class="hljs-number">2</span>     <span class="hljs-number">19</span>   
NAME            <span class="hljs-string">'name'</span>               <span class="hljs-number">2</span>     <span class="hljs-number">20</span>   
OP              <span class="hljs-string">'}'</span>                  <span class="hljs-number">2</span>     <span class="hljs-number">24</span>   
FSTRING_MIDDLE  <span class="hljs-string">'!'</span>                  <span class="hljs-number">2</span>     <span class="hljs-number">25</span>   
FSTRING_END     <span class="hljs-string">'"'</span>                  <span class="hljs-number">2</span>     <span class="hljs-number">26</span>   
OP              <span class="hljs-string">')'</span>                  <span class="hljs-number">2</span>     <span class="hljs-number">27</span>   
NEWLINE         <span class="hljs-string">'\n'</span>                 <span class="hljs-number">2</span>     <span class="hljs-number">28</span>   
NAME            <span class="hljs-string">'return'</span>             <span class="hljs-number">3</span>     <span class="hljs-number">4</span>    
NAME            <span class="hljs-string">'True'</span>               <span class="hljs-number">3</span>     <span class="hljs-number">11</span>   
NEWLINE         <span class="hljs-string">'\n'</span>                 <span class="hljs-number">3</span>     <span class="hljs-number">15</span>   
DEDENT          <span class="hljs-string">''</span>                   <span class="hljs-number">4</span>     <span class="hljs-number">0</span>    
ENDMARKER       <span class="hljs-string">''</span>                   <span class="hljs-number">4</span>     <span class="hljs-number">0</span>    

주목할 점:
- INDENT: 함수 본문의 들여쓰기가 시작됨을 표시
- DEDENT: 들여쓰기가 종료됨을 표시 (<span class="hljs-keyword">return</span> 문 이후)
- f-string의 경우 FSTRING_START, FSTRING_MIDDLE, FSTRING_END로 분리됨
</code></pre>
<p>실제로 실행시켜보면 <strong>“INDENT”</strong> 와 <strong>“DEDENT”</strong> 등이 표기되는 것을 알 수 있다. <code>FSTRING_START</code> 등 신기한 토큰들도 많이보인다. Python 은 들여쓰기 수준이 증가하거나 감소할때 잘 알고 있듯이 <code>INDENT</code> 와 <code>DEDENT</code> 가 아래 처럼 발생한다.</p>
<pre><code class="lang-python">=== 중첩 함수의 INDENT/DEDENT ===
<span class="hljs-function"><span class="hljs-keyword">def</span> <span class="hljs-title">outer</span>():</span>
    x = <span class="hljs-number">1</span>
    <span class="hljs-function"><span class="hljs-keyword">def</span> <span class="hljs-title">inner</span>():</span>
        y = <span class="hljs-number">2</span>
        <span class="hljs-keyword">return</span> y
    <span class="hljs-keyword">return</span> x

============================================================
NAME: <span class="hljs-string">'def'</span>
NAME: <span class="hljs-string">'outer'</span>
OP: <span class="hljs-string">'('</span>
OP: <span class="hljs-string">')'</span>
OP: <span class="hljs-string">':'</span>
INDENT → 레벨 <span class="hljs-number">1</span>
  NAME: <span class="hljs-string">'x'</span>
  OP: <span class="hljs-string">'='</span>
  NUMBER: <span class="hljs-string">'1'</span>
  NAME: <span class="hljs-string">'def'</span>
  NAME: <span class="hljs-string">'inner'</span>
  OP: <span class="hljs-string">'('</span>
  OP: <span class="hljs-string">')'</span>
  OP: <span class="hljs-string">':'</span>
  INDENT → 레벨 <span class="hljs-number">2</span>
    NAME: <span class="hljs-string">'y'</span>
    OP: <span class="hljs-string">'='</span>
    NUMBER: <span class="hljs-string">'2'</span>
    NAME: <span class="hljs-string">'return'</span>
    NAME: <span class="hljs-string">'y'</span>
  DEDENT ← 레벨 <span class="hljs-number">2</span>
  NAME: <span class="hljs-string">'return'</span>
  NAME: <span class="hljs-string">'x'</span>
DEDENT ← 레벨 <span class="hljs-number">1</span>
</code></pre>
<h3 id="heading-list-comprehension">List comprehension 토큰화</h3>
<pre><code class="lang-python">=== 연습 <span class="hljs-number">2</span>: 리스트 컴프리헨션 토큰화 ===
코드: squares = [x**<span class="hljs-number">2</span> <span class="hljs-keyword">for</span> x <span class="hljs-keyword">in</span> range(<span class="hljs-number">10</span>) <span class="hljs-keyword">if</span> x % <span class="hljs-number">2</span> == <span class="hljs-number">0</span>]

토큰 목록:
--------------------------------------------------
  NAME            <span class="hljs-string">'squares'</span>
  OP              <span class="hljs-string">'='</span>
  OP              <span class="hljs-string">'['</span>
  NAME            <span class="hljs-string">'x'</span>
  OP              <span class="hljs-string">'**'</span>
  NUMBER          <span class="hljs-string">'2'</span>
  NAME            <span class="hljs-string">'for'</span>
  NAME            <span class="hljs-string">'x'</span>
  NAME            <span class="hljs-string">'in'</span>
  NAME            <span class="hljs-string">'range'</span>
  OP              <span class="hljs-string">'('</span>
  NUMBER          <span class="hljs-string">'10'</span>
  OP              <span class="hljs-string">')'</span>
  NAME            <span class="hljs-string">'if'</span>
  NAME            <span class="hljs-string">'x'</span>
  OP              <span class="hljs-string">'%'</span>
  NUMBER          <span class="hljs-string">'2'</span>
  OP              <span class="hljs-string">'=='</span>
  NUMBER          <span class="hljs-string">'0'</span>
  OP              <span class="hljs-string">']'</span>
</code></pre>
<p>모든 코드가 토큰화 된다.</p>
<h2 id="heading-66ei7lmy66mw">마치며</h2>
<p>양질의 글은 아니지만 복리 효과를 믿으며 적어보는 글. 토큰화에 대한 개념을 알고 있으면 나중에 재밌는 것들을 해볼 수 있을 것 같다.</p>
]]></content:encoded></item><item><title><![CDATA[Redis ZSET]]></title><description><![CDATA[ZSET 이란?
ZSET 은 Redis 에서 unique 한 string 들이 score 순서대로(in order) 정렬되어 있는 자료구조이다. 그래서 Leader board 나 Rate limiter 에 쓰일수 있습니다. 기본적으로 자료구조가 Hash Table + Skip List(스킵 리스트) 두가지 자료구조를 합쳐서 사용하기 때문에 접근에는 O(1), 추가에는 O(log N) 이 소요됩니다.
추가(ZADD)
ZADD KEY [NX | X...]]></description><link>https://roach-wiki.com/redis-zset</link><guid isPermaLink="true">https://roach-wiki.com/redis-zset</guid><dc:creator><![CDATA[roach]]></dc:creator><pubDate>Mon, 02 Feb 2026 13:28:12 GMT</pubDate><enclosure url="https://cdn.hashnode.com/res/hashnode/image/upload/v1770037043882/ce506fdc-f3ff-4d89-937e-ae3a4a4be2c3.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<h2 id="heading-zset">ZSET 이란?</h2>
<p>ZSET 은 Redis 에서 unique 한 string 들이 score 순서대로(in order) 정렬되어 있는 자료구조이다. 그래서 <strong>Leader board</strong> 나 <strong>Rate limiter</strong> 에 쓰일수 있습니다. 기본적으로 자료구조가 Hash Table + Skip List(스킵 리스트) 두가지 자료구조를 합쳐서 사용하기 때문에 접근에는 <strong>O(1)</strong>, 추가에는 <strong>O(log N)</strong> 이 소요됩니다.</p>
<h2 id="heading-zadd">추가(ZADD)</h2>
<pre><code class="lang-bash">ZADD KEY [NX | XX] [GT | LT] [CH] [INCR] score member
</code></pre>
<pre><code class="lang-bash">localhost:6379&gt; ZADD roach_set 1 <span class="hljs-string">"roach"</span>
(<span class="hljs-built_in">integer</span>) 1
localhost:6379&gt; ZADD roach_set 2 <span class="hljs-string">"roach2"</span>
(<span class="hljs-built_in">integer</span>) 1
localhost:6379&gt; ZADD roach_set 3 <span class="hljs-string">"roach3"</span>
(<span class="hljs-built_in">integer</span>) 1
localhost:6379&gt; ZADD roach_set 4 <span class="hljs-string">"roach4"</span> 5 <span class="hljs-string">"roach5"</span>
(<span class="hljs-built_in">integer</span>) 2
</code></pre>
<p>위와 같이 명령어를 입력하여 추가할 수 있습니다. <code>[</code> 친 부분은 Optional 하다고 생각해주시면 됩니다. 실제로 ZSET 에 추가해봅시다.</p>
<p>위와 같이 추가가 잘 되었고 제가 몇개를 넣었는지 리턴해주는 것을 확인할 수 있습니다. 삽입간 정렬이 일어나므로 공식문서에 적힌대로 <strong>O(log N)</strong> 시간이 소요되는 것을 알 수 있습니다.</p>
<h2 id="heading-zrange">범위 검색(ZRANGE)</h2>
<pre><code class="lang-bash">ZRANGE key start stop [BYSCORE | BYLEX] [REV] [LIMIT offset count]
</code></pre>
<pre><code class="lang-bash">localhost:6379&gt; ZRANGE roach_set 1 3
1) <span class="hljs-string">"roach2"</span>
2) <span class="hljs-string">"roach3"</span>
3) <span class="hljs-string">"roach4"</span>
localhost:6379&gt; ZRANGE roach_set 1 4
1) <span class="hljs-string">"roach2"</span>
2) <span class="hljs-string">"roach3"</span>
3) <span class="hljs-string">"roach4"</span>
4) <span class="hljs-string">"roach5"</span>
</code></pre>
<p><code>ZRANGE</code> 는 score 가 아닌 인덱스 기반으로 조회가 가능한 메소드 입니다. <code>O(log(N)+M)</code> 의 시간복잡도를 가지고 있으며 N 은 sorted set 안의 멤버들의 개수이고, M 은 리턴되는 멤버들의 개수입니다.</p>
<p>첫번째 질의로는 <code>첫번째 인덱스 ~ 세번째 인덱스</code> 까지를 가져오도록 질의했고, 두번째 인덱스로는 <code>첫번째 인덱스 ~ 네번째 인덱스</code> 를 가져오도록 질의하였습니다. 참고로 마지막 인덱스를 <code>-1</code> 로 하면 lastIndex 와 동일한 의미를 지닙니다. 시간복잡도가 꽤 커질수 있으므로 redis 에서도 주의해서 사용하라는 <code>@slow</code> 어노테이션이 붙어있습니다.</p>
<h2 id="heading-zrangebyscore">스코어 기반 범위 검색(ZRANGEBYSCORE)</h2>
<pre><code class="lang-bash">ZRANGEBYSCORE key min max [WITHSCORES] [LIMIT offset count]
</code></pre>
<pre><code class="lang-bash">localhost:6379&gt; ZRANGEBYSCORE roach_set 1 3
1) <span class="hljs-string">"roach"</span>
2) <span class="hljs-string">"roach2"</span>
3) <span class="hljs-string">"roach3"</span>
localhost:6379&gt; ZRANGEBYSCORE roach_set 1 4
1) <span class="hljs-string">"roach"</span>
2) <span class="hljs-string">"roach2"</span>
3) <span class="hljs-string">"roach3"</span>
4) <span class="hljs-string">"roach4"</span>
</code></pre>
<p><code>ZRANGEBYSCORE</code> 는 점수기반 범위로 검색하고 시간복잡도는 ZRANGE 와 동일하게 <code>O(log(N)+M)</code> 의 복잡도를 지닙니다.</p>
<p>특별하게 설명할 부분은 없고 점수 기반은 점수 사이에 얼마가 있을지 모르므로 꼭 LIMIT 과 OFFSET 을 잘 활용하여 검색해야 한다는 점만 알아두면 좋을거 같습니다.</p>
<h2 id="heading-zrem">삭제(ZREM)</h2>
<pre><code class="lang-bash">ZREM key member [member ...]
</code></pre>
<pre><code class="lang-bash">localhost:6379&gt; ZREM roach_set <span class="hljs-string">"roach"</span>
(<span class="hljs-built_in">integer</span>) 1
localhost:6379&gt; ZRANGEBYSCORE roach_set 1 4
1) <span class="hljs-string">"roach2"</span>
2) <span class="hljs-string">"roach3"</span>
3) <span class="hljs-string">"roach4"</span>
</code></pre>
<p>SET 에서 KEY 와 MEMBER 기반으로 삭제하는 메소드 입니다. 삭제하는 것도 정렬의 오버헤드가 드므로 시간 복잡도는 <code>O(log(N)+M)</code> 이 소요됩니다.</p>
<p>“roach” 라는 key 를 이용해서 해당 Set 에 제거하는 방식입니다.</p>
<h2 id="heading-zrank">랭크(ZRANK)</h2>
<pre><code class="lang-bash">ZRANK key member [WITHSCORE]
</code></pre>
<pre><code class="lang-bash">localhost:6379&gt; ZRANK roach_set <span class="hljs-string">"roach1"</span>
(nil)
localhost:6379&gt; ZRANK roach_set <span class="hljs-string">"roach2"</span>
(<span class="hljs-built_in">integer</span>) 0
localhost:6379&gt; ZRANK roach_set <span class="hljs-string">"roach2"</span> WITHSCORE
1) (<span class="hljs-built_in">integer</span>) 0
2) <span class="hljs-string">"2"</span>
localhost:6379&gt; ZRANK roach_set <span class="hljs-string">"roach4"</span> WITHSCORE
1) (<span class="hljs-built_in">integer</span>) 2
2) <span class="hljs-string">"4"</span>
</code></pre>
<p>ZRANK 는 key 와 member 기반으로 RANK 를 알려주는 메소드입니다. 기본적으로 <strong>zero-based(0 부터 시작)</strong> 이며 <code>WITHSCORE</code> 와 함께 조회할 시에는 스코어까지 함께 리턴받을 수 있습니다. 시간복잡도는 <code>O(log N)</code> 시간복잡도 안에 수행히 가능합니다.</p>
<h2 id="heading-rate-limiter">Rate Limiter 구현</h2>
<p>유저가 <strong>5초 동안 요청할 수 있는 허용된 요청의 수는 5개</strong> 입니다. 요걸 어떻게 ZSET 으로 구현해볼 수 있을까요? 가볍게 생각해보면 1초를(1000ms) 로 잡고 계산하여 이를 score 화 하는 방법이 있습니다. ZSET 자체는 정렬된 자료구조이므로 RANGE 를 이용하여 쉽게 범위 검사가 가능합니다.</p>
<pre><code class="lang-bash">localhost:6379&gt; ZADD user:1 1000 req_1
(<span class="hljs-built_in">integer</span>) 0
localhost:6379&gt; ZADD user:1 1100 req_2
(<span class="hljs-built_in">integer</span>) 1
localhost:6379&gt; ZADD user:1 1300 req_3
(<span class="hljs-built_in">integer</span>) 1
localhost:6379&gt; ZADD user:1 2000 req_4
(<span class="hljs-built_in">integer</span>) 1
localhost:6379&gt; ZADD user:1 4000 req_5
(<span class="hljs-built_in">integer</span>) 1
localhost:6379&gt; ZADD user:1 5000 req_6
</code></pre>
<p>현재 1초에서 5초사이에 <code>user:1</code> 이 총 6건의 요청을 보낸 것을 확인할 수 있습니다. 이를 확인하기 위해서는 <code>ZCARD</code> 메소드를 이용하면 됩니다. ZCARD 는 현재 SET 의 Cardinality 를 리턴해주므로 중복이 아닌 멤버의 갯수를 리턴해주게 됩니다.</p>
<pre><code class="lang-bash">localhost:6379&gt; ZCARD user:1
(<span class="hljs-built_in">integer</span>) 6
</code></pre>
<p>즉 <code>ZCARD user:1</code> 을 하게 되면 user:1 에 얼마나 많은 <code>member</code> 가 있는지 확인할 수 있습니다. 5초 동안 허용된 요청수 5를 넘었으므로 user:1 은 더이상 요청을 보내지 못하게 됩니다. 근데 만약 시간이 더 흘러서 <code>5-10초</code> 구간까지 갔다고 해봅시다.</p>
<pre><code class="lang-bash">localhost:6379&gt; ZADD user:1 1000 req_1
(<span class="hljs-built_in">integer</span>) 1
localhost:6379&gt; ZADD user:1 1100 req_2
(<span class="hljs-built_in">integer</span>) 1
localhost:6379&gt; ZADD user:1 1300 req_3
(<span class="hljs-built_in">integer</span>) 1
localhost:6379&gt; ZADD user:1 2000 req_4
(<span class="hljs-built_in">integer</span>) 1
localhost:6379&gt; ZADD user:1 4000 req_5
(<span class="hljs-built_in">integer</span>) 1
localhost:6379&gt; ZADD user:1 5000 req_6
(<span class="hljs-built_in">integer</span>) 1
localhost:6379&gt; ZADD user:1 6000 req_7
(<span class="hljs-built_in">integer</span>) 1
localhost:6379&gt; ZADD user:1 10000 req_8
(<span class="hljs-built_in">integer</span>) 1
</code></pre>
<p>사실 만약 지금이 10초 부근이라 했을때 5초 이전의 <code>req_1 ~ req_5</code> 요청들은 해당 구간의 sliding window 에 없어야 합니다. 그 경우에는 <code>ZREMRANGEBYSCORE</code> 로 0~5 초 구간의 요청데이터를 지워주면 됩니다.</p>
<pre><code class="lang-bash">localhost:6379&gt; ZREMRANGEBYSCORE user:1 0 5000
(<span class="hljs-built_in">integer</span>) 6
</code></pre>
<p><strong>ZREMRANGEBYSCORE</strong> 는 KEY 기반으로 <strong>점수가 min ~ max 구간에 있는 member 들을 제거</strong>해줍니다. Return 값을 보면 <code>req_1 ~ req_6</code> 까지 총 6개가 잘 지워진것을 확인할 수 있습니다.</p>
<pre><code class="lang-bash">localhost:6379&gt; ZCARD user:1
(<span class="hljs-built_in">integer</span>) 2
</code></pre>
<p>이제 ZCARD 를 해보면 총 2 개로 <code>req_7</code> 과 <code>req_8</code> 만 남은것을 확인할 수 있습니다.</p>
<h2 id="heading-66ei7lmy66mw">마치며</h2>
<p>이 글은 복리 효과를 위해 매일 30분정도를 투자하여 작성되는 글입니다 :). 가볍게 읽어주시고 더 정확한 자료는 공식문서를 참고 바랍니다.</p>
<hr />
<h3 id="heading-references">References</h3>
<ul>
<li><strong>Redis sorted set</strong>: <a target="_blank" href="https://redis.io/docs/latest/develop/data-types/sorted-sets/">https://redis.io/docs/latest/develop/data-types/sorted-sets/</a></li>
</ul>
]]></content:encoded></item><item><title><![CDATA[좀비 프로세스로 인한 트러블 슈팅기]]></title><description><![CDATA[서론
크롤러를 운영하다보면 규모 및 속도에 따라서 크게 두가지 부류로 크롤러를 운영하게 된다.

html 만을 http request 로 가져와서 Parsing 하는 경우

javascript 들이 로딩되고 실행되고 나서 데이터를 가져오기 위해 selenium 같은 헤비한 크롤러를 돌리는 경우


이 외에도 여러 방식이 더 있을 수 있지만 보통은 이 두가지 부류로 크롤링을 하게 된다고 생각한다. 첫번째 http request 의 경우 별도의 프...]]></description><link>https://roach-wiki.com/7kka67meio2uhouhnoyeuoykpouhncdsnbjtlzwg7yq465s67iuioykio2mheq4sa</link><guid isPermaLink="true">https://roach-wiki.com/7kka67meio2uhouhnoyeuoykpouhncdsnbjtlzwg7yq465s67iuioykio2mheq4sa</guid><dc:creator><![CDATA[roach]]></dc:creator><pubDate>Mon, 19 Jan 2026 06:54:57 GMT</pubDate><content:encoded><![CDATA[<h2 id="heading-7isc66gg">서론</h2>
<p>크롤러를 운영하다보면 규모 및 속도에 따라서 크게 두가지 부류로 크롤러를 운영하게 된다.</p>
<ul>
<li><p>html 만을 <strong>http request</strong> 로 가져와서 Parsing 하는 경우</p>
</li>
<li><p>javascript 들이 로딩되고 실행되고 나서 데이터를 가져오기 위해 <strong>selenium 같은 헤비한 크롤러</strong>를 돌리는 경우</p>
</li>
</ul>
<p>이 외에도 여러 방식이 더 있을 수 있지만 보통은 이 두가지 부류로 크롤링을 하게 된다고 생각한다. 첫번째 http request 의 경우 별도의 프로세스를 뛰어도 되지 않기 때문에 헤비하지 않고, 상대적으로 가벼운 http call 로 진행된다. 대부분의 경우 timeout 이 났을때의 재처리 혹은 에러 처리방안 등등만 잘 고려하면 크게 문제가 되지않는다.</p>
<p>두번째 방식이 selenium 과 같이 별도의 브라우저 프로세스를 뛰어야 하는 경우인데, 이 같은 경우는 프로세스를 하나 더 뛰우기 때문에 <strong>프로세스를 처리하는 정책</strong>, 몇 개의 worker 를 뛰우는게 좋은지 등등 리소스 측면에서 여러가지로 대응해야 할점이 많다. 오늘은 두번째 방식인 브라우저 프로세스로 인한 크롤러를 운영하며 겪었던 문제를 적어보려고 한다.</p>
<h2 id="heading-7zie7iob">현상</h2>
<p>크롤러가 초기에는 잘 돌다가 한 3시간 정도의 시간이 지나고나면 갑자기 <code>shutdown signal</code> <strong>를 받고 종료</strong> 되 버렸다. 이는 크롤러의 <code>SIGTERM</code> 에 달려있는 핸들러의 로그로 운영체제가 이를 종료하기를 원했다는 것이다. 그래서 로그창을 확인해보니 꺼지기 5분전 마지막 CDP 에 캡쳐된 request 외에는 별다른 로그가 존재하지 않았다.</p>
<p>그래서 메트릭 창을 확인해보니 CPU 도 정상적이고, 메모리도 정상적이였기 때문에 문제를 찾기 어려웠다. 그러던 중 로그를 유심히 살펴보다가 <code>[Errno 11] Resource temporarily unavailable</code> <strong>라는 로그</strong>를 발견했다. 보통 운영체제 수준에서 소켓 관련이나 <strong>프로세스/스레드의 제한이 Limit</strong> 을 넘게 되면 발생하는 문제로 알고 있다.</p>
<p>예를 들면, <code>fork</code> 를 통해서 새로운 프로세스를 만들때 이때 PID 테이블이 고갈되면 이러한 에러를 리턴하는 것으로 기억하고 있었다. 따라서 무언가 프로세스의 Pool 과 관련있겠구나 싶어서 이 부분과 관련된 코드를 찾아보았다.</p>
<h2 id="heading-7lau7lih">추측</h2>
<p>크로미움을 spawn 할때 현재 PID 에서 자식 프로세스로 spawn 하게 된다. 그때 Chromium 하위에 자식 프로세스들이 spawn 됬는데 이를 정리하지 못하는건가? 라는 생각이 들었다. 운영 환경이 Docker 로 돌아가고 있었기 때문에 Init 프로세스가 내 어플리케이션이라 프로세스를 잘 정리하지 못할 수 있겠다는 생각이 들었다.</p>
<p>이렇게 생각한 이유는 Go 에서 spawn 한 Chromium 은 자식프로세스로 관리하지만 해당 Chromium 이 생성한 자식들은 추적할 방안이 없기 때문이다.</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1768802230631/0d1d1cda-646e-4c85-a41d-e1ffaa76ced6.png" alt class="image--center mx-auto" /></p>
<blockquote>
<p>우리가 실행중인 어플리케이션이 PID 가 1 인 이유는 도커 환경에서 실행되었기 때문이다. 운영또한 도커 환경에서 실행되므로 앞으로 아래 코드들은 모두 도커 환경에서 실행되었다고 생각해주면 된다.</p>
</blockquote>
<p>일단 추측이 맞는지 테스트 해보기 위해서 간단하게 <code>sh</code> 와 <code>sleep</code> 을 이용해서 테스트를 진행하기로 했다.</p>
<pre><code class="lang-python">┌─────────────────────────────────────────────────────────────────┐
│ <span class="hljs-number">1.</span> Go spawns <span class="hljs-string">'sh'</span> process                                       │
│    zombie-check (PID <span class="hljs-number">1</span>)                                         │
│     └─ sh (PID <span class="hljs-number">10</span>)                                              │
│                                                                 │
│ <span class="hljs-number">2.</span> sh launches <span class="hljs-string">'sleep'</span> <span class="hljs-keyword">in</span> background (&amp;) <span class="hljs-keyword">and</span> exits immediately │
│    zombie-check (PID <span class="hljs-number">1</span>)                                         │
│     └─ sh (PID <span class="hljs-number">10</span>)                                              │
│         └─ sleep (PID <span class="hljs-number">20</span>) ← running <span class="hljs-keyword">in</span> background              │
│                                                                 │
│ <span class="hljs-number">3.</span> sh exits, Go reaps it via cmd.Run() ✅                       │
│    zombie-check (PID <span class="hljs-number">1</span>)                                         │
│     └─ sleep (PID <span class="hljs-number">20</span>) ← ORPHAN! Kernel reparents to PID <span class="hljs-number">1</span>      │
│                                                                 │
│ <span class="hljs-number">4.</span> After <span class="hljs-number">100</span>ms, sleep exits                                    │
│    zombie-check (PID <span class="hljs-number">1</span>)                                         │
│     └─ sleep (PID <span class="hljs-number">20</span>) [defunct] ← ZOMBIE! 🧟                   │
│                                                                 │
│ <span class="hljs-number">5.</span> Go doesn<span class="hljs-string">'t track reparented processes                       │
│    → Receives SIGCHLD but ignores it                           │
│    → Zombie remains forever (or until dumb-init reaps it)      │
└─────────────────────────────────────────────────────────────────┘</span>
</code></pre>
<p>테스트 하고자 하는 방법론은 간단하다. Go 의 exec.Command 로 sh 를 spawn 하고, sh 가 sleep 을 spawn 한다. sleep 은 background 에서 진행되므로 sh 는 바로 종료되고, sleep 은 좀비 프로세스로 남게 되는지를 테스트 해보는 것이다.</p>
<pre><code class="lang-go"><span class="hljs-function"><span class="hljs-keyword">func</span> <span class="hljs-title">main</span><span class="hljs-params">()</span></span> {
    <span class="hljs-comment">// Create 10 zombie processes</span>
    <span class="hljs-keyword">for</span> i := <span class="hljs-number">0</span>; i &lt; <span class="hljs-number">10</span>; i++ {
        cmd := exec.Command(<span class="hljs-string">"sh"</span>, <span class="hljs-string">"-c"</span>, <span class="hljs-string">"sleep 0.1 &amp;"</span>)
        cmd.Run()
        time.Sleep(<span class="hljs-number">200</span> * time.Millisecond)

        fmt.Printf(<span class="hljs-string">"Generated zombie %d/10\n"</span>, i+<span class="hljs-number">1</span>)
    }
    <span class="hljs-keyword">for</span> {
        time.Sleep(<span class="hljs-number">2</span> * time.Second)
    }
}
</code></pre>
<p>이를 docker 로 실행시키고 <code>docker exec $ID_FIXED ps -o pid,ppid,stat,comm,args</code> 커맨드를 통해 확인하면 생성된 sh 의 자식 프로세스인 sleep 이 어떻게 처리되는지 알 수 있다.</p>
<pre><code class="lang-bash">Checking process table inside container:
PID   PPID  STAT COMMAND          COMMAND
    1     0 S    zombie-check     ./zombie-check
   12     1 Z    sleep            [sleep]
   14     1 Z    sleep            [sleep]
   16     1 Z    sleep            [sleep]
   18     1 Z    sleep            [sleep]
   20     1 Z    sleep            [sleep]
   22     1 Z    sleep            [sleep]
   24     1 Z    sleep            [sleep]
   26     1 Z    sleep            [sleep]
   28     1 Z    sleep            [sleep]
   30     1 Z    sleep            [sleep]
   31     0 R    ps               ps -o pid,ppid,<span class="hljs-built_in">stat</span>,comm,args
</code></pre>
<p>확인해보면 sleep 상태의 좀비 프로세스들이 무수히 생겨났다는걸 알 수 있습니다. 이제 도커에서 PID 1 인 제 Go application 이 자식프로세스가 죽었을때 adopt 하지 않는것을 알았으니 실제 크롤러를 로컬 환경에서 장시간 돌려 확인해보도록 하겠습니다.</p>
<h2 id="heading-7j6s7zie">재현</h2>
<p>일단 예전 크롤러 파일을 뛰우고 크롤러를 뛰운 다음에 Zombie Process 가 계속해서 증가하는지 모니터링 해보도록 하겠습니다. (해당 스크립트는 Claude code 와 함께 작성하였습니다)</p>
<pre><code class="lang-bash">━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
📊 Statistics
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  🧟 Zombie processes: 45 ⚠️  Warning
  📦 Total processes:  75
  🆔 PID usage:        78 / 99999 (0.1%)
</code></pre>
<p>실제로 확인해보니 계속해서 Zombie Process 가 종료되지 않고 늘지 않는 것을 확인할 수 있습니다.</p>
<pre><code class="lang-bash">━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
🔍 Zombie Processes (showing up to 10)
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  PID   PPID  STAT COMMAND
  ────  ────  ──── ───────
  55     1 Z    chromium
  56     1 Z    chromium
  57     1 Z    chromium
  58     1 Z    chromium
  60     1 Z    chromium
  61     1 Z    chromium
  67     1 Z    chromium
  70     1 Z    chromium
  169     1 Z    chromium
  295     1 Z    chromium
</code></pre>
<p>실제로 확인해보면 크로미움 커맨드로 실행된 Process 들이며 PPID 는 1로 가지고 있습니다. 여기서 어 진짜 자식프로세스인가 의문이 들어 실제로 프로세스를 한번 커맨드로 확인해보았습니다.</p>
<pre><code class="lang-bash">1685  1679 S    /usr/lib/chromium/chromium --<span class="hljs-built_in">type</span>=zygote --no-zygote-sandbox --no-sandbox --headless --headless
 1686  1679 S    /usr/lib/chromium/chromium --<span class="hljs-built_in">type</span>=zygote --no-sandbox --headless --headless
 1701  1679 S    /usr/lib/chromium/chromium --<span class="hljs-built_in">type</span>=utility --utility-sub-type=network.mojom.NetworkService
 1718  1686 S    /usr/lib/chromium/chromium --<span class="hljs-built_in">type</span>=renderer --headless --no-sandbox --disable-dev-shm-usage
 1738  1685 S    /usr/lib/chromium/chromium --<span class="hljs-built_in">type</span>=gpu-process --no-sandbox --disable-dev-shm-usage
</code></pre>
<p>확인해보니 renderer 나 gpu-process 등 chromium 의 하위 프로세스로 생성된 자식들임을 확인 할 수 있습니다. 이러한 프로세스 들이 부모 chromium 이 죽어서 PID 1 로 입양 되었지만, 실제로 Go 에서는 이를 정리하지 않기 때문에 정리가 되고 있지 않던 것이 였습니다.</p>
<h2 id="heading-7iiy7kcv">수정</h2>
<pre><code class="lang-go">  sigChan := <span class="hljs-built_in">make</span>(<span class="hljs-keyword">chan</span> os.Signal, <span class="hljs-number">10</span>)
  signal.Notify(sigChan, syscall.SIGCHLD)

  <span class="hljs-keyword">go</span> <span class="hljs-function"><span class="hljs-keyword">func</span><span class="hljs-params">()</span></span> {
      <span class="hljs-keyword">for</span> <span class="hljs-keyword">range</span> sigChan {
          <span class="hljs-comment">// 모든 종료된 자식 reap</span>
          <span class="hljs-keyword">for</span> {
              <span class="hljs-keyword">var</span> status syscall.WaitStatus
              pid, err := syscall.Wait4(<span class="hljs-number">-1</span>, &amp;status, syscall.WNOHANG, <span class="hljs-literal">nil</span>)
              <span class="hljs-keyword">if</span> pid &lt;= <span class="hljs-number">0</span> || err != <span class="hljs-literal">nil</span> {
                  <span class="hljs-keyword">break</span>
              }
              log.Debug().Int(<span class="hljs-string">"pid"</span>, pid).Msg(<span class="hljs-string">"Reaped zombie"</span>)
          }
      }
  }()
</code></pre>
<p>이러한 에러를 수정하기 위해서는 어떠한 방법이 있을지 고민해보다가 1차원적으로는 위와 같은 Go 코드를 짤 방법을 생각해보았습니다. 하지만, 뭔가 Dockerfile 이 아닐때 실행해도 잘 될까? 무언가 좀 보장하기 어렵게 만드는거 같다는 생각이 들었고, 동시성 이슈는 없을까..? 등등 조금 부족한 OS 지식으로 이러한 코드를 작성하고 안전하다고 하기에는 무리가 있다는 생각이 들었습니다.</p>
<p>그래서 검색을 해보니 이미 유명한 이슈였고, 해결하는 방법으로 <code>dumb-init</code> 이라는 프로세스가 존재했습니다. <a target="_blank" href="https://www.hahwul.com/blog/2022/docker-dumb-init/"><code>dumb-init</code></a> 은 아주 간단하게 커맨드의 앞에 작성해주고 뒤에 실행하고 싶은 executable 한 커맨드를 넘겨주면 됩니다.</p>
<h2 id="heading-7ywm7iqk7yq4">테스트</h2>
<p>이제 <code>dumb-init</code> 으로 실행된 도커파일을 monitoring 툴을 통해서 감지해보자.</p>
<pre><code class="lang-go">📍 Stage <span class="hljs-number">1</span>/<span class="hljs-number">4</span>: Initial crawling (<span class="hljs-number">0</span><span class="hljs-number">-10</span>min)
   Expected: <span class="hljs-number">0</span><span class="hljs-number">-10</span> zombies

💡 Tip: Open another terminal and run:
   docker logs -f crawler-fixed-test

Press Ctrl+C to stop monitoring...
╔════════════════════════════════════════════════════════════════╗
║  🧟 Zombie Process Monitor - Bug Reproduction Test     ║
╚════════════════════════════════════════════════════════════════╝

📅 Time: <span class="hljs-number">2026</span><span class="hljs-number">-01</span><span class="hljs-number">-19</span> <span class="hljs-number">15</span>:<span class="hljs-number">46</span>:<span class="hljs-number">32</span>
⏱️  Elapsed: <span class="hljs-number">0</span>h <span class="hljs-number">0</span>m <span class="hljs-number">10</span>s

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
📊 Statistics
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  🧟 Zombie processes: <span class="hljs-number">0</span> ✅ Normal
  📦 Total processes:  <span class="hljs-number">32</span>
  🆔 PID usage:        <span class="hljs-number">35</span> / <span class="hljs-number">99999</span> (<span class="hljs-number">0.0</span>%)

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
🔍 Zombie Processes (showing up to <span class="hljs-number">10</span>)
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  None yet - system is clean ✨

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
📍 Stage <span class="hljs-number">1</span>/<span class="hljs-number">4</span>: Initial crawling (<span class="hljs-number">0</span><span class="hljs-number">-10</span>min)
   Expected: <span class="hljs-number">0</span><span class="hljs-number">-10</span> zombies

💡 Tip: Open another terminal and run:
   docker logs -f crawler-fixed-test
</code></pre>
<p>오래 켜놓아도 프로세스가 32에서 증가하거나 줄어들지 않는 것을 확인할 수 있다. 이제 함께 실행한 기존에 버그가 있던 코드로 실행된 도커를 확인해보자.</p>
<pre><code class="lang-go">📊 Statistics
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  🧟 Zombie processes: <span class="hljs-number">43</span> ⚠️  Warning
  📦 Total processes:  <span class="hljs-number">73</span>
  🆔 PID usage:        <span class="hljs-number">76</span> / <span class="hljs-number">99999</span> (<span class="hljs-number">0.1</span>%)

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
🔍 Zombie Processes (showing up to <span class="hljs-number">10</span>)
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  PID   PPID  STAT COMMAND
  ────  ────  ──── ───────
  <span class="hljs-number">46</span>     <span class="hljs-number">1</span> Z    chromium
  <span class="hljs-number">47</span>     <span class="hljs-number">1</span> Z    chromium
  <span class="hljs-number">49</span>     <span class="hljs-number">1</span> Z    chromium
  <span class="hljs-number">50</span>     <span class="hljs-number">1</span> Z    chromium
  <span class="hljs-number">60</span>     <span class="hljs-number">1</span> Z    chromium
  <span class="hljs-number">62</span>     <span class="hljs-number">1</span> Z    chromium
  <span class="hljs-number">103</span>     <span class="hljs-number">1</span> Z    chromium
  <span class="hljs-number">106</span>     <span class="hljs-number">1</span> Z    chromium
  <span class="hljs-number">156</span>     <span class="hljs-number">1</span> Z    chromium
  <span class="hljs-number">375</span>     <span class="hljs-number">1</span> Z    chromium
</code></pre>
<p>위와 같이 기존 코드는 좀비 프로세스가 잘 정리되지 않는 것을 확인해볼 수 있다.</p>
<h2 id="heading-66ei7lmy66mw">마치며</h2>
<p>이번 트러블 슈팅은 OS 적 지식이 도움이 좀 많이 되었던거 같다. OS 지식 기반으로 Claude 와 함께 추론하여 버그를 찾았는데 시각화 하는 과정에서 꽤 많은 도움을 많이 받았다. 이제 일단 fix 는 해두었으니 이를 모니터링 수단과 함께 연동할 방법을 찾아봐야겠다.</p>
]]></content:encoded></item><item><title><![CDATA[Postgresql 커버링 인덱스]]></title><description><![CDATA[Index?
Index 란 무엇이고 왜 중요할까? Index 는 데이터베이스에서 File System 에 일어나는 Random I/O 를 줄이기 위해 존재한다. 랜덤 I/O 란, 해당 데이터가 저장된 Array 에 우리가 특정 주소값 i 를 넣어 조회하듯 일어나는 이벤트를 뜻한다.
그럼 느리지 않을거 같은데 왜 랜덤 I/O 를 줄여야 하지? 라는 고민이 충분히 들수도 있다. 이러한 이유는 File System 까지 가는 계층에서 드는 비용과 전...]]></description><link>https://roach-wiki.com/postgresql</link><guid isPermaLink="true">https://roach-wiki.com/postgresql</guid><category><![CDATA[PostgreSQL]]></category><dc:creator><![CDATA[roach]]></dc:creator><pubDate>Sun, 18 Jan 2026 06:03:14 GMT</pubDate><content:encoded><![CDATA[<h2 id="heading-index">Index?</h2>
<p>Index 란 무엇이고 왜 중요할까? Index 는 데이터베이스에서 File System 에 일어나는 <strong>Random I/O</strong> 를 줄이기 위해 존재한다. 랜덤 I/O 란, 해당 데이터가 저장된 Array 에 우리가 특정 주소값 <code>i</code> 를 넣어 조회하듯 일어나는 이벤트를 뜻한다.</p>
<p>그럼 느리지 않을거 같은데 왜 랜덤 I/O 를 줄여야 하지? 라는 고민이 충분히 들수도 있다. 이러한 이유는 File System 까지 가는 계층에서 드는 비용과 전통적인 HDD 의 경우 디스크헤드 비용, Kernel 에서 블락단위로 읽어오는 비용 등등 여러가지 부가적인 요소들이 들어가게 된다. 이는 B-Tree 같은 것들이 왜 <code>leaf node</code>에서 range 로 한번의 I/O 로 많은 것들을 읽을 수 있도록 설계해놨는지를 알 수 있게 해준다.</p>
<h2 id="heading-b-tree">B-Tree</h2>
<pre><code class="lang-bash">
                [Internal Node]
                /      |      \
               /       |       \
    [Internal Node] [Internal] [Internal]
        /    \         |  \        /  \
       /      \        |   \      /    \
   [Leaf]  [Leaf]  [Leaf] [Leaf] [Leaf] [Leaf]
</code></pre>
<p>B-Tree 의 자료구조이다. 정렬된 형태로 존재하며 빠르게 원하는 key 값으로 데이터를 찾을 수 있게 <code>Internal Node</code> 들은 키값만을 저장한다. <code>Leaf Node</code> 는 실제로 찾아야 하는 데이터 혹은 그 데이터를 가르키는 어떠한 값을 가르키도록 보통 설계되어 있다. (MySQL InnoDB 엔진의 경우 이 값이 클러스터링 인덱스또는 실제 값을 가르키게끔 되어 있거나, Postgresql 의 경우 TID 값을 통해 Heap 을 가르키도록 되어 있다)</p>
<pre><code class="lang-sql"><span class="hljs-keyword">CREATE</span> <span class="hljs-keyword">TABLE</span> <span class="hljs-keyword">logs</span> (
    <span class="hljs-keyword">id</span> BIGSERIAL PRIMARY <span class="hljs-keyword">KEY</span>,
    <span class="hljs-built_in">timestamp</span> TIMESTAMPTZ <span class="hljs-keyword">NOT</span> <span class="hljs-literal">NULL</span> <span class="hljs-keyword">DEFAULT</span> <span class="hljs-keyword">NOW</span>(),
    <span class="hljs-keyword">level</span> <span class="hljs-built_in">VARCHAR</span>(<span class="hljs-number">10</span>) <span class="hljs-keyword">NOT</span> <span class="hljs-literal">NULL</span>,
    service <span class="hljs-built_in">VARCHAR</span>(<span class="hljs-number">50</span>) <span class="hljs-keyword">NOT</span> <span class="hljs-literal">NULL</span>,
    message <span class="hljs-built_in">TEXT</span> <span class="hljs-keyword">NOT</span> <span class="hljs-literal">NULL</span>,
    metadata JSONB
); 

<span class="hljs-keyword">CREATE</span> <span class="hljs-keyword">UNIQUE</span> <span class="hljs-keyword">INDEX</span> logs_pkey <span class="hljs-keyword">ON</span> public.logs <span class="hljs-keyword">USING</span> btree (<span class="hljs-keyword">id</span>)
<span class="hljs-keyword">CREATE</span> <span class="hljs-keyword">INDEX</span> idx_test <span class="hljs-keyword">ON</span> public.logs <span class="hljs-keyword">USING</span> btree (<span class="hljs-keyword">level</span>, service)
</code></pre>
<p>만약 위와 같은 테이블이 있다고 해보자. 해당 테이블이 있을때 아래와 같은 쿼리를 날리면 어떻게 될까?</p>
<pre><code class="lang-sql"><span class="hljs-keyword">SELECT</span> <span class="hljs-keyword">level</span>, service, message <span class="hljs-keyword">FROM</span> <span class="hljs-keyword">logs</span> <span class="hljs-keyword">WHERE</span> <span class="hljs-keyword">level</span>=<span class="hljs-string">'ERROR'</span>
</code></pre>
<p>인덱스가 있어 빠를것 같지만 아래와 같은 실행 계획을 가지게 되며 실행시간도 <code>11021.287 ms</code> 로 꽤나 긴것을 확인할 수 있습니다.</p>
<div class="hn-table">
<table>
<thead>
<tr>
<td>QUERY PLAN</td></tr>
</thead>
<tbody>
<tr>
<td>Bitmap Heap Scan on logs (cost=68034.25..822774.99 rows=7832699 width=37) (actual time=430.442..10830.196 rows=7774197 loops=1)</td></tr>
<tr>
<td>Recheck Cond: ((level)::text = 'ERROR'::text)</td></tr>
<tr>
<td>Heap Blocks: exact=656830</td></tr>
<tr>
<td>Buffers: shared hit=6599 read=656800 dirtied=56788 written=18</td></tr>
<tr>
<td>-&gt; Bitmap Index Scan on idx_test (cost=0.00..66076.08 rows=7832699 width=0) (actual time=325.108..325.108 rows=7774197 loops=1)</td></tr>
<tr>
<td>Index Cond: ((level)::text = 'ERROR'::text)</td></tr>
<tr>
<td>Buffers: shared hit=6569</td></tr>
<tr>
<td>Planning Time: 0.054 ms</td></tr>
<tr>
<td>JIT:</td></tr>
<tr>
<td>Functions: 4</td></tr>
<tr>
<td>Options: Inlining true, Optimization true, Expressions true, Deforming true</td></tr>
<tr>
<td>Timing: Generation 0.168 ms, Inlining 3.306 ms, Optimization 5.606 ms, Emission 3.804 ms, Total 12.884 ms</td></tr>
<tr>
<td>Execution Time: 11021.287 ms</td></tr>
</tbody>
</table>
</div><p>이 실행계획을 간단히 설명해보면 첫번째로 <code>Bitmap Index Scan</code> 을 통해 해당 행들이 페이지내의 어느 블록에 있는지를 알아냅니다. 이렇게 하는 이유는 그냥 Index Scan 을 통해서 Random I/O 를 발생시키면 너무 오래걸리기 때문에 Bitmap 에서 정렬시켜 순차적으로 한번에 많이 긁어오기 위함입니다.</p>
<p>그리고 해당 Bitmap 을 이용하여 Heap Scan 을 실시합니다. 이때 정렬된 순서로 긁기 때문에 그냥 Random I/O 보다는 더 효율적으로 데이터를 가져오게 됩니다.</p>
<pre><code class="lang-sql">Buffers: shared hit=6599 read=656800 dirtied=56788 written=18
</code></pre>
<p>그래서 결과를 보면 실제로 Memory 에서 가져온것은 6599 건, 그리고 파일시스템을 통해서 656800 건 만큼 블록단위로 데이터를 읽어 가져오게 됩니다. 즉, 대부분을 파일 시스템을 통해 가져오는 것을 확인할 수 있습니다. 여기서 최적화를 해야 한다면 어떻게 해야할까요?</p>
<h2 id="heading-7luk67ke66ebioyduounseykpa">커버링 인덱스</h2>
<p>여기서 커버링 Index 를 이용해 볼 수 있습니다. 커버링 Index 는 세컨더리 인덱스에도 값을 저장하게끔 하여 실제 Heap page 까지는 도달되는 I/O 를 줄이는 방법입니다. 인덱스에서 대부분의 질의가 해결되기 때문에 커버링 인덱스라고 불립니다.</p>
<p>만드는 가장 간단한 방법은 복합 인덱스를 아래와 같이 만들어보는 것입니다.</p>
<pre><code class="lang-sql"><span class="hljs-keyword">CREATE</span> <span class="hljs-keyword">INDEX</span> idx_composite <span class="hljs-keyword">ON</span> <span class="hljs-keyword">logs</span>(<span class="hljs-keyword">level</span>, service, message);

VACUUM <span class="hljs-keyword">ANALYZE</span> <span class="hljs-keyword">logs</span>;
</code></pre>
<p>위와 같이 인덱스를 생성하고 쿼리를 날리게 되면 아래와 같은 결과를 얻을 수 있습니다.</p>
<div class="hn-table">
<table>
<thead>
<tr>
<td>QUERY PLAN</td></tr>
</thead>
<tbody>
<tr>
<td>Index Only Scan using idx_composite on logs (cost=0.56..143045.73 rows=7738598 width=37) (actual time=0.029..310.204 rows=7774197 loops=1)</td></tr>
<tr>
<td>Index Cond: (level = 'ERROR'::text)</td></tr>
<tr>
<td>Heap Fetches: 0</td></tr>
<tr>
<td>Buffers: shared hit=1008 read=6889 written=12</td></tr>
<tr>
<td>Planning:</td></tr>
<tr>
<td>Buffers: shared hit=35</td></tr>
<tr>
<td>Planning Time: 0.154 ms</td></tr>
<tr>
<td>JIT:</td></tr>
<tr>
<td>Functions: 1</td></tr>
<tr>
<td>Options: Inlining false, Optimization false, Expressions true, Deforming true</td></tr>
<tr>
<td>Timing: Generation 0.086 ms, Inlining 0.000 ms, Optimization 0.000 ms, Emission 0.000 ms, Total 0.086 ms</td></tr>
<tr>
<td>Execution Time: 475.544 ms</td></tr>
</tbody>
</table>
</div><p>일단 실행계획을 분석해보면 <code>Heap Fetches</code> 가 0 인것을 확인할 수 있습니다. <strong>즉, 인덱스에서 모든 조회가 이루어졌음을 확인할 수 있습니다.</strong> read 또한 <code>6889</code> 로 급격히 감소했음을 확인할 수 있습니다. 다만, 이 부분은 실제 데이터가 최신인지 확인할 수 없을때는 증가할 수 있습니다. (실험을 위해 VACUUM ANALYZE 를 미리 실행시킨 이유가 그 이유입니다)</p>
<p>즉, Index Scan 만을 통해 데이터를 모두 가져오고 Heap 을 하나도 Fetch 하지 않았음을 확인할 수 있습니다. 이전에 비해 비약적으로 빨라진 것을 확인할 수 있습니다. 이것이 인덱스에서 데이터를 Fetch 해오는 것이 커버가 되는 커버링 인덱스라고 할 수 있습니다.</p>
<p>다만, 여기서 의문은 방금의 인덱스는 과연 좋은 인덱스 였을까요? 보통 Tree 자료구조에서 key 로 무언갈 선정한다는건 이 데이터를 정렬의 축으로 잡겠다는 의미와 같습니다. 즉, level 과 service 는 ENUM 과 같은 제약된 다양성을 가지는 곳에는 괜찮을 수 있지만, message 와 같이 정렬이 필요없는 부분또한 key 에 속하게 되어 종단 노드의 크기가 커지게 됩니다.</p>
<pre><code class="lang-sql">
      [Internal Node (level, service, message)]
                /      |      \
               /       |       \
    [Internal Node] [Internal] [Internal] =&gt; level, service, message
        /    \         |  \        /  \
       /      \        |   \      /    \
   [Leaf]  [Leaf]  [Leaf] [Leaf] [Leaf] [Leaf]
</code></pre>
<p>사실 message 를 빠르게 가져오기 위해 key 에 넣게 된다면 그 만큼의 INSERT 와 UPDATE 시에 오버헤드가 걸리게 되므로 trade-off 를 계산해야 되는 상황에 빠지게 됩니다. 그렇다면 message 를 탐색 key 로 잡지 않고 종단 노드에만 위치하게 하는 방법은 없을까요? Postgresql 에서는 이를 <code>INCLUDE</code> 라는 키워드를 통해 해결하게 해줍니다.</p>
<h2 id="heading-include">Include</h2>
<pre><code class="lang-sql"><span class="hljs-keyword">CREATE</span> <span class="hljs-keyword">INDEX</span> idx_include <span class="hljs-keyword">ON</span> <span class="hljs-keyword">logs</span>(<span class="hljs-keyword">level</span>, service) <span class="hljs-keyword">INCLUDE</span> (message);
</code></pre>
<p>위와 같이 INCLUDE 를 통해 <code>message</code> 를 추가하게 되면 종단 노드에만 잡혀서 아주 컴팩트한 인덱스가 되고, INSERT 와 UPDATE 시 비용이 덜 들게 된다고 생각할 수 있습니다. 실제 테스트를 위해 한번 추가하고 난 뒤에 Index 사이즈를 보도록 합시다.</p>
<pre><code class="lang-sql"><span class="hljs-keyword">SELECT</span>
    indexrelname,
    pg_size_pretty(pg_relation_size(indexrelid)) <span class="hljs-keyword">AS</span> index_size
<span class="hljs-keyword">FROM</span> pg_stat_user_indexes
<span class="hljs-keyword">WHERE</span> relname = <span class="hljs-string">'logs'</span>;
</code></pre>
<div class="hn-table">
<table>
<thead>
<tr>
<td>indexrelname</td><td>index_size</td></tr>
</thead>
<tbody>
<tr>
<td>logs_pkey</td><td>666 MB</td></tr>
<tr>
<td>idx_test</td><td>207 MB</td></tr>
<tr>
<td>idx_composite</td><td>217 MB</td></tr>
<tr>
<td>idx_include</td><td>1822 MB</td></tr>
</tbody>
</table>
</div><p>실제로 사이즈를 보니 예상 한 것보다 너무 비대한 것을 확인할 수 있습니다 . 이유는 무엇일까요? 이를 알아보기 위해서는 일단 데이터를 확인해보도록 하겠습니다.</p>
<pre><code class="lang-sql"><span class="hljs-keyword">SELECT</span> <span class="hljs-keyword">COUNT</span>(*) / <span class="hljs-keyword">COUNT</span>(<span class="hljs-keyword">DISTINCT</span> (<span class="hljs-keyword">level</span>, service, message)) <span class="hljs-keyword">as</span> duplication_ratio
  <span class="hljs-keyword">FROM</span> <span class="hljs-keyword">logs</span>;
</code></pre>
<p>로그 테이블에서 (level, service, message) 가 유니크한 수를 전체 개수로 나누어 얼마나 유니크한지를 보도록 하겠습니다. 1.0 에 근사한 수치가 나올수록 카디널리티가 높아 인덱싱에 유리한 구조임을 알수 있겠죠.</p>
<div class="hn-table">
<table>
<thead>
<tr>
<td>duplication_ratio</td></tr>
</thead>
<tbody>
<tr>
<td>3048</td></tr>
</tbody>
</table>
</div><p>실제로 해보면 약 3048 이 나오게 됩니다. 즉, 동일한 조합이 평균적으로 약 3048 개가 중복되어 있음을 알 수 있습니다. 그런데 왜 <code>inx_composite</code> 은 217MB 뿐인데, <code>idx_include</code> 는 1822MB 나 될까요? 이는 Postgresql 이 인덱스를 생성하는 방식이 대략적으로 아래와 같기 때문입니다. (<strong>이는 설명하기 위한 수도코드로 살짝 동작이 다릅니다!!</strong>)</p>
<pre><code class="lang-c">btree_insert(index, key=(level, service, message), tid) {
    existing_entry = search_btree(key);

    <span class="hljs-keyword">if</span> (existing_entry != <span class="hljs-literal">NULL</span>) {
        add_tid_to_posting_list(existing_entry, tid);
    } <span class="hljs-keyword">else</span> {
        create_new_entry_with_posting_list(key, tid);
    }
}

btree_insert_with_include(index, key=(level, service), tid, include=message) {
    create_new_index_tuple(key=(level, service), tid, include_data=message);
}
</code></pre>
<p>의사 코드를 보면 복합 인덱스는 중복 제거를 하지만, <code>include</code> 의 경우 중복제거를 하지 않습니다. 즉, 복합 인덱스에서는 (level, service, message) 가 합쳐저 하나의 posting_list 로 관리되지만, include 에서는 (level, service) 가 같더라도 message 가 다르면 별도의 엔트리가 생성되게 됩니다. (저도 궁금해서 공식 문서를 읽어봤는데 <a target="_blank" href="https://www.postgresql.org/docs/current/btree.html">“<code>INCLUDE</code> indexes can never use deduplication“</a> 라고 적혀있더군요)</p>
<div class="hn-table">
<table>
<thead>
<tr>
<td>indexrelname</td><td>size</td><td>total_pages</td><td>estimated_tuples</td><td>tuples_per_page</td></tr>
</thead>
<tbody>
<tr>
<td>idx_composite</td><td>217 MB</td><td>27817</td><td>31075028</td><td>1117.1236294352375</td></tr>
<tr>
<td>idx_include</td><td>1822 MB</td><td>233251</td><td>31075028</td><td>133.2257010688057</td></tr>
</tbody>
</table>
</div><p>실제로 페이지에 저장된 튜플의 밀도도 훨씬 복합인덱스가 높은 것을 확인할 수 있습니다.</p>
<h2 id="heading-66ei7lmy66mw">마치며</h2>
<p><code>INCLUDE</code> 관련 부하 테스트를 하게 되다가 알게 된 사실이라 적어봅니다. 언제 사용해야 할지 지금 까지 감은 카디널리티가 엄청나게 높고, key 로 잡아야 하는 컬럼의 데이터가 크다면 써볼 수 있을것 같습니다. 다만 이렇게 까다로운 경우 대부분 성능향상을 하기 위해서는 많은 테스팅이 필요하므로 테스트를 많이 해보고 도입해볼 것 같습니다.</p>
]]></content:encoded></item><item><title><![CDATA[Postgresql MVCC]]></title><description><![CDATA[postgresql 의 MVCC 를 살펴보면 아주 재미있다. MVCC(Multi-Version Concurrency Control) 은 동시성을 처리하는 핵심아이디어로 기본적인 전제로 “읽기는 쓰기를 블록해선 안되고, 쓰기도 읽기를 블록하지 않는다” 라는 아이디어 에서 시작한다.
이 개념을 적용하기 위해서는 Postgresql 에서는 xmin 과 xmax 를 활용한다. Postgresql 에서 테이블안에 있는 데이터는 튜플(Tuple) 형태로 ...]]></description><link>https://roach-wiki.com/postgresql-mvcc</link><guid isPermaLink="true">https://roach-wiki.com/postgresql-mvcc</guid><category><![CDATA[PostgreSQL]]></category><dc:creator><![CDATA[roach]]></dc:creator><pubDate>Wed, 14 Jan 2026 06:35:45 GMT</pubDate><content:encoded><![CDATA[<p>postgresql 의 MVCC 를 살펴보면 아주 재미있다. <strong>MVCC(Multi-Version Concurrency Control)</strong> 은 동시성을 처리하는 핵심아이디어로 기본적인 전제로 <strong>“읽기는 쓰기를 블록해선 안되고, 쓰기도 읽기를 블록하지 않는다”</strong> 라는 아이디어 에서 시작한다.</p>
<p>이 개념을 적용하기 위해서는 Postgresql 에서는 <code>xmin</code> 과 <code>xmax</code> 를 활용한다. Postgresql 에서 테이블안에 있는 데이터는 <strong>튜플(Tuple)</strong> 형태로 저장된다. 예를 들면, 아래와 같이 테이블이 있다고 해보자.</p>
<h2 id="heading-7ywm7j2067iuioq1royhsa">테이블 구조</h2>
<div class="hn-table">
<table>
<thead>
<tr>
<td>id</td><td>name</td><td>balance</td><td>created_at</td></tr>
</thead>
<tbody>
<tr>
<td>1</td><td>Alice</td><td>1000</td><td>2026-01-14 01:14:08.515896</td></tr>
<tr>
<td>2</td><td>Bob</td><td>2000</td><td>2026-01-14 01:14:08.515896</td></tr>
<tr>
<td>3</td><td>Charlie</td><td>3000</td><td>2026-01-14 01:14:08.515896</td></tr>
</tbody>
</table>
</div><p>위 테이블에는 <code>id</code>,<code>name</code>,<code>balance</code>,<code>created_at</code> 등의 column 이 존재한다. 여기서 튜플은 <code>(1,Alice,1000,2026-01-14)</code>를 의미한다. 즉, 하나의 레코드가 튜플로 저장된다.</p>
<h2 id="heading-7yqc7zsm">튜플</h2>
<p>튜플은 아래와 같은 정보를 가지고 있다.</p>
<ul>
<li><p><strong>ctid</strong>: 페이지에서 저장된 위치를 나타내는 값</p>
</li>
<li><p><strong>xmin</strong>: 이 튜플을 INSERT 한 트랜잭션의 ID</p>
</li>
<li><p><strong>xmax</strong>: 이 튜플을 DELETE 한 트랜잭션의 ID</p>
</li>
</ul>
<p>각 처리된 트랜잭션의 ID 정보를 가지고 있는 이유는 해당 Tuple 의 <strong>가시성(Visibility)</strong> 를 계산하기 위함이다. 쿼리를 날려보고 결과를 확인해보며 이해해 보자.</p>
<pre><code class="lang-pgsql"><span class="hljs-comment">--- 세션 A 시작</span>
<span class="hljs-keyword">BEGIN</span>;

<span class="hljs-keyword">SELECT</span> txid_current(); <span class="hljs-comment">-- 810</span>

<span class="hljs-keyword">SELECT</span> xmin, xmax, ctid, id, <span class="hljs-type">name</span>, balance
<span class="hljs-keyword">FROM</span> accounts
<span class="hljs-keyword">ORDER</span> <span class="hljs-keyword">BY</span> id;
</code></pre>
<p>세션 A 는 <code>ximn</code> <strong>txid(트랜잭션 ID)</strong> 를 810 로 가지고 있고, <code>xmax</code> 는 0 으로 가지고 있다. <code>xmax</code> 는 삭제될때만 txid 를 남기므로 0 이라는 것은 아직 지워지지 않았음을 의미한다.</p>
<div class="hn-table">
<table>
<thead>
<tr>
<td>xmin</td><td>xmax</td><td>ctid</td><td>id</td><td>name</td><td>balance</td></tr>
</thead>
<tbody>
<tr>
<td>734</td><td>0</td><td>(0,1)</td><td>1</td><td>Alice</td><td>1000</td></tr>
<tr>
<td>734</td><td>0</td><td>(0,2)</td><td>2</td><td>Bob</td><td>2000</td></tr>
<tr>
<td>734</td><td>0</td><td>(0,3)</td><td>3</td><td>Charlie</td><td>3000</td></tr>
</tbody>
</table>
</div><p>즉, 이 세개의 데이터는 현재 <strong>Transaction</strong> 전에 생긴 데이터임을 알 수 있다. 이 트랜잭션을 닫지 않고, 다른 <strong>트랜잭션(세션 B)</strong> 를 열어보자.</p>
<pre><code class="lang-pgsql"><span class="hljs-keyword">BEGIN</span>;

<span class="hljs-keyword">SELECT</span> pg_current_snapshot();
<span class="hljs-keyword">SELECT</span> txid_current(); <span class="hljs-comment">-- 811</span>

<span class="hljs-keyword">INSERT</span> <span class="hljs-keyword">INTO</span> accounts (<span class="hljs-type">name</span>, balance)
<span class="hljs-keyword">VALUES</span> (<span class="hljs-string">'New User (uncommitted)'</span>, <span class="hljs-number">9999</span>)
<span class="hljs-keyword">RETURNING</span> xmin, xmax, ctid, id, <span class="hljs-type">name</span>, balance;
</code></pre>
<p>여기서는 <code>811</code> 로 나온다. 여기서 INSERT 한게 세션 A 에 보일까? xmin 의 문제는 아니지만 세션 A 에는 보이지 않는다. 그 이유는 기본 격리수준인 <code>READ COMMITED</code> 를 이용하기 때문이다. B 세션을 커밋해보자. 그리고 A 세션은 아직 커밋하지 않았지만 다시 SELECT 를 해보자.</p>
<div class="hn-table">
<table>
<thead>
<tr>
<td>xmin</td><td>xmax</td><td>ctid</td><td>id</td><td>name</td><td>balance</td></tr>
</thead>
<tbody>
<tr>
<td>734</td><td>0</td><td>(0,1)</td><td>1</td><td>Alice</td><td>1000</td></tr>
<tr>
<td>734</td><td>0</td><td>(0,2)</td><td>2</td><td>Bob</td><td>2000</td></tr>
<tr>
<td>734</td><td>0</td><td>(0,3)</td><td>3</td><td>Charlie</td><td>3000</td></tr>
<tr>
<td>811</td><td>0</td><td>(0,7)</td><td>11</td><td>New User (uncommitted)</td><td>9999</td></tr>
</tbody>
</table>
</div><p>세션 B 에서 저장된 값의 <code>xmin</code> 을 보니 세션 B 의 트랜잭션 ID 가 남아있는 것을 확인할 수 있다. 이제 세션 C 를 열고 삭제해보자.</p>
<pre><code class="lang-pgsql"><span class="hljs-keyword">BEGIN</span>;

<span class="hljs-keyword">SELECT</span> pg_current_snapshot();
<span class="hljs-keyword">SELECT</span> txid_current(); <span class="hljs-comment">-- 813</span>

<span class="hljs-keyword">DELETE</span> <span class="hljs-keyword">FROM</span> accounts <span class="hljs-keyword">where</span> <span class="hljs-type">name</span> = <span class="hljs-string">'New User (uncommitted)'</span>;
</code></pre>
<p><strong>세션 C(813)</strong> 에서 해당 튜플을 삭제했다. 이 튜플은 xmax 값이 아마 813 으로 남아있을 것이다. Postgresql 은 이처럼 트랜잭션이 열린동안에도 읽기와 쓰기에 대한 Lock 을 하지 최소화 하거나 하지 않기 위해 Tuple 을 계속해서 생성해내고, xmin, xmax 값을 바꾼다. 이제 후에 Vacuum 으로 정리될 죽은 튜플에서 xmax 값을 확인해보자.</p>
<div class="hn-table">
<table>
<thead>
<tr>
<td>item</td><td>xmin</td><td>xmax</td><td>ctid</td><td>status</td><td>xmin_committed</td></tr>
</thead>
<tbody>
<tr>
<td>1</td><td>734</td><td>0</td><td>(0,1)</td><td>🟢 LIVE</td><td>COMMITTED</td></tr>
<tr>
<td>2</td><td>734</td><td>0</td><td>(0,2)</td><td>🟢 LIVE</td><td>COMMITTED</td></tr>
<tr>
<td>3</td><td>734</td><td>0</td><td>(0,3)</td><td>🟢 LIVE</td><td>COMMITTED</td></tr>
<tr>
<td>7</td><td>812</td><td>813</td><td>(0,7)</td><td>💀 DEAD (or being deleted)</td><td>COMMITTED</td></tr>
</tbody>
</table>
</div><p>상태가 죽음(DEAD) 로 표시한걸 확인할 수 있다. 이는 다음에 <strong>AUTO VACUUM</strong> 이 돌때 제거된다. 수동으로 호출도 가능하다.</p>
<pre><code class="lang-pgsql"><span class="hljs-keyword">VACUUM</span> accounts;
</code></pre>
<p>이제 대략적으로 <strong>쓰기/삭제(업데이트는 삭제와 쓰기가 일어남) 일어날 때 마다 튜플이 생성</strong>되는 걸 확인할 수 있었다. 그리고 이를 후에 주기적으로 정리하는 VACUUM 이라는 것도 있다는 것을 알게 되었다. 이제 스냅샷에 대해 알아보자. 스냅샷은 직관적으로 내 트랜잭션에 무엇이 보여야 하는지를 관리해준다.</p>
<h2 id="heading-7iqk64of7io3">스냅샷</h2>
<p>스냅샷이 중요한 개념인데 트랜잭션 격리 레벨에 따라 다르다. 기본 격리 수준인 <code>READ COMMITED</code> 에서는 각 쿼리가 실행될때마다 스냅샷이 기록됩니다. 예시와 함께 보시죠</p>
<pre><code class="lang-pgsql"><span class="hljs-keyword">BEGIN</span>;

<span class="hljs-keyword">SELECT</span> pg_current_snapshot(); <span class="hljs-comment">--- 815:815</span>

<span class="hljs-keyword">SELECT</span> xmin, xmax, ctid, id, <span class="hljs-type">name</span>, balance
<span class="hljs-keyword">FROM</span> accounts
<span class="hljs-keyword">ORDER</span> <span class="hljs-keyword">BY</span> id;

<span class="hljs-keyword">SELECT</span> pg_current_snapshot(); <span class="hljs-comment">--- ???:???</span>
</code></pre>
<p>위와 같은 쿼리가 있을때 두번째로 <code>snapshot()</code> 을 찍으면 어떻게 될까요? 만약 아무런 변경이 없어 <code>xmin</code> 값이 올라가지 않았다면 동일하게 <code>815:815</code> 가 나왔을 것입니다. 하지만 만약, 다른 세션에서 새로운 컬럼을 아래와 같이 추가한다면 어떻게될까요?</p>
<pre><code class="lang-pgsql"><span class="hljs-keyword">BEGIN</span>;

<span class="hljs-keyword">SELECT</span> pg_current_snapshot();
<span class="hljs-keyword">SELECT</span> txid_current(); <span class="hljs-comment">--- 816</span>

<span class="hljs-keyword">INSERT</span> <span class="hljs-keyword">INTO</span> accounts (<span class="hljs-type">name</span>, balance)
<span class="hljs-keyword">VALUES</span> (<span class="hljs-string">'New User (uncommitted)'</span>, <span class="hljs-number">9999</span>)
<span class="hljs-keyword">RETURNING</span> xmin, xmax, ctid, id, <span class="hljs-type">name</span>, balance;

<span class="hljs-keyword">COMMIT</span>;
</code></pre>
<p>위와 같이 추가하게 되면 이제 <code>xmin</code> 의 경계가 816까지 올라가게 됩니다. 이 상태에서 닫지않은 815 세션에서 동일하게 쿼리를 수행하면 어떻게 될까요?</p>
<pre><code class="lang-pgsql"><span class="hljs-keyword">BEGIN</span>;

<span class="hljs-keyword">SELECT</span> pg_current_snapshot(); <span class="hljs-comment">--- 815:815</span>

<span class="hljs-keyword">SELECT</span> xmin, xmax, ctid, id, <span class="hljs-type">name</span>, balance
<span class="hljs-keyword">FROM</span> accounts
<span class="hljs-keyword">ORDER</span> <span class="hljs-keyword">BY</span> id;

<span class="hljs-keyword">SELECT</span> pg_current_snapshot(); <span class="hljs-comment">--- 816:816</span>
</code></pre>
<p><code>816</code>으로 나오게 됩니다. 그리고 저 SELECT 에서는 새롭게 추가한 <code>New User (uncommitted)</code> 가 보이게 됩니다. 당연한 <strong>READ COMMITED</strong> 의 동작이지만 여기에는 스냅샷 기반으로 가시성을 통제하는 뒷단의 마법같은 로직이 숨겨져있습니다.</p>
<h2 id="heading-7iqk64of7io3-1">스냅샷</h2>
<p>snapshot 은 기본적으로 <code>xmin:xmax:[진행중인 txid]</code> 로 구성됩니다. 각 값은 아래와 같은 정의를 가집니다.</p>
<ul>
<li><p><strong>Snapshot xmin:</strong> 아직 완료되지 않은(Active) 트랜잭션 중 가장 낮은 ID. (이보다 작은 ID는 모두 커밋됨이 보장됨)</p>
</li>
<li><p><strong>Snapshot xmax:</strong> 현재까지 할당된 TXID 중 가장 큰 값 + 1. (이보다 크거나 같은 ID는 스냅샷 생성 시점에 아직 시작도 안 한 "미래" 트랜잭션)</p>
</li>
<li><p><strong>xip_list (진행 중인 txid):</strong> <code>xmin</code>과 <code>xmax</code> 사이에서 아직 진행 중인 트랜잭션들의 목록</p>
</li>
</ul>
<p>그래서 특정한 <code>xmin</code> 을 높이는 작업이나 <code>xmax</code> 를 높이는 작업이 끝나면, 각 <code>xmin</code>과 <code>xmax</code> 의 값이 올라갔던 것입니다. 위에서는 아직 진행중인 transaction 을 제가 로깅하진 않았지만 아마 816 에서 세션을 열고, 815 에서 한번더 확인했다면 815 에서 816이 진행중인 세션으로 보였을 것입니다. 이 스냅샷의 범위와 튜플을 이용해 가시성을 통제합니다.</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1768371762951/8f7168f1-6139-4b58-87fe-99f3d851b29e.png" alt class="image--center mx-auto" /></p>
<p>이해를 하기 위해 그림과 함께 보면 현재 snapshot 이 <strong>807:814:[807]</strong> 이라고 가정해봅시다. 각 튜플마다 지금 보여야 하는지 안보여야 하는지를 한번 설명해보도록 하겠습니다.</p>
<ul>
<li><p>A 튜플은 이미 734 에서 처리되었으므로 우리 세션에 보여야 합니다.</p>
</li>
<li><p>B 튜플은 806 에서 커밋되었으나 xmax 가 커밋된 결과에 있으므로 삭제되었으므로 보이지 않습니다.</p>
</li>
<li><p>C 튜플은 현재 트랜잭션이 커밋되지 않은 상태로 진행중이므로 현재에서 보이지 않습니다.</p>
</li>
<li><p>D 튜플은 810 에서 커밋되었으니 <strong>snapshot_xmax(814) 보다 tuple_xmin(810) 이 작으므로 현재 트랜잭션에 보입니다</strong>.</p>
</li>
<li><p>E 튜플 또한 삭제 되었으니 보이지 않습니다.</p>
</li>
<li><p>F 튜플은 커밋되었으나 <strong>snapshot_xmax(814) 보다 tuple_xmin(820) 이 더 크므로 미래에 일어난 일이므로 보이지 않습니다</strong>.</p>
</li>
</ul>
<p>즉 이런식으로 현재 snapshot 이 가지고 있는 범위에 따라 보이는 튜플들을 통제합니다. 만약 기본 격리수준인 <code>READ COMMITED</code> 의경우 쿼리를 실행한번 더 하게되면 xmax 가 820 까지 늘어나게 되면서 F 튜플이 보였을 수 있습니다.</p>
<h3 id="heading-repeatable-read">REPEATABLE READ 는?</h3>
<p>그렇다면 REPEATBALE READ 는 어떨까요? 본질적으로 REPEATABLE READ 는 PHANTOM READ 를 방지하므로 820 은 제 트랜잭션에서 노출되서는 안됩니다. Postgresql 은 여기서 쉽게 REPETABLE READ 는 트랜잭션이 시작되고 첫번째 쿼리가 만든 스냅샷만 해당 트랜잭션에서 이용하게 합니다.</p>
<pre><code class="lang-python">BEGIN;

SELECT * FROM TABLE; --- <span class="hljs-number">815</span>:<span class="hljs-number">819</span>

SELECT * FROM TABLE; --- <span class="hljs-number">815</span>:<span class="hljs-number">819</span>

COMMIT;
</code></pre>
<p>즉, 같은 트랜잭션에서 스냅샷이 바뀌지 않으므로 여러번 SELECT 해도 결과가 바뀌지 않습니다. 다만, MySQL 과 다르게 동시에 해당 튜플에 값을 쓸때 처리하는 방식이 다르므로 Postgresql 에서는 <strong>REPEATABLE READ</strong> 를 쓸때 조심하셔야 됩니다.</p>
<h2 id="heading-66ei7lmy66mw">마치며</h2>
<p>Postgresql 에 Tuple 과 스냅샷에 대해 알아보았는데요. 다음시간에는 격리수준에 대한 조금 더 자세한 내용을 알아보려고 합니다</p>
]]></content:encoded></item><item><title><![CDATA[[짧은 글] 1024, 2048, 4096 크기를 지키는게 왜 중요할까?]]></title><description><![CDATA[개요
프로그래밍을 하다보면 무언가 읽기 위해 buffer 를 설정할때 꼭 버퍼의 사이즈가 512, 1024, 2048, 4096, … 등으로 올라가는 것을 확인할 수 있다. 왜 이런것일까?
운영체제
하드웨어마다 다르겠지만 보통 하드웨어에서는 섹터(Sector) 단위로 데이터를 읽어온다. 예전 하드웨어에서는 512KB 를 읽어서 로드해주고, 최신 하드웨어에서는 4096KB 크기로 읽어서 올려준다. 즉, 내가 1KB 를 읽던, 2KB 를 읽던 하드...]]></description><link>https://roach-wiki.com/1024-2048-4096</link><guid isPermaLink="true">https://roach-wiki.com/1024-2048-4096</guid><category><![CDATA[blocksize]]></category><category><![CDATA[os]]></category><dc:creator><![CDATA[roach]]></dc:creator><pubDate>Sun, 28 Dec 2025 06:46:21 GMT</pubDate><enclosure url="https://cdn.hashnode.com/res/hashnode/image/upload/v1766904351847/12a92d1b-f332-465d-a580-192582808007.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<h2 id="heading-6rcc7jqu">개요</h2>
<p>프로그래밍을 하다보면 무언가 읽기 위해 <code>buffer</code> 를 설정할때 꼭 버퍼의 사이즈가 <strong>512, 1024, 2048, 4096, …</strong> 등으로 올라가는 것을 확인할 수 있다. 왜 이런것일까?</p>
<h2 id="heading-7jq07jib7lk07kcc">운영체제</h2>
<p>하드웨어마다 다르겠지만 보통 하드웨어에서는 <strong>섹터(Sector)</strong> 단위로 데이터를 읽어온다. 예전 하드웨어에서는 512KB 를 읽어서 로드해주고, 최신 하드웨어에서는 <strong>4096KB</strong> 크기로 읽어서 올려준다. 즉, 내가 1KB 를 읽던, 2KB 를 읽던 하드웨어는 정해진 섹터 크기만큼 로드해준다는 것이다.</p>
<p>그렇다면 운영체제는 이를 어떻게 받을까? 예전에는 <strong>512KB * 8</strong> 배를 해서 <strong>4096KB 로 오버헤드를 줄이는 방식</strong>이였으나, 현대에서는 <strong>4096KB</strong> 를 그대로 사용하는 것으로 알고 있다. 즉, 4096KB 단위로 관리하므로 이 사이즈에 맞게 읽거나 딱 떨어지게 읽을 수 있다면 편하게 저장하고 읽기 쉽다.</p>
<p>자신의 섹터사이즈가 궁금하다면 아래 커맨드를 입력해서 확인해보면 좋다.</p>
<pre><code class="lang-bash">sudo fdisk -l | grep -E <span class="hljs-string">"Sector size|I/O size"</span>                                                                             ─╯
Sector size (logical/physical): 512 bytes / 512 bytes
I/O size (minimum/optimal): 512 bytes / 512 bytes
</code></pre>
<p>블럭 사이즈도 확인해보자 필자는 현재 Ubuntu 25 버전을 이용중이므로 4096 일 것이다.</p>
<pre><code class="lang-bash">sudo blockdev --getbsz /dev/sda2
4096
</code></pre>
<h2 id="heading-7iuk7zey">실험</h2>
<p>실험에서는 블럭사이즈가 2의 승수 만큼 올라가진 않는 수와 2의 승수로 올라가는 수로 동일한 데이터를 읽었을때 시간차이를 비교해보겠다.</p>
<ul>
<li><p>1024KB(Block Size) 씩 20480번 데이터를 읽고 쓰기 = <strong>20971520 bytes (21 MB, 20 MiB)</strong></p>
</li>
<li><p>1130KB(Block Size) 씩 18560번 데이터를 읽고 쓰기 = <strong>20972800 bytes (21 MB, 20 MiB)</strong></p>
</li>
</ul>
<p>위의 데이터를 보면 보통은 2번 케이스가 <strong>buffer_size 가 크기 때문에 더 빠르지 않을까?</strong> 라는 생각을 할 수 있다. 이걸 테스트 하기 위해 Linux 의 커맨드인 <code>strace</code> 와 <code>dd</code> 를 사용해보겠다.</p>
<pre><code class="lang-bash">strace -c dd bs=1130 count=18560 <span class="hljs-keyword">if</span>=/dev/zero of=test1 oflag=direct
</code></pre>
<p>일단 인자 설명부터 하겠다.</p>
<ul>
<li><p><code>bs</code>: 블럭사이즈로 예제 2의 경우 1130 으로 설정하면 된다.</p>
</li>
<li><p><code>count</code> : 얼마나 반복해서 옮길것인지 예제 2의 경우 18560 이 된다.</p>
</li>
<li><p><code>/dev/zero</code>: 읽을때마다 0을 주는 곳이다.</p>
</li>
<li><p><code>test1</code>:우리가 데이터를 저장할 곳이다.</p>
</li>
<li><p><code>oflag</code> 의 direct 는 운영체제의 커널 버퍼를 최대한 이용하지 않고, 하드웨어에 직접 쓰겠다는 의미이다. 커널 버퍼를 이용하게 되면 최적화가 되서 원치 않은 결과가 나오게 될수도 있다.</p>
</li>
</ul>
<p>해당 커맨드를 실행시켜 <code>dd</code> 로 블럭 사이즈를 1130, 그리고 횟수를 18560 으로 잡고 <code>/dev/zero</code> (읽을때 마다 0이 나옴) 에서 데이터를 읽어서 test1 에 파일저장을 해보겠다.</p>
<pre><code class="lang-bash">18560+0 records <span class="hljs-keyword">in</span>
18560+0 records out
20972800 bytes (21 MB, 20 MiB) copied, 7.86753 s, 2.7 MB/s
% time     seconds  usecs/call     calls    errors syscall
------ ----------- ----------- --------- --------- ------------------
 89.88    0.520800          28     18560           write
  9.98    0.057836           3     18572           <span class="hljs-built_in">read</span>
  0.04    0.000253          23        11           futex
  0.02    0.000133         133         1           socketpair
  0.02    0.000128          64         2           ftruncate
  0.01    0.000045          15         3           clone3
  0.01    0.000039           2        18        11 statx
  0.01    0.000033           1        17        12 readlink
  0.00    0.000024           2        11           close
  0.00    0.000022           2         9           rt_sigprocmask
  0.00    0.000021           7         3           madvise
  0.00    0.000020           0        32           mmap
  0.00    0.000014          14         1           sendto
  0.00    0.000014           1        12         2 openat
  0.00    0.000013           6         2           munmap
  0.00    0.000009           0        10           rt_sigaction
  0.00    0.000005           1         4           lseek
  0.00    0.000004           1         4           brk
  0.00    0.000004           4         1           ioctl
  0.00    0.000003           1         3           sigaltstack
  0.00    0.000000           0         8           fstat
  0.00    0.000000           0         1           poll
  0.00    0.000000           0         8           mprotect
  0.00    0.000000           0         4           pread64
  0.00    0.000000           0         2         2 access
  0.00    0.000000           0         1           execve
  0.00    0.000000           0         2         2 statfs
  0.00    0.000000           0         1           arch_prctl
  0.00    0.000000           0         1           sched_getaffinity
  0.00    0.000000           0         1           set_tid_address
  0.00    0.000000           0         1           set_robust_list
  0.00    0.000000           0         2           prlimit64
  0.00    0.000000           0         2           getrandom
  0.00    0.000000           0         1           rseq
------ ----------- ----------- --------- --------- ------------------
100.00    0.579420          15     37311        29 total
</code></pre>
<p>약 7.87 초가 소요되었다.</p>
<pre><code class="lang-bash">strace -c dd bs=1024 count=20480 <span class="hljs-keyword">if</span>=/dev/zero of=test1 oflag=direct
</code></pre>
<p>이제 1024 로 20480 번 시도해보자.</p>
<pre><code class="lang-bash">20480+0 records <span class="hljs-keyword">in</span>
20480+0 records out
20971520 bytes (21 MB, 20 MiB) copied, 7.36041 s, 2.8 MB/s
% time     seconds  usecs/call     calls    errors syscall
------ ----------- ----------- --------- --------- ------------------
 88.91    0.510712          24     20480           write
 10.92    0.062707           3     20492           <span class="hljs-built_in">read</span>
  0.03    0.000201          15        13           futex
  0.03    0.000190           5        32           mmap
  0.03    0.000153         153         1           execve
  0.01    0.000063           3        17        12 readlink
  0.01    0.000056           4        12         2 openat
  0.01    0.000055           6         8           mprotect
  0.01    0.000049           2        18        11 statx
  0.01    0.000045           4        11           close
  0.01    0.000031           3         8           fstat
  0.01    0.000029           7         4           lseek
  0.00    0.000021           2        10           rt_sigaction
  0.00    0.000018           9         2           munmap
  0.00    0.000018           9         2           ftruncate
  0.00    0.000014           3         4           brk
  0.00    0.000013           3         4           pread64
  0.00    0.000010           3         3           sigaltstack
  0.00    0.000009           4         2         2 statfs
  0.00    0.000006           3         2         2 access
  0.00    0.000006           3         2           prlimit64
  0.00    0.000005           0         9           rt_sigprocmask
  0.00    0.000005           5         1           sendto
  0.00    0.000005           2         2           getrandom
  0.00    0.000004           4         1           poll
  0.00    0.000004           4         1           arch_prctl
  0.00    0.000004           4         1           sched_getaffinity
  0.00    0.000003           3         1           set_tid_address
  0.00    0.000003           3         1           set_robust_list
  0.00    0.000003           3         1           rseq
  0.00    0.000002           2         1           ioctl
  0.00    0.000000           0         3           madvise
  0.00    0.000000           0         1           socketpair
  0.00    0.000000           0         3           clone3
------ ----------- ----------- --------- --------- ------------------
100.00    0.574444          13     41153        29 total
</code></pre>
<p>약 7.37 초가 소요되었다. 즉, 적은 수의 buffer_size 를 이용함에도 <code>7.87 —&gt; 7.37</code> <strong>초 약 0.5 초가량의 큰 차이</strong>가 난다. 데이터가 크다면 기하급수적으로 차이가 더 날것이다.</p>
<blockquote>
<p>데이터가 2번 케이스가 더 커서 느린거 아니야?</p>
</blockquote>
<p>위와 같은 생각도 할수 있으므로 1024 에서 그냥 더 읽어보겠다.</p>
<pre><code class="lang-bash">strace -c dd bs=1024 count=20481 <span class="hljs-keyword">if</span>=/dev/zero of=test1 oflag=direct                                                        ─╯
20481+0 records <span class="hljs-keyword">in</span>
20481+0 records out
20972544 bytes (21 MB, 20 MiB) copied, 7.37321 s, 2.8 MB/s
% time     seconds  usecs/call     calls    errors syscall
------ ----------- ----------- --------- --------- ------------------
 89.04    0.508829          24     20481           write
 10.72    0.061252           2     20493           <span class="hljs-built_in">read</span>
  0.04    0.000221          22        10           futex
  0.03    0.000198           6        32           mmap
  0.03    0.000158         158         1           execve
  0.02    0.000113          56         2           ftruncate
  0.01    0.000071           4        17        12 readlink
  0.01    0.000071           5        12         2 openat
  0.01    0.000070           3        18        11 statx
  0.01    0.000056           7         8           mprotect
  0.01    0.000049           4        11           close
  0.01    0.000046          46         1           socketpair
  0.01    0.000045          15         3           clone3
  0.01    0.000035           8         4           lseek
  0.01    0.000029           2        10           rt_sigaction
  0.00    0.000028           3         9           rt_sigprocmask
  0.00    0.000026           3         8           fstat
  0.00    0.000022           7         3           madvise
  0.00    0.000021          10         2           munmap
  0.00    0.000015           3         4           brk
  0.00    0.000013           3         4           pread64
  0.00    0.000010           5         2         2 statfs
  0.00    0.000009           3         3           sigaltstack
  0.00    0.000008           4         2         2 access
  0.00    0.000006           6         1           sendto
  0.00    0.000006           3         2           prlimit64
  0.00    0.000006           3         2           getrandom
  0.00    0.000004           4         1           poll
  0.00    0.000004           4         1           sched_getaffinity
  0.00    0.000003           3         1           ioctl
  0.00    0.000003           3         1           set_tid_address
  0.00    0.000003           3         1           set_robust_list
  0.00    0.000003           3         1           rseq
  0.00    0.000002           2         1           arch_prctl
------ ----------- ----------- --------- --------- ------------------
100.00    0.571435          13     41152        29 total
</code></pre>
<p>데이터를 2번케이스보다도 더 읽고 썼음에도 0.5 초나 더 빠르다. 왜 하드웨어의 섹터 사이즈, 그리고 운영체제의 블록사이즈가 1024, 4096 과 같은 수로 운영되는지 눈으로 확인해볼 수 있었다.</p>
<h2 id="heading-7kcv66as">정리</h2>
]]></content:encoded></item><item><title><![CDATA[책 리뷰 - 밑바닥부터 배우는 Ai 에이전트]]></title><description><![CDATA[리뷰
최근 회사에서도 AI Agent 를 정말 많이 사용하고 있고, 우리 파이프라인 자체는 이미 LLM call 의 chaining 을 이용해서 사진이나 제품을 분류하는 일들을 많이 진행하고 있었음. 예전에 langchain 이 나올 무렵에는 대부분 langchain 도 단순히 OpenAPI 의 wrapper 수준이여서 API 콜 정도 밖에 지원하지 않았었는데, 그때 수동으로 chaining 이나 routing 같은 패턴을 순수 파이썬 코드로 ...]]></description><link>https://roach-wiki.com/ai</link><guid isPermaLink="true">https://roach-wiki.com/ai</guid><category><![CDATA[리뷰 ]]></category><category><![CDATA[agents]]></category><dc:creator><![CDATA[roach]]></dc:creator><pubDate>Sat, 27 Dec 2025 09:29:00 GMT</pubDate><content:encoded><![CDATA[<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1766827242361/f13d2243-cb6f-42de-b177-b2d5acf71629.png" alt class="image--center mx-auto" /></p>
<h2 id="heading-66as67ew">리뷰</h2>
<p>최근 회사에서도 AI Agent 를 정말 많이 사용하고 있고, 우리 파이프라인 자체는 이미 LLM call 의 chaining 을 이용해서 사진이나 제품을 분류하는 일들을 많이 진행하고 있었음. 예전에 <code>langchain</code> 이 나올 무렵에는 대부분 langchain 도 단순히 OpenAPI 의 wrapper 수준이여서 API 콜 정도 밖에 지원하지 않았었는데, 그때 수동으로 <code>chaining</code> 이나 <code>routing</code> 같은 패턴을 순수 파이썬 코드로 작업했었었다.</p>
<p>이 책을 읽으면 그때 경험을 간접적으로 해볼 수 있다. 순수 파이썬 코드로 짜보면서 내부에서 대략적으로 어느 방식으로 돌아가는지 이해하고, 나중에 직접적으로 제어해야 할때 알맞은 디자인 패턴을 골라서 적용할 수 있도록 경험치를 쌓아주는 책.</p>
<h2 id="heading-7j6l7kcq">장점</h2>
<ul>
<li><p>하루만에 충분히 읽을 수 있음</p>
</li>
<li><p>순수 파이썬 코드로 간단하게 현재 LLM Agent 를 여러 디자인 패턴과 함께 맛보기 좋음</p>
</li>
<li><p>파이썬을 알고만 있다면 아주쉽게 따라하기 좋음</p>
</li>
<li><p>쉬움</p>
</li>
</ul>
<h3 id="heading-64uo7kcq">단점</h3>
<ul>
<li><p>UI 구현은 솔직히 왜 있는지 잘 모르겠음</p>
</li>
<li><p>밑바닥부터 무언갈 만들긴 하나 거의 가드레일 하나 없는 Tutorial 수준의 예시들임</p>
</li>
</ul>
<h3 id="heading-7lau7lkc7zwy64quioupheyeka">추천하는 독자</h3>
<ul>
<li>Agent 로 파이프라인을 한번도 구성해보지 않은 사람들 (구성해봤다면 굳이 안읽어도 될거 같음. 추상화된 개념 정도의 수준의 구현만 있음)</li>
</ul>
<h3 id="heading-64kc7j2064e">난이도</h3>
<ul>
<li>Easy</li>
</ul>
<h3 id="heading-7kcv66as">정리</h3>
<p>Agent 파이프라인을 이미 구성해본 사람들에게는 그다지 도움이 안되지만, 처음 시작해보는 사람들에게는 이렇게 구성할 수 있구나 하고 깨달음을 줄 수도 있는 책. 근데 이미 langchain 이나 이런걸 쓴다해도, 이정도의 개념을 모르고 쓰기는 어렵다고 생각함.</p>
<hr />
<p>책링크: <a target="_blank" href="https://product.kyobobook.co.kr/detail/S000218729898">https://product.kyobobook.co.kr/detail/S000218729898</a></p>
]]></content:encoded></item><item><title><![CDATA[select 톺아보기]]></title><description><![CDATA[배경
시스템 프로그래밍을 공부하거나 비동기 프로그래밍 아키텍쳐 부분을 공부하다보면 심심치 않게 selector, epoll, kqueue 등의 키워드를 마주하게 된다. 이를 개념적으로 이해하는 것도 좋으나 항상 코드와 함께 무엇이 문제여서 발전했는지 보다보면 조금 더 이해가 잘간다. 함께 C로 작성된 코드로 함께 알아보자. (대부분이 pseudo 느낌이라 사실 C 를 몰라도 이해하기 쉬울 것이다)
이 포스트에서는 sleep 상태로의 전환 그리고...]]></description><link>https://roach-wiki.com/select</link><guid isPermaLink="true">https://roach-wiki.com/select</guid><dc:creator><![CDATA[roach]]></dc:creator><pubDate>Sat, 27 Dec 2025 06:48:55 GMT</pubDate><content:encoded><![CDATA[<h2 id="heading-67cw6rk9">배경</h2>
<p>시스템 프로그래밍을 공부하거나 비동기 프로그래밍 아키텍쳐 부분을 공부하다보면 심심치 않게 <code>selector</code>, <code>epoll</code>, <code>kqueue</code> 등의 키워드를 마주하게 된다. 이를 개념적으로 이해하는 것도 좋으나 항상 코드와 함께 무엇이 문제여서 발전했는지 보다보면 조금 더 이해가 잘간다. 함께 <code>C</code>로 작성된 코드로 함께 알아보자. (대부분이 pseudo 느낌이라 사실 C 를 몰라도 이해하기 쉬울 것이다)</p>
<p>이 포스트에서는 <code>sleep</code> 상태로의 전환 그리고 읽기가 가능할때 다시 깨어나서 값을 출력하는 등을 보여주는데 집중할 것이므로 기타 부수적인 지식이나 사항은 따로 설명하지 않을 예정이다.</p>
<h2 id="heading-busy-waiting">Busy waiting</h2>
<pre><code class="lang-c">    <span class="hljs-keyword">while</span> (len != <span class="hljs-number">0</span> &amp;&amp; (ret = read(fd, ptr, len)) != <span class="hljs-number">0</span>) {
        <span class="hljs-keyword">if</span> (ret == <span class="hljs-number">-1</span>) {
            <span class="hljs-keyword">if</span> (errno == EINTR) {
                <span class="hljs-keyword">continue</span>;
            }
            <span class="hljs-keyword">if</span> (errno == EAGAIN || errno == EWOULDBLOCK) {
                <span class="hljs-built_in">fprintf</span>(<span class="hljs-built_in">stderr</span>, <span class="hljs-string">"File is not ready for reading\n"</span>);
                sleep(<span class="hljs-number">1</span>);
                <span class="hljs-keyword">break</span>;
            }
        }
        <span class="hljs-keyword">if</span> (ret == <span class="hljs-number">-1</span>) {
            perror(<span class="hljs-string">"read"</span>);
            <span class="hljs-keyword">break</span>;
        }
        len -= ret;
        ptr += ret;
    }
</code></pre>
<p>일단 <code>Blocking</code> 은 대부분 알고 있으니 <code>NonBlocking</code> 부터 시작해보겠다. 기존 Blocking 시스템에서는 file 이 사용가능 할때까지 blocking 이 걸렸으나, nonblocking 은 <strong>특수한 에러(EAGAIN or EWOULDBLOCK)</strong> 과 같은 에러를 리턴해서 현재 파일 읽기 또는 쓰기가 불가능함을 알려준다. (또는 블락킹을 당할 상황)</p>
<p>따라서 <code>Busy waiting</code> 방식으로 while 문 안에서 계속해서 파일이 사용가능한지를 체크하게 되면 한가지 문제점이 발생한다. 바로 프로세스가 <strong>계속해서 Sleep 상태로 들어가지 못하고 일해야 한다는 사실</strong>이다. 즉, CPU 를 계속해서 사용하게 된다. (signal 로 구현도 가능하나 복잡하여 이 본문에서는 다루지 않겠다)</p>
<p>이 부분을 해결하기 위해서는 현실적으로 많이 사용하는 <strong>“Blocking”</strong> 으로 전환하고, 멀티 스레드 혹은 멀티 프로세스 모드로 전환하여 하나의 스레드(또는 프로세스)가 blocking 되더라도 다른 일을 처리 가능하므로 하나의 스레드의 블락킹이 전체 프로그램에 크게 영향을 미치지 않아 보이게 할 수 있다. 다만 이러한 방식은 그 유명한 C10K 문제를 유발하게 된다.</p>
<h2 id="heading-select">select</h2>
<p>그렇다면 프로세스는 원하는 fd 들이 준비될때까지 sleep 상태에 들어가고, <strong>fd</strong> 들이 쓸수 있는 상태가 되었을때 알림을 받고 일할 수 있다면 어떨까? select 는 이러한 문제를 해결하기 위해 개발되었다.</p>
<pre><code class="lang-c"><span class="hljs-function"><span class="hljs-keyword">int</span>  <span class="hljs-title">select</span><span class="hljs-params">(<span class="hljs-keyword">int</span> n, fd_set *read_fds, fd_set *write_fds, fd_set *except_fds,
         struct timeval *timeout)</span></span>;
</code></pre>
<p><a target="_blank" href="https://pubs.opengroup.org/onlinepubs/009695199/basedefs/sys/select.h.html">공식문서</a>를 보면 대략적으로 위와 같이 정의되어 있다. (가독성을 위해 인자 이름을 변경하였다)</p>
<p>간단하게 설명하면 우리가 확인하고 싶은 fd 들의 집합(e.g. 1,2,3,4,..) 를 넘기면 커널이 준비된 fd 들의 집합을 리턴해준다. 즉, <strong>읽기가 가능한지 확인하고 싶은 집합, 쓰기가 가능한지 확인하고 싶은 집합</strong> 등을 보내면 된다.</p>
<pre><code class="lang-c">read_fds = [<span class="hljs-number">1</span>,<span class="hljs-number">2</span>,<span class="hljs-number">3</span>,<span class="hljs-number">4</span>] ======Send======&gt; readable: [<span class="hljs-number">1</span>] ======<span class="hljs-keyword">return</span>========&gt; [<span class="hljs-number">1</span>]
</code></pre>
<p>대략적으로 위와 같이 읽기 가능한 목록을 리턴해준다고 생각하면 된다. 근데 이렇게 배열로 관리하지 않고 <code>fd_set</code> 이라는 특별한 자료구조로 관리하기 때문에 매크로를 이용해서 내가 원하는 fd 가 읽기 가능한지 파악해야 된다. select 에서는 <code>FD_ISSET</code> 이라는 매크로를 지원하는데 이를 통해서 내가 원하는 fd 가 읽기 가능한지 확인 가능하다.</p>
<p>이런식으로 계속해서 읽기 가능한 set 을 알려주므로 select 는 레벨 트리거 라고 불린다.</p>
<p>커널은 이를 어떻게 확인할까? <code>kernel</code> 은 우리가 제공한 fd_set 을 N 번 순회하면서 이를 확인한다. 그래서 select 의 첫번째 인자는 <code>n</code> 을 받고 있는데 이는 <strong>우리가 제공한 fd 들 중 가장 높은 fd 에 +1 을 해준 값</strong>이다. 즉, kernel 이 내부적으로 <strong>0~n 까지 순회하며 이를 확인한다는 것</strong>이다. 따라서 기존 방식보다는 효율적이나 계속해서 커널이 N 번 확인이 반복된다는 단점이 존재한다.</p>
<h2 id="heading-7iuk7iq1">실습</h2>
<p>이정도만 알면 대략적으로 코드를 작성해볼수 있다. 코드에서 우리 프로세스의 STDIN 이 읽기 가능할때 해당 부분에서 1024 바이트만큼 읽어오는 코드를 작성해보겠다.</p>
<pre><code class="lang-c"><span class="hljs-meta">#<span class="hljs-meta-keyword">include</span> <span class="hljs-meta-string">&lt;stdio.h&gt;</span></span>
<span class="hljs-meta">#<span class="hljs-meta-keyword">include</span> <span class="hljs-meta-string">&lt;sys/select.h&gt;</span></span>
<span class="hljs-meta">#<span class="hljs-meta-keyword">include</span> <span class="hljs-meta-string">&lt;sys/time.h&gt;</span></span>
<span class="hljs-meta">#<span class="hljs-meta-keyword">include</span> <span class="hljs-meta-string">&lt;sys/types.h&gt;</span></span>
<span class="hljs-meta">#<span class="hljs-meta-keyword">include</span> <span class="hljs-meta-string">&lt;unistd.h&gt;</span></span>

<span class="hljs-meta">#<span class="hljs-meta-keyword">define</span> TIMEOUT 5</span>
<span class="hljs-meta">#<span class="hljs-meta-keyword">define</span> BUF_LEN 1024</span>

<span class="hljs-function"><span class="hljs-keyword">int</span> <span class="hljs-title">main</span><span class="hljs-params">(<span class="hljs-keyword">void</span>)</span> </span>{
    <span class="hljs-class"><span class="hljs-keyword">struct</span> <span class="hljs-title">timeval</span> <span class="hljs-title">tv</span>;</span>
    fd_set readfds;
    <span class="hljs-keyword">int</span> ret;

    FD_ZERO(&amp;readfds);
    FD_SET(STDIN_FILENO, &amp;readfds);

    tv.tv_sec = TIMEOUT;
    tv.tv_usec = <span class="hljs-number">0</span>;

    ret = select(STDIN_FILENO + <span class="hljs-number">1</span>, &amp;readfds, <span class="hljs-literal">NULL</span>, <span class="hljs-literal">NULL</span>, &amp;tv); <span class="hljs-comment">// (0 ~ nfds - 1)</span>

    <span class="hljs-keyword">if</span> (ret == <span class="hljs-number">-1</span>) {
        perror(<span class="hljs-string">"select"</span>);
        <span class="hljs-keyword">return</span> <span class="hljs-number">1</span>;
    } <span class="hljs-keyword">else</span> <span class="hljs-keyword">if</span> (!ret) {
        <span class="hljs-built_in">printf</span>(<span class="hljs-string">"%d seconds elapsed.\n"</span>, TIMEOUT);
        <span class="hljs-keyword">return</span> <span class="hljs-number">0</span>;
    }

    <span class="hljs-comment">/**
     * File descriptor 에서 즉시 읽기가 가능함.
     */</span>
    <span class="hljs-keyword">if</span> (FD_ISSET(STDIN_FILENO, &amp;readfds)) {
        <span class="hljs-keyword">char</span> buf[BUF_LEN + <span class="hljs-number">1</span>];
        <span class="hljs-keyword">int</span> len;

        len = read(STDIN_FILENO, buf, BUF_LEN);
        <span class="hljs-keyword">if</span> (len == <span class="hljs-number">-1</span>) {
            perror(<span class="hljs-string">"read"</span>);
            <span class="hljs-keyword">return</span> <span class="hljs-number">1</span>;
        }

        <span class="hljs-keyword">if</span> (len) {
            buf[len] = <span class="hljs-string">'\0'</span>; <span class="hljs-comment">// null character</span>
            <span class="hljs-built_in">printf</span>(<span class="hljs-string">"Read %d bytes: %s\n"</span>, len, buf);
        }

        <span class="hljs-keyword">return</span> <span class="hljs-number">0</span>;
    }

    <span class="hljs-built_in">fprintf</span>(<span class="hljs-built_in">stderr</span>, <span class="hljs-string">"No input available.\n"</span>);
    <span class="hljs-keyword">return</span> <span class="hljs-number">1</span>;
}
</code></pre>
<p>코드자체는 간단하다. 부분적으로 나눠서 보면 select 부분에서 <code>STDIN_FILENO</code> 가 읽기 가능할때까지 프로세스는 sleep 상태가 될것이고, 커널에서 읽기 가능하다 (<code>FD_ISSET(STDIN_FILENO, &amp;readfds)</code>) 라고 알려주면 byte 를 읽고 정상적으로 종료할 것이다.</p>
<p>바이너리 파일로 만들어서 한번 테스트 해보자.</p>
<pre><code class="lang-c">gcc -o ./bin/test_select <span class="hljs-number">10.</span>c
./bin/test_select &lt; test_pipe
</code></pre>
<p>이제 실행했으니 한번 프로세스의 상태를 알아보자. (pipe 는 go 의 channel 같은 느낌으로 이해하면 된다)</p>
<pre><code class="lang-c">ps aux | grep test_select
</code></pre>
<p>상태를 출력해보면 아래와 같이 S+ 상태로 나온다.</p>
<pre><code class="lang-c">roach      <span class="hljs-number">35347</span>  <span class="hljs-number">0.0</span>  <span class="hljs-number">0.0</span>   <span class="hljs-number">2784</span>  <span class="hljs-number">1436</span> pts/<span class="hljs-number">1</span>    S+   <span class="hljs-number">14</span>:<span class="hljs-number">50</span>   <span class="hljs-number">0</span>:<span class="hljs-number">00</span> ./bin/test_select
</code></pre>
<p>즉, fore ground 에서 sleep 상태로 있다는 뜻이다. 이제 해당 프로세스에 “hello” 를 입력해서 깨워보자.</p>
<pre><code class="lang-c">echo <span class="hljs-string">"hello"</span> &gt; my_input_pipe
</code></pre>
<p>이렇게 입력하고 나면 아래와 같이 <code>sleep</code> 상태에서 일어나서 들어온 값을 출력시키고 정상적으로 종료한다.</p>
<pre><code class="lang-c">readable!!
Read <span class="hljs-number">6</span> bytes: hello
</code></pre>
<h2 id="heading-66ei7lmy66mw">마치며</h2>
<p>오늘은 간단하게 리눅스 커맨드들과 함께 프로세스의 상태를 확인하며 <code>select</code> 를 알아보았다. 다음시간에는 <code>poll</code> 과 <code>epoll</code> 등을 알아보려고 한다.</p>
<hr />
<p>    <strong>레벨 트리거(level trigger)</strong>: LLM 의 설명으로는 전압이 1로 올라가게되면 그 상태를 유지하는 구간이 생기게 되는데 높은 레벨의 상태를 유지하는 동안에는 계속 <code>1</code> 을 리턴한다고 해서 특정 상태에 머무르면 트리거 된다고 해서 레벨방식이라고 한다.</p>
]]></content:encoded></item><item><title><![CDATA[바이브 코딩 회고]]></title><description><![CDATA[TL;DR

나는 바이브 코딩이 개발자를 근시일내에 대체할 것이라는 말엔 동감하지 않음

현재의 AI Tool 은 단순히 개인 역량을 더 증폭시켜주는 도구라고 생각함

AI 코딩 에이전트의 성능을 작업 시간으로 재는 것이 유효한가?

LLM 은 일종의 OS 의 철학적 역할과 비슷하게 구현 상세를 추상화시키는 도구가 될것이라고 생각


들어가며
요새 vibe coding 이라는 말이 되게 평범해졌고, 이제 누구나 노력한다면 간단한 프로토타입은 A...]]></description><link>https://roach-wiki.com/67cu7j2067imioy9louuqsdtmozqs6a</link><guid isPermaLink="true">https://roach-wiki.com/67cu7j2067imioy9louuqsdtmozqs6a</guid><dc:creator><![CDATA[roach]]></dc:creator><pubDate>Tue, 23 Dec 2025 05:50:49 GMT</pubDate><content:encoded><![CDATA[<h2 id="heading-tldr">TL;DR</h2>
<ul>
<li><p><strong>나는 바이브 코딩이 개발자를 근시일내에 대체할 것이라는 말엔 동감하지 않음</strong></p>
</li>
<li><p><strong>현재의 AI Tool 은 단순히 개인 역량을 더 증폭시켜주는 도구라고 생각함</strong></p>
</li>
<li><p><strong>AI 코딩 에이전트의 성능을 작업 시간으로 재는 것이 유효한가?</strong></p>
</li>
<li><p><strong>LLM 은 일종의 OS 의 철학적 역할과 비슷하게 구현 상세를 추상화시키는 도구가 될것이라고 생각</strong></p>
</li>
</ul>
<h2 id="heading-65ok7ja06rca66mw">들어가며</h2>
<p>요새 <code>vibe coding</code> 이라는 말이 되게 평범해졌고, 이제 누구나 노력한다면 간단한 프로토타입은 AI 로 개발할 수 있는 시대가 도래했다. 이런 기술력은 표면적으로는 AI 로 사람을 근시일내에 쉽게 대체할수 있어 보이지만 나는 사실 이러한 AI 신봉자들에 의견에는 크게 동의하지 않는다. 그래서 이런 부분때문에 뭐 공부를 하지 않아야된다 라고 조언하는 사람들 또한 좋아하지 않는다. 이 이야기를 하기 위해서는 내가 AI 를 사용했던 경험들에 대해 이야기 해야 한다.</p>
<p>2022년 인가 2023년 쯤 인가 언젠지는 잘 기억안나지만 <code>Github Copilot</code> 이 나오고 그때 신청해서 엄청나게 썼던 기억이 난다. 개인적으로 계속 쓰다가, 회사 코드에서도 사용을 권장해서 신청하여 회사코드에서도 적용할 방법들을 계속해서 고민했었다. 그래서 배민 내부에서도 Github Copilot 을 잘 쓰는 방법에 대해 3번 정도 사내에서 작거나 크게 공유하는 자리를 가졌었다.</p>
<p>그 당시 코파일럿을 보면서 느꼈던 점은 참 구조화된 일은 잘한다는 것이였다. 예를 들면, <code>given-when-then</code> 으로 테스트 코드를 구조화 시켰을때 <code>Copilot</code> 이 새롭게 테스트 코드를 작성해도 이 <code>given-when-then</code> 을 맞춰서 잘 작성해준다는 것이였다. 즉, 파일 내에서의 일정한 패턴을 인식하고, 이 패턴에 맞게 코드를 작성해주는 것처럼 보였다.</p>
<p>그래서 이 당시에는 코파일럿에게 위와 같이 패턴이 파일내에서 한눈에 보이는 작업들을 많이 시켰다. 이러한 작업은 내가 작성하나 코파일럿이 작성하나 실상 큰 차이가 나지 않았기 때문이다. 이때도 일부 내가 패턴이 보이되 쉽게 반복해서 해야 하는 작업들은 코파일럿에게 넘기는 방법을 많이 연구했었다.</p>
<h2 id="heading-agent">Agent 시대의 도래</h2>
<p>그러다가 LLM 기술이 발전하고 컨텍스트 사이즈가 늘어나고 점차 Agent 향으로 발달하면서 확연하게 <code>code assistance</code> 들의 성능이 좋아진게 느껴졌다. 특히 Antropic 의 <code>Claude Code</code> 가 나오고 바로 써봤을때 정말 놀라운 수준의 지능을 가졌다고 느껴지기도 했다.</p>
<p>이정도면 사람들이 많이 쓰겠지? 라는 생각을 가지고 주변 개발자들과 만나는 시간을 가질때 이야기를 자주 했었지만 쓰는 사람이 생각보다 많지 않았다. (쓰는 사람이 나밖에 없을때가 더 많았던거 같다)</p>
<p>그 당시 집에서 혼자 웹 Trading 게임을 바이브코딩만으로 완성시켜보겠다고 클로드 코드랑 놀았던 기억이 있다. 여하튼, 이 당시 사용하지 않는 개발자들과 많이 이야기 해보며 느꼈던 점은 보통 아래와 같았다.</p>
<ol>
<li><p>AI 기술에 대해 너무 큰 기대를 가지고 있음.</p>
</li>
<li><p>Copilot 시절의 <code>auto-completion</code> 기능때문에 Context 를 헤쳤던 안좋은 기억이 많음</p>
</li>
<li><p>그냥 필요가 있다고 느껴지지 않아 안써봄.</p>
</li>
</ol>
<h3 id="heading-ai">AI 로 근시일내 사람이 대체 불가하다고 믿는 이유</h3>
<p><code>AI 기술에 대해 너무 큰 기대를 가지고 있는</code> 그룹에 대해 이야기해보자. 이 경우는 프롬프트를 입력하면 Coding agent 가 뚝딱하고 완벽한 결과를 내놓기를 원한다. 하지만 그건 거의 불가능하다. 이건 나는 지금도 불가능하다고 생각하는데 이 이유는 아래와 같다.</p>
<p>예를 들어, 10억에서 100억건 사이의 문서를 크롤링 해야한다고 해보자. 이걸 AI Agent 에게 시킬때 단순히 아래와 같이 프롬프팅 했다고 해보자.</p>
<blockquote>
<p>"내가 특정 페이지들에서 문서를 하루동안 크롤링 할건데, 최대한 이 시간동안에는 중복처리가 안되게 해줘."</p>
</blockquote>
<p>그렇다면 중복처리는 어떻게 해야할까? AI agent 가 UUID 를 Unique 키로 이용해 Redis 를 통해 분산 시스템에서도 보장되는 중복처리를 하겠다고 플랜을 작성했다.</p>
<pre><code class="lang-plaintext">{"uuid" : "1"}
</code></pre>
<p>위와 같이 하면 충분히 중복처리가 가능할거 같고, 사용자 또한 크게 생각하지 않고 accept 를 누른다. 몇 번의 바이브 코드로 수정하고 동작하자 운영에 배포한다. 운영에 나가면 어떻게 될까? 높은 확률로 Redis 가 정상적으로 동작하지 않게 된다. 일단 저 구조로 10억개가 올라가면 어림잡아 계산 때려도 메모리가 몇십에서 몇백GB 이상이 필요하게 된다. 점점 쌓이다가 결국 죽고 말것이다.</p>
<p>여기서 문제가 이제 발생한다. <code>Vibe coding</code> 으로 저 코드를 accept 한 유저가 이 문제를 발견할 수 있을까? 내 생각에는 높은 확률로 발견하지 못한다. 왜냐면 이걸 accept 한 사람의 코드를 작성하는 판단에는 <code>Memory</code> 에 대한 경험 또는 학습이 부족하기 때문이다.</p>
<p>그리고 어찌어찌 알아내서 개선을 한다해도 이 이후에 개선은 Chat-GPT 또는 또다른 LLM 에 맞겨 진행한다. 이게 정말 <code>Production</code> 에 배포되도 되는 코드인가? 상황에 따라 다르겠지만 대부분의 상황에서 개인적으로는 아니라고 생각한다.</p>
<p>이 이야기를 들으면 반대로 모든 걸 다 디테일하게 챙겨주면 가능하지 않냐는 말을 하는 사람도 있다. 나도 이 부분에는 동의한다. 다만 여기서의 모순은 이 모든걸 다 챙길 사람이라는 리소스가 필요하다는 것이다. 즉, 이 말이 능력이 좋은 한 사람이 여러 Agent 를 활용해 생산성을 높일 수는 있지만, 그 사람을 대체하는 것은 아직은 불가능하다는 뜻이기도 하다.</p>
<p>그러므로 나는 현재의 AI 도구들은 <strong>증폭기</strong>라고 생각한다. 즉, 결국 LLM 이라는 것도 내가 입력한 토큰 기반으로 답변을 생성해내는 것이기 때문에, <code>Garbage-in Garbage-out</code> 이라는 말과 같이 쓰레기를 넣으면 쓰레기가 나올수 밖에 없다.</p>
<h2 id="heading-ai-agent">AI Agent 를 활용하는 방법</h2>
<p>위와 같이 AI Agent 는 LLM 모델의 성능 그리고 내가 입력한 토큰들에 의해 결과가 좌지우지된다. 즉, <strong>비결정적</strong>이다. 그래서 AI 와 TDD 를 함께 섞어 테스트로 결정적인 함수[^1]를 만들어서 이 비결정적인 Output 을 테스트하여 최대한 비결정성을 줄이려는 시도들을 하는 것 같다.</p>
<p>이러한 구조적인 방향성에는 크게 동의하며 나 또한 AI 에게 TDD 는 아니지만 Test 는 대부분 수행시키며, 코드 규칙을 따르게 하기 위한 <code>lint</code> 도 시킨다. 어떠한 더 좋은 방향성이 나올지는 모르지만, 사람의 개입으로 비결정적인 방향을 결정성을 지닌 함수에 넣어 빠르게 피드백을 받고 고치게 하는 이러한 방향으로 구조화 되어야 한다고 본다.</p>
<p>구조화 하는 방향과 함께 나의 작업 방향성과 AI 의 방향성이 틀어질 확률 또한 낮춰야 한다고 생각한다. 나 또한 최대한 Planning 과정에서 AI Agent 와 이야기를 많이 하여 대부분의 Planning 과정에서 구현 방향 또한 공유하고, 그걸 토대로 구현을 AI 에게 맡긴다.</p>
<p>이러한 방향에서 계속해서 인간이 개입해야 한다고 보며 개인의 능력에 따라서 AI 가 동일한 토큰을 입력했을때 원하는 답변을 얻을 확률을 줄이는 것이 개인의 역량이라고 본다.</p>
<h2 id="heading-os-ai">OS 의 철학적 역할을 하게될 AI</h2>
<p>위와 같이 AI 가 기초적인 구현을 잘 하게 되다보면 OS 의 철학적 역할과 비슷해질것이라고 본다. 우리는 하드웨어가 어떻게 동작하는지 기초적인 구현체는 잘 모르지만, 아래단계에서 추상화를 통해 구현의 복잡함을 숨겨주기 때문에 우리가 프로그래밍을 할때는 논리에만 집중하여 프로그래밍을 할 수 있게 된다.</p>
<p>나는 <code>AI</code> 들이 발전하면 점차 구체적인 구현작업들은 AI 들이 대부분 해주고, 사람은 오히려 더 추상적인 일들을 많이 하게 될것이라고 믿고 있다. 다만 현재에도 하드웨어를 만들고 더 효율 좋은 좋은 아키텍쳐로 발전하려고 힘쓰는 사람들이 있듯이 이 시간대에도 더 효율성이 좋은 미래의 보편적인 추상화 아래의 부분에서 힘쓰는 사람들이 있을거라고 생각한다.</p>
<h2 id="heading-7l2u65sp7jeq7j207kce7yq466w8ioylnoqwhoycvouhncdtmqjsnkjsnyqg7lih7kcv7zwy64qu6rkmioygleunkcdsnkdtmqjtlzzqsia">코딩에이전트를 시간으로 효율을 측정하는게 정말 유효한가?</h2>
<p>이렇게 하다보면 특정 작업에서는 내가 코드를 작성할때보다 AI 가 작성할때 시간이 더 걸리는 경우가 있다. 그래서 가끔 AI 코딩 에이전트의 시간 효율성이 인간 작업자에 비해 별로다 라고 이야기하는 리포트들이 나오는데, 나는 시간으로 측정하는게 맞나 싶다.</p>
<p>애초에 이 일을 맡겨 놓으면 나는 이 일에 뇌를 소비하지 않고, 또 다른 작업을 플래닝하는데 뇌를 쓸수 있는 가용시간이 생기는 것이기 때문에, 하나의 Task 를 같이 했을때 시간으로 측정하는건 크게 의미가 없다고 본다. 오히려 AI 도구를 쓰는 사람들이 위와 같이 작업을 하려는 노력을 해야한다고 본다.</p>
<h2 id="heading-66ei7lmy66mw">마치며</h2>
<p>나는 <code>AI</code> 가 앞으로 세상을 많이 바꿀거라고 생각한다. 지금은 극 초기라고 생각하고 앞으로 더 빠르게 바뀔것 같다. 이러한 시기에 내가 장점이라고 생각하는 부분은 기존에는 좋은 기관에 가야만 배울수 있던 부분들을 AI 를 통해 학습하고 실제로 구현해서 실험까지 해보는 것들이다.</p>
<p>실제로 AI 와 대화하면서 많이 생각하고, 실제로 몰랐던 부분들을 직접 구현해보고 테스트해보면서 다른 문제들을 해결할 방법들에 대한 실마리를 얻곤 한다. 평상시 잘 몰랐던 부분들을 AI 와 함께 학습하면서 최근에 실력이 더 빠르게 늘고 있다고 생각한다.</p>
<p>다만, 이러한 과정속에서도 대부분의 구현을 AI 에 맡기기 보다 자신이 원하는 케이스를 반환하도록 AI 를 함수처럼 이용해보거나 반대로 AI 에게 추상적인 부분을 맡기고 자신이 최대한 구현해 보며 구현력을 높이는 방향도 좋다.</p>
<p>아니라면 알고리즘을 풀어보는 연습을 하는 것도 좋다. 알고리즘 문제 풀이가 자신의 논리를 코드로 정확하게 옮기는 힘을 기른다고 생각하기에 이런 부분에서 큰 도움이 된다고 생각한다. 여하튼 <code>AI</code> 세상이 와도 뭐 노동은 가치가 없어진다는 등 나는 이러한 시대가 온다고 말하기에는 현재의 기술수준은 그정도는 아니라고 생각한다. 각자 자기가 할수있는 방향에서 <code>AI</code> 를 쓸수 있다면 써보도 개인의 능력을 키우는데 많이 써보는게 좋다고 생각한다.</p>
<p>그리고 과도한 <code>AI hype</code> 은 경계해야 한다. 그렇게 실제로 일자리를 없앨 기술이라면 이미 개발자는 아예 없어졌어야 한다. 재미있던 부분을 빠르게 공부해보고 잘 가르쳐주는 똑똑한 친구를 얻었다고 생각하면 좋을 것 같다.</p>
<p>[^1]: 테스트는 입력과 기대 출력이 고정되어 있으므로 결정적(deterministic)인 함수처럼 다룬다. 즉, F(Input) = Output 형태로 동일한 입력에 대해 동일한 출력이 나오는지를 검증하는 과정이다.</p>
]]></content:encoded></item><item><title><![CDATA[LinkedList 페이징]]></title><description><![CDATA[저번 시간에는 LinkedList 의 노드를 하나하나 파일에서 읽어오면서 삽입과 삭제를 진행했다. 노드를 하나하나 읽다보니 File I/O 가 읽는 만큼 생기게 됬고 상당히 비싼 연산으로 동작하게 됬다.
오늘은 이 LinkedList 를 일정 Block 단위로 묶어 한번에 읽어오고, 이에 대한 순회연산은 메모리 내부에서 진행하는 방식으로 최적화를 진행해보려고 한다.
기본구조

지난시간까지는 노드가 어디에 저장됬는지 offset 을 쫓아 이동했다...]]></description><link>https://roach-wiki.com/linkedlist</link><guid isPermaLink="true">https://roach-wiki.com/linkedlist</guid><dc:creator><![CDATA[roach]]></dc:creator><pubDate>Tue, 23 Dec 2025 05:50:09 GMT</pubDate><content:encoded><![CDATA[<p><a target="_blank" href="wiki/doc-1763560568">저번 시간</a>에는 LinkedList 의 노드를 하나하나 파일에서 읽어오면서 삽입과 삭제를 진행했다. 노드를 하나하나 읽다보니 File I/O 가 읽는 만큼 생기게 됬고 상당히 비싼 연산으로 동작하게 됬다.</p>
<p>오늘은 이 LinkedList 를 일정 Block 단위로 묶어 한번에 읽어오고, 이에 대한 순회연산은 메모리 내부에서 진행하는 방식으로 최적화를 진행해보려고 한다.</p>
<h2 id="heading-6riw67o46rws7kgw">기본구조</h2>
<p><img src="https://storage.googleapis.com/roach-wiki/images/06c5af8f-116b-46ab-952f-e440d7e91e6d.webp" alt="image.png" /></p>
<p>지난시간까지는 노드가 어디에 저장됬는지 offset 을 쫓아 이동했다면, 이제는 Page 를 연속적으로 쫓아 Page 가 어디에 저장됬는지를 찾게 될 것이다. 하나의 페이지사이즈가 <strong>4096</strong> 일때 이 페이지에 저장될 수 있는 16바이트 크기의 노드 개수는 <strong>256개</strong>이다. 그렇다면 우리가 페이징 시스템을 적용함으로써 얻을 수 있는 File I/O 의 이상적인 축소치는 아래와 같은 식이 될 것이다. 조금 더 수식적으로 정리해보자.</p>
<p><strong>1. 기본 가정</strong></p>
<ul>
<li><p><strong>페이지 크기 ($P_{size}$):</strong> 4096 bytes</p>
</li>
<li><p><strong>노드 크기 ($N_{size}$):</strong> 16 bytes</p>
</li>
<li><p><strong>기존 I/O 횟수 ($IO_{old}$):</strong> 10,000 번</p>
</li>
</ul>
<p><strong>2. 페이지 당 노드 수용량 ($C$)</strong> 한 페이지에 저장될 수 있는 노드의 개수는 아래와 같이 계산된다.</p>
<p>$$C = \frac{P_{size}}{N_{size}} = \frac{4096}{16} = 256 \text{ (nodes/page)}$$</p><p><strong>3. I/O 감소 효율 계산</strong> 페이징 시스템을 적용했을 때 기대할 수 있는 파일 I/O 횟수($IO_{new}$)는 기존 횟수에 페이지 밀집도의 역수를 곱한 것과 같다.</p>
<p>$$IO_{new} = IO_{old} \times \frac{1}{C}$$</p><p>$$IO_{new} = 10000 \times \frac{1}{256}$$</p><p>$$IO_{new} = 39.0625 \text{ (ops)}$$</p><p><strong>결론:</strong> 기존에 10,000번 발생하던 디스크 I/O는 페이징 기법을 통해 이론적으로 약 <strong>39.06번</strong>으로 감소하게 된다.</p>
<p>기존의 File I/O 가 10000 이고, 노드 사이즈는 16, 페이지 사이즈는 4086 인 경우로 가정해보자. 이 경우 페이지당 노드에 256개가 저장되게 되므로 아래와 같이 File/IO 를 줄일 수 있다.</p>
<h2 id="heading-6rws7zie">구현</h2>
<p>이제 구현부로 들어가보자. 저번 시간에 LinkedList 로 이미 File I/O 에 익숙해졌으므로 개념만 잡는다면 아주 쉽게 구현할 수 있을 것이다. 기본적으로 <strong>Page 의 offset</strong> 도 알아야 하고, <strong>Page 안의 Node 의 offset</strong> 도 알아야 할것이다. Page 안의 Node 는 이제부터 <strong>Slot</strong> 이라고 칭하겠다.</p>
<h3 id="heading-node">Node 구현</h3>
<p>일단 첫번째로 저번시간과 마찬가지로 데이터를 저장할 Node 의 구현체부터 구현해보자. Node 는 <code>uint32</code> 타입의 값을 가지고, 다음 Slot 을 나타내는 NextSlot 과 다음 페이지를 가지는 NextPage 값을 가진다.</p>
<p>여기서 NextPage 를 왜 Node 가 들고있지? Page 가 들고 있어야 할거 같은데? 라는 의문이 들수도 있는데 이 설계의 경우 <code>Page</code> 는 단순히 노드를 관리하고 <strong>예제 수준에서의 복잡성 및 I/O 를 줄이기 위해</strong> 도입된 것이므로 <code>Node</code> 의 연속적인 탐색을 이어주기 위해 <code>Node</code> 에 <code>NextPage</code> 의 정보도 담도록 하였다. (만약, NextPage 가 조금 더 나은 설계라고 생각되시면 한번 혼자서 구현해보는것도 추천한다.)</p>
<pre><code class="lang-go"><span class="hljs-keyword">type</span> Node <span class="hljs-keyword">struct</span> {
    Value    <span class="hljs-keyword">uint32</span> <span class="hljs-comment">// 4 byte</span>
    NextPage <span class="hljs-keyword">uint32</span> <span class="hljs-comment">// 4 byte</span>
    NextSlot <span class="hljs-keyword">uint16</span> <span class="hljs-comment">// 2 byte</span>
    Tomb     <span class="hljs-keyword">uint8</span>  <span class="hljs-comment">// 1 byte</span>
    _pad     <span class="hljs-keyword">uint32</span> <span class="hljs-comment">// 4 byte</span>
}
</code></pre>
<h3 id="heading-page">Page 구현</h3>
<p>이제 <code>Page</code> 에 대해 고민해보자. Page 는 어떠한 정보를 담고 있어야 할까? 사실상 지금 예시에서는 별다른 정보가 필요하지 않으니 얼마나 많은 노드가 있는지를 <code>Length</code> 라고 저장해보자. 이 값은 메타데이터이므로 Page 의 Header 로서 저장된다.</p>
<pre><code class="lang-go"><span class="hljs-keyword">type</span> PageHeader <span class="hljs-keyword">struct</span> {
    Length <span class="hljs-keyword">uint16</span>
}
</code></pre>
<p><code>PageHeader</code> 는 2byte 를 필요로 하므로 읽거나 쓸때 2byte 의 buffer 를 만들어주고 파일 시스템을 통해 쓰거나 읽어오면 된다. 이 부분은 이제 익숙하므로 바로 부가설명없이 코드로 적겠다.</p>
<pre><code class="lang-go"><span class="hljs-function"><span class="hljs-keyword">func</span> <span class="hljs-title">readPageHeader</span><span class="hljs-params">(f *os.File, pageID <span class="hljs-keyword">uint32</span>)</span> <span class="hljs-params">(PageHeader, error)</span></span> {
    offset := pageOffset(pageID)
    <span class="hljs-keyword">if</span> _, err := f.Seek(offset, io.SeekStart); err != <span class="hljs-literal">nil</span> {
        <span class="hljs-keyword">return</span> PageHeader{}, err
    }

    buf := <span class="hljs-built_in">make</span>([]<span class="hljs-keyword">byte</span>, PAGE_HEADER_SIZE)
    <span class="hljs-keyword">if</span> _, err := io.ReadFull(f, buf); err != <span class="hljs-literal">nil</span> {
        <span class="hljs-keyword">return</span> PageHeader{}, err
    }

    <span class="hljs-keyword">var</span> ph PageHeader
    ph.Length = Endian.Uint16(buf[<span class="hljs-number">0</span>:<span class="hljs-number">2</span>])
    <span class="hljs-keyword">return</span> ph, <span class="hljs-literal">nil</span>
}

<span class="hljs-function"><span class="hljs-keyword">func</span> <span class="hljs-title">writePageHeader</span><span class="hljs-params">(f *os.File, pageID <span class="hljs-keyword">uint32</span>, ph PageHeader)</span> <span class="hljs-title">error</span></span> {
    offset := pageOffset(pageID)
    <span class="hljs-keyword">if</span> _, err := f.Seek(offset, io.SeekStart); err != <span class="hljs-literal">nil</span> {
        <span class="hljs-keyword">return</span> err
    }

    buf := <span class="hljs-built_in">make</span>([]<span class="hljs-keyword">byte</span>, PAGE_HEADER_SIZE)
    Endian.PutUint16(buf[<span class="hljs-number">0</span>:<span class="hljs-number">2</span>], ph.Length)

    _, err := f.Write(buf)
    <span class="hljs-keyword">return</span> err
}
</code></pre>
<p>다만 Page 의 경우 하나의 메소드가 하나 더 필요하다. <code>Page</code> 가 없을 경우 <code>Used</code> 를 0 으로 Page 를 하나 생성해주어야 한다. <code>Used</code> 를 0 으로 하고 페이지를 하나 생성해서 파일에 써주자.</p>
<pre><code class="lang-go"><span class="hljs-comment">// 새로운 빈 페이지를 파일에 생성</span>
<span class="hljs-comment">// - PageHeader(Used = 0) 으로 기록하고 나머지는 0 으로 채움</span>
<span class="hljs-function"><span class="hljs-keyword">func</span> <span class="hljs-title">initEmptyPage</span><span class="hljs-params">(f *os.File, pageID <span class="hljs-keyword">uint32</span>)</span> <span class="hljs-title">error</span></span> {
    offset := pageOffset(pageID)
    <span class="hljs-keyword">if</span> _, err := f.Seek(offset, io.SeekStart); err != <span class="hljs-literal">nil</span> {
        <span class="hljs-keyword">return</span> err
    }

    <span class="hljs-comment">// 페이지 전체를 0 으로 채운다.</span>
    buf := <span class="hljs-built_in">make</span>([]<span class="hljs-keyword">byte</span>, PAGE_SIZE)

    _, err := f.Write(buf)
    <span class="hljs-keyword">return</span> err
}
</code></pre>
<p>이제 Page 의 경우 기본적인 Interface 는 완성되었다. 그렇다면 저장소의 메타데이터인 <code>Header</code> 는 어떤 정보가 필요할까? Header 의 경우 이제는 읽어올때 Page 를 읽어오는 옵션이 생겼으므로<code>HeadPage</code>, <code>TailPage</code> 정보가 추가로 필요할 것이다.</p>
<h3 id="heading-header">Header 구현</h3>
<pre><code class="lang-go"><span class="hljs-keyword">type</span> Header <span class="hljs-keyword">struct</span> {
    Magic     [<span class="hljs-number">4</span>]<span class="hljs-keyword">byte</span> <span class="hljs-comment">// Magic: 포맷 식별자 [4]byte{'L', 'L', 'S', 'T'}</span>
    Version   <span class="hljs-keyword">uint16</span>
    PageSize  <span class="hljs-keyword">uint16</span>
    PageCount <span class="hljs-keyword">uint32</span>
    HeadPage  <span class="hljs-keyword">uint32</span>
    HeadSlot  <span class="hljs-keyword">uint16</span>
    TailPage  <span class="hljs-keyword">uint32</span>
    TailSlot  <span class="hljs-keyword">uint16</span>
    Size      <span class="hljs-keyword">uint64</span>
}
</code></pre>
<p>우리가 파일을 읽고 쓸때 항상 첫번째 부분은 <code>Header</code> 이므로 <code>Header</code> 에 수정된 부분을 계속해서 업데이트 해주면 된다. Header 를 읽고 쓰는 부분 또한 코드로 적어보자.</p>
<pre><code class="lang-go"><span class="hljs-function"><span class="hljs-keyword">func</span> <span class="hljs-title">writeHeader</span><span class="hljs-params">(f *os.File, h *Header)</span> <span class="hljs-title">error</span></span> {
    <span class="hljs-keyword">if</span> _, err := f.Seek(<span class="hljs-number">0</span>, io.SeekStart); err != <span class="hljs-literal">nil</span> {
        <span class="hljs-keyword">return</span> err
    }

    buf := <span class="hljs-built_in">make</span>([]<span class="hljs-keyword">byte</span>, <span class="hljs-number">0</span>, HEADER_SIZE)
    buf = <span class="hljs-built_in">append</span>(buf, h.Magic[:]...)
    buf = Endian.AppendUint16(buf, h.Version)
    buf = Endian.AppendUint16(buf, h.PageSize)
    buf = Endian.AppendUint32(buf, h.PageCount)
    buf = Endian.AppendUint32(buf, h.HeadPage)
    buf = Endian.AppendUint16(buf, h.HeadSlot)
    buf = Endian.AppendUint32(buf, h.TailPage)
    buf = Endian.AppendUint16(buf, h.TailSlot)
    buf = Endian.AppendUint64(buf, h.Size)

    <span class="hljs-keyword">if</span> _, err := f.Write(buf); err != <span class="hljs-literal">nil</span> {
        <span class="hljs-keyword">return</span> err
    }

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

<span class="hljs-function"><span class="hljs-keyword">func</span> <span class="hljs-title">readHeader</span><span class="hljs-params">(f *os.File, h *Header)</span> <span class="hljs-title">error</span></span> {
    <span class="hljs-keyword">if</span> _, err := f.Seek(<span class="hljs-number">0</span>, io.SeekStart); err != <span class="hljs-literal">nil</span> {
        <span class="hljs-keyword">return</span> err
    }

    buf := <span class="hljs-built_in">make</span>([]<span class="hljs-keyword">byte</span>, HEADER_SIZE)
    <span class="hljs-keyword">if</span> _, err := io.ReadFull(f, buf); err != <span class="hljs-literal">nil</span> {
        <span class="hljs-keyword">return</span> err
    }

    <span class="hljs-built_in">copy</span>(h.Magic[:], buf[<span class="hljs-number">0</span>:<span class="hljs-number">4</span>])

    <span class="hljs-comment">// Magic 검증</span>
    <span class="hljs-keyword">if</span> h.Magic != Magic {
        <span class="hljs-keyword">return</span> ErrInvalidMagic
    }

    h.Version = Endian.Uint16(buf[<span class="hljs-number">4</span>:<span class="hljs-number">6</span>])
    h.PageSize = Endian.Uint16(buf[<span class="hljs-number">6</span>:<span class="hljs-number">8</span>])
    h.PageCount = Endian.Uint32(buf[<span class="hljs-number">8</span>:<span class="hljs-number">12</span>])
    h.HeadPage = Endian.Uint32(buf[<span class="hljs-number">12</span>:<span class="hljs-number">16</span>])
    h.HeadSlot = Endian.Uint16(buf[<span class="hljs-number">16</span>:<span class="hljs-number">18</span>])
    h.TailPage = Endian.Uint32(buf[<span class="hljs-number">18</span>:<span class="hljs-number">22</span>])
    h.TailSlot = Endian.Uint16(buf[<span class="hljs-number">22</span>:<span class="hljs-number">24</span>])
    h.Size = Endian.Uint64(buf[<span class="hljs-number">24</span>:<span class="hljs-number">32</span>])

    <span class="hljs-keyword">return</span> <span class="hljs-literal">nil</span>
}
</code></pre>
<p>딱히 어려운 부분은 없고, 계속해서 사이즈 만큼의 <code>buffer</code> 를 만들고 파일에 적거나 읽는다. 이 부분만 알아두면 된다. 그렇다면 PagedLinkedList 도 저번시간에 만든 LinkedList 와 같이 기본적인 Interface 를 한번 만들어보자.</p>
<h3 id="heading-interface">저장소 Interface 구현</h3>
<pre><code class="lang-go"><span class="hljs-keyword">type</span> LinkedListStore <span class="hljs-keyword">interface</span> {
    Open(path <span class="hljs-keyword">string</span>, truncate <span class="hljs-keyword">bool</span>) (*Handle, error)
    AppendTail(h *Handle, value <span class="hljs-keyword">uint32</span>) error
    DeleteFirstByValue(h *Handle, value <span class="hljs-keyword">uint32</span>) (<span class="hljs-keyword">bool</span>, error)
    TraverseValues(h *Handle) ([]<span class="hljs-keyword">uint32</span>, error)
    TraverseValuesPhysical(h *Handle) ([]<span class="hljs-keyword">uint32</span>, error)
    Where(h *Handle, target <span class="hljs-keyword">uint32</span>) (*Location, error)
    Close(h *Handle) error
}
</code></pre>
<p>저번 챕터와 메소드는 거의 동일한데 <code>TraverseValuesPhysical</code> 가 추가되었다. <code>TraverseValues</code> 는 Page 를 메모리의 Buffer 로 읽어와 I/O 를 줄이는 버전이고, <code>TraverseValuesPhysical</code> 은 예전처럼 Node 의 Next 를 통해 전체 Node 를 I/O 로 순회하는 버전이다. 차이를 비교하기 위해 만들어 두었다.</p>
<p>읽는 것 보다 쓰는 것을 먼져 생각하면 조금 구조가 쉬우므로 쓸때 어떤 사항을 고려해서 구현해야 할지 생각해보자. 아마 아래와 같은 알고리즘으로 진행될 것이다.</p>
<p><img src="https://storage.googleapis.com/roach-wiki/images/645f829b-6e7b-471c-9075-eb7f4f292885.webp" alt="image.png" /></p>
<ol>
<li><p>헤더를 읽어 파일에서 <code>Page</code> 위치를 찾는다.</p>
</li>
<li><p>만약 해당 위치에 <code>Page</code> 가 없다면 <code>Page</code> 를 생성하고, 있다면 마지막 <code>Page</code> 정보를 리턴한다. (마지막인 이유는 여유가 있는 페이지는 마지막 페이지이기 때문이다.)</p>
</li>
<li><p>해당 페이지에 <strong>노드를 쓸수 없는 경우 (PageHeader.Length &gt;= SLOT_PER_PAGE</strong>) 에는 새롭게 페이지를 할당하여 리턴한다.</p>
</li>
<li><p>쓸수 있다면 노드를 어느 위치에 써야하는지 알려주고 Page 정보를 수정한뒤 리턴한다.</p>
</li>
<li><p>노드를 써야하는 위치로 Offset 을 이동시킨다.</p>
</li>
<li><p>새롭게 노드를 생성한 뒤 파일의 해당 위치에 노드를 작성한다.</p>
</li>
<li><p>기존 HeaderNode 가 없었다면 새 Node 로 갱신한다.</p>
</li>
<li><p>기존 TailNode 를 읽어와 기존 TailNode 의 Next 를 현재 노드로 갱신한다.</p>
</li>
<li><p>해더의 정보도 갱신한다.</p>
</li>
</ol>
<p>사실 기존 메커니즘과 크게 다르지는 않다. 다만 새롭게 페이지를 할당하고, 노드에 이 정보를 갱신해야 하는 과정들이 추가되었다. 일단 큰 구현에 앞서 몇가지 먼져 짚고 넘어가야 할 부분이 있다. 우리가 <code>Page</code> 의 위치는 어떻게 추론할 수 있을까?</p>
<p>Page 가 연속적으로 쓰인다는 가정하에 <code>Page</code> 의 위치를 나타내는 <code>ID</code> 를 하나 0부터 시작하는 자연수의 <code>sequence</code> 로 부여 하고 페이지사이즈를 곱하면 해당 페이지의 offset 을 알수 있다.</p>
<p>$$PAGE\_OFFSET = HEADER\_SIZE + PAGE\_ID \times PAGE\_SIZE$$</p><p>코드로 구현해보면 아래와 같이 구현될 것이다.</p>
<pre><code class="lang-go"><span class="hljs-comment">// - 헤더 영역(HeaderSize) 이후에 페이지들이 연속적으로 저장된다고 가정</span>
<span class="hljs-comment">// - pageID=0 이면 header 바로 뒤에 오는 첫 페이지</span>
<span class="hljs-function"><span class="hljs-keyword">func</span> <span class="hljs-title">pageOffset</span><span class="hljs-params">(pageID <span class="hljs-keyword">uint32</span>)</span> <span class="hljs-title">int64</span></span> {
    <span class="hljs-keyword">return</span> <span class="hljs-keyword">int64</span>(HEADER_SIZE) + <span class="hljs-keyword">int64</span>(pageID)*PAGE_SIZE
}
</code></pre>
<p>이제 Page 의 위치를 구현하는 부분은 알았으니 Page 안에서 노드의 위치를 구현하는 부분을 계산해보자. Node 또한 Page offset 부터 Page 의 Header 사이즈만큼 이동한 다음 노드의 ID(Slot ID) 에 Node 의 크기를 곱한 부분부터 써주면 된다.</p>
<p>$$NODE\_OFFSET = PAGE\_OFFSET + PAGE\_HEADER\_SIZE + SLOT\_ID \times NODE\_SIZE$$</p><pre><code class="lang-go"><span class="hljs-function"><span class="hljs-keyword">func</span> <span class="hljs-title">writeSlot</span><span class="hljs-params">(f *os.File, pageID <span class="hljs-keyword">uint32</span>, slotID <span class="hljs-keyword">uint16</span>, node Node)</span> <span class="hljs-title">error</span></span> {
    offset := pageOffset(pageID) + PAGE_HEADER_SIZE + SLOT_SIZE*<span class="hljs-keyword">int64</span>(slotID)
    <span class="hljs-keyword">if</span> _, err := f.Seek(offset, io.SeekStart); err != <span class="hljs-literal">nil</span> {
        <span class="hljs-keyword">return</span> err
    }

    buf := <span class="hljs-built_in">make</span>([]<span class="hljs-keyword">byte</span>, SLOT_SIZE)
    Endian.PutUint32(buf[<span class="hljs-number">0</span>:<span class="hljs-number">4</span>], node.Value)
    Endian.PutUint32(buf[<span class="hljs-number">4</span>:<span class="hljs-number">8</span>], node.NextPage)
    Endian.PutUint16(buf[<span class="hljs-number">8</span>:<span class="hljs-number">10</span>], node.NextSlot)
    buf[<span class="hljs-number">10</span>] = node.Tomb
    Endian.PutUint32(buf[<span class="hljs-number">11</span>:<span class="hljs-number">15</span>], node._pad) <span class="hljs-comment">// 의미없는 패딩값 (0 유지)</span>

    _, err := f.Write(buf)
    <span class="hljs-keyword">return</span> err
}
</code></pre>
<p>코드로 구현해보니 그다지 어렵지 않다. 그렇다면 이제 Node 를 쓰는 경우를 직접적으로 구현해보자.</p>
<pre><code class="lang-go"><span class="hljs-comment">// 새 슬롯을 할당하는 함수</span>
<span class="hljs-comment">// - 마지막 페이지가 존재하고 여유 슬롯이 있으면 그 페이지를 사용.</span>
<span class="hljs-comment">// - 마지막 페이지가 가득 찼으면 새 페이지를 생성하고 그 페이지의 0번 슬롯을 사용</span>
<span class="hljs-comment">// - Header 의 PageCount를 증가시킴</span>
<span class="hljs-function"><span class="hljs-keyword">func</span> <span class="hljs-title">allocateSlot</span><span class="hljs-params">(f *os.File, h *Header)</span> <span class="hljs-params">(pageID <span class="hljs-keyword">uint32</span>, slotIndex <span class="hljs-keyword">uint16</span>, err error)</span></span> {
    <span class="hljs-keyword">if</span> h.PageCount == <span class="hljs-number">0</span> {
        pageID = <span class="hljs-number">0</span>
        <span class="hljs-keyword">if</span> err = initEmptyPage(f, pageID); err != <span class="hljs-literal">nil</span> {
            <span class="hljs-keyword">return</span>
        }
        h.PageCount = <span class="hljs-number">1</span>
    } <span class="hljs-keyword">else</span> {
        <span class="hljs-comment">// 이미 페이지가 하나 이상 있으면, "마지막 페이지" 를 우선 사용</span>
        pageID = h.PageCount - <span class="hljs-number">1</span>
    }

    ph, err := readPageHeader(f, pageID)

    <span class="hljs-keyword">if</span> err != <span class="hljs-literal">nil</span> {
        <span class="hljs-keyword">return</span>
    }

    <span class="hljs-keyword">if</span> <span class="hljs-keyword">int</span>(ph.Length) &gt;= SLOTS_PER_PAGE {
        pageID = h.PageCount <span class="hljs-comment">// 새 페이지 번호</span>
        <span class="hljs-keyword">if</span> err = initEmptyPage(f, pageID); err != <span class="hljs-literal">nil</span> {
            <span class="hljs-keyword">return</span>
        }
        h.PageCount++
        ph.Length = <span class="hljs-number">0</span>
    }

    slotIndex = ph.Length
    ph.Length++
    <span class="hljs-keyword">if</span> err = writePageHeader(f, pageID, ph); err != <span class="hljs-literal">nil</span> {
        <span class="hljs-keyword">return</span>
    }
    <span class="hljs-keyword">return</span> pageID, slotIndex, <span class="hljs-literal">nil</span>
}

<span class="hljs-function"><span class="hljs-keyword">func</span> <span class="hljs-params">(s *PagedStore)</span> <span class="hljs-title">AppendTail</span><span class="hljs-params">(handle *Handle, value <span class="hljs-keyword">uint32</span>)</span> <span class="hljs-title">error</span></span> {
    h, err := ensurePagedHeader(handle)
    <span class="hljs-keyword">if</span> err != <span class="hljs-literal">nil</span> {
        <span class="hljs-keyword">return</span> err
    }
    f := handle.File

    pageID, slotIndex, err := allocateSlot(f, h)
    <span class="hljs-keyword">if</span> err != <span class="hljs-literal">nil</span> {
        <span class="hljs-keyword">return</span> err
    }

    slotOffset := pageOffset(pageID) + PAGE_HEADER_SIZE + SLOT_SIZE*<span class="hljs-keyword">int64</span>(slotIndex)
    <span class="hljs-keyword">if</span> _, err := f.Seek(slotOffset, io.SeekStart); err != <span class="hljs-literal">nil</span> {
        <span class="hljs-keyword">return</span> err
    }

    newNode := &amp;Node{
        Value:    value,
        NextPage: NullPage,
        NextSlot: NullSlot,
        Tomb:     <span class="hljs-number">0</span>,
        _pad:     <span class="hljs-number">0</span>,
    }

    <span class="hljs-keyword">if</span> err := writeSlot(f, pageID, slotIndex, *newNode); err != <span class="hljs-literal">nil</span> {
        <span class="hljs-keyword">return</span> err
    }

    <span class="hljs-keyword">if</span> h.HeadPage == NullPage {
        h.HeadPage = pageID
        h.HeadSlot = slotIndex
        h.TailPage = pageID
        h.TailSlot = slotIndex
        h.Size++
        <span class="hljs-keyword">return</span> writeHeader(f, h)
    }

    tailNode, err := readSlot(f, h.TailPage, h.TailSlot)

    <span class="hljs-keyword">if</span> err != <span class="hljs-literal">nil</span> {
        <span class="hljs-keyword">return</span> err
    }

    tailNode.NextPage = pageID
    tailNode.NextSlot = slotIndex
    <span class="hljs-keyword">if</span> err := writeSlot(f, h.TailPage, h.TailSlot, tailNode); err != <span class="hljs-literal">nil</span> {
        <span class="hljs-keyword">return</span> err
    }

    h.TailPage = pageID
    h.TailSlot = slotIndex
    h.Size++
    <span class="hljs-keyword">return</span> writeHeader(f, h)
}
</code></pre>
<p>위에서 설명한대로 페이지의 유/무에 따라 페이지를 생성하고 노드의 위치를 계산하는 <code>allocateSlot</code> 함수와 해당 offset 에 따라 노드를 작성하고 Header 를 갱신하는 부분을 작성해주면 된다.</p>
<h3 id="heading-7j296riw">읽기</h3>
<p>이제 쓰기 부분은 마무리 되었고 읽기 부분을 작성해보자. 읽기 부분은 기존과 조금 다른게 Page 단위로 읽어와 이걸 메모리에 올려 Slot 은 메모리에서 순회해야 한다. 따라서 Page 의 정보를 담을 Buffer 가 필요하므로 <code>PageBuffer</code> 라는 구조체를 통해 Page 의 내용을 담도록 하겠다.</p>
<pre><code class="lang-go"><span class="hljs-keyword">type</span> PageBuffer <span class="hljs-keyword">struct</span> {
    pageID <span class="hljs-keyword">uint32</span> <span class="hljs-comment">// 현재 버퍼가 담고 있는 페이지 ID</span>
    data   []<span class="hljs-keyword">byte</span> <span class="hljs-comment">// len == PAGE_SIZE</span>
    valid  <span class="hljs-keyword">bool</span>   <span class="hljs-comment">// 아직 안 채워졌는지 여부</span>
}
</code></pre>
<p>읽을때는 Page 를 읽어와서 Buffer 에 담고 첫번째 노드를 읽어온 후에 첫번째 노드 값의 NextSlot 정보를 통해 순회를 진행해주면 된다. 따라서 첫번째 노드를 읽어오는 메소드가 하나 필요하다.</p>
<pre><code class="lang-go"><span class="hljs-function"><span class="hljs-keyword">func</span> <span class="hljs-title">readSlotWithBuffer</span><span class="hljs-params">(f *os.File, pb *PageBuffer, pageID <span class="hljs-keyword">uint32</span>, slotID <span class="hljs-keyword">uint16</span>)</span> <span class="hljs-params">(Node, error)</span></span> {
    <span class="hljs-comment">// 1) 버퍼에 원하는 페이지가 없으면 페이지 전체를 한 번 읽어온다.</span>
    <span class="hljs-keyword">if</span> !pb.valid || pb.pageID != pageID {
        <span class="hljs-keyword">if</span> err := pb.loadPage(f, pageID); err != <span class="hljs-literal">nil</span> {
            <span class="hljs-keyword">return</span> Node{}, err
        }
    }

    <span class="hljs-comment">// 2) 페이지 내에서 이 슬롯이 시작하는 오프셋 계산</span>
    <span class="hljs-comment">//    [PageHeader(2바이트)] [Slot0] [Slot1] ...</span>
    start := PAGE_HEADER_SIZE + <span class="hljs-keyword">int64</span>(SLOT_SIZE)*<span class="hljs-keyword">int64</span>(slotID)

    <span class="hljs-comment">// 3) buf[start : start+SLOT_SIZE] 부분만 잘라서 파싱</span>
    slotBytes := pb.data[start : start+SLOT_SIZE]

    <span class="hljs-keyword">var</span> node Node
    node.Value = Endian.Uint32(slotBytes[<span class="hljs-number">0</span>:<span class="hljs-number">4</span>])
    node.NextPage = Endian.Uint32(slotBytes[<span class="hljs-number">4</span>:<span class="hljs-number">8</span>])
    node.NextSlot = Endian.Uint16(slotBytes[<span class="hljs-number">8</span>:<span class="hljs-number">10</span>])
    node.Tomb = slotBytes[<span class="hljs-number">10</span>]
    node._pad = Endian.Uint32(slotBytes[<span class="hljs-number">11</span>:<span class="hljs-number">15</span>])

    <span class="hljs-keyword">return</span> node, <span class="hljs-literal">nil</span>
}
</code></pre>
<p>위와 같이 첫번째 노드를 읽어왔으면 그 정보를 통해 아래와 같이 순회해주면 <code>Traverse</code> 메소드가 손쉽게 완성된다.</p>
<pre><code class="lang-go"><span class="hljs-function"><span class="hljs-keyword">func</span> <span class="hljs-params">(s *PagedStore)</span> <span class="hljs-title">Where</span><span class="hljs-params">(handle *Handle, target <span class="hljs-keyword">uint32</span>)</span> <span class="hljs-params">(*Location, error)</span></span> {
    h, err := ensurePagedHeader(handle)
    <span class="hljs-keyword">if</span> err != <span class="hljs-literal">nil</span> {
        <span class="hljs-keyword">return</span> <span class="hljs-literal">nil</span>, err
    }
    f := handle.File

    page := h.HeadPage
    slot := h.HeadSlot

    <span class="hljs-keyword">var</span> pb PageBuffer

    <span class="hljs-keyword">for</span> page != NullPage &amp;&amp; slot != NullSlot {
        node, err := readSlotWithBuffer(f, &amp;pb, page, slot)
        <span class="hljs-keyword">if</span> err != <span class="hljs-literal">nil</span> {
            <span class="hljs-keyword">return</span> <span class="hljs-literal">nil</span>, err
        }

        <span class="hljs-keyword">if</span> node.Tomb == <span class="hljs-number">0</span> &amp;&amp; node.Value == target {
            <span class="hljs-keyword">return</span> &amp;Location{Page: page, Slot: slot}, <span class="hljs-literal">nil</span>
        }
        page = node.NextPage
        slot = node.NextSlot
    }

    <span class="hljs-keyword">return</span> <span class="hljs-literal">nil</span>, <span class="hljs-literal">nil</span>
}
</code></pre>
<p><code>Where</code> 은 순회하는 부분에서 찾는 값이 있다면 위치를 리턴해주기만 하면된다.</p>
<h2 id="heading-67me6rwq">비교</h2>
<p>이제 기존 메소드와 함께 비교해보자. 약 10000 개의 노드를 순회했을때 발생되는 I/O 횟수를 계측한 것이다. Header 사이즈 등을 고려했을때 우리가 수식으로 계산했던 39와 비슷하게 나오는 것을 확인할 수 있다.</p>
<pre><code class="lang-go">roach@User1:~/btree$ <span class="hljs-keyword">go</span> run chapter02/compare/main.<span class="hljs-keyword">go</span>
List built: Size=<span class="hljs-number">10000</span>, PageCount=<span class="hljs-number">40</span>
Naive traverse length: <span class="hljs-number">10000</span>
Naive I/O: Reads=<span class="hljs-number">10000</span>, Writes=<span class="hljs-number">0</span>, Seeks=<span class="hljs-number">10000</span>
Buffered traverse length: <span class="hljs-number">10000</span>
Buffered I/O: Reads=<span class="hljs-number">40</span>, Writes=<span class="hljs-number">0</span>, Seeks=<span class="hljs-number">40</span>
Buffered I/O Diff: Reads=<span class="hljs-number">-9960</span>, Writes=<span class="hljs-number">0</span>, Seeks=<span class="hljs-number">-9960</span>
</code></pre>
<p>시간 차이는 얼마나 걸릴까? 조금 더 데이터를 늘려 1000000 건으로 테스트 해보자.</p>
<pre><code class="lang-go">roach@User1:~/btree$ <span class="hljs-keyword">go</span> run chapter02/compare/main.<span class="hljs-keyword">go</span>
List built: Size=<span class="hljs-number">1000000</span>, PageCount=<span class="hljs-number">3922</span>
Naive traverse length: <span class="hljs-number">1000000</span>
Naive I/O: Reads=<span class="hljs-number">1000000</span>, Writes=<span class="hljs-number">0</span>, Seeks=<span class="hljs-number">1000000</span>
Naive traverse time: <span class="hljs-number">1.319998672</span>s
Buffered traverse length: <span class="hljs-number">1000000</span>
Buffered I/O: Reads=<span class="hljs-number">3922</span>, Writes=<span class="hljs-number">0</span>, Seeks=<span class="hljs-number">3922</span>
Buffered traverse time: <span class="hljs-number">30.416243</span>ms
Buffered I/O Diff: Reads=<span class="hljs-number">-996078</span>, Writes=<span class="hljs-number">0</span>, Seeks=<span class="hljs-number">-996078</span>
</code></pre>
<p><code>Naive</code> 의 경우 1.31 초 가량이 걸렸는데 Buffered 의 경우 0.03 초 가량밖에 안걸림을 확인할 수 있다. I/O 가 얼마나 비싼작업인지 내심 확인해 볼수 있는 지표이다.</p>
<h2 id="heading-66ei7lmy66mw">마치며</h2>
<p>이번 챕터에서는 Page 와 같은 블록단위로 관리하는 방법을 통해 메모리에 올려 빠르게 읽으며 I/O 를 줄이는 방법을 알아보았다. 다음시간에는 근본적으로 시간 복잡도를 줄이는 BinaryTree 를 이용해서 시간복잡도 까지 줄여보는 작업을 진행해보려고 한다.</p>
]]></content:encoded></item><item><title><![CDATA[LinkedList 로 DB 를 만들어보자]]></title><description><![CDATA[전시간에는 간단한 파일을 다루는 기본기를 통해 파일을 쓰고, Offset 부터 읽는 등을 학습했다. 이번시간에는 LinkedList 자료구조를 통해 unit32 형태의 값을 저장하고 읽어와보자
인터페이스 정의
type Handle struct {
    File   *os.File
    Header HeaderRecord
}

type HeaderRecord interface {
    headerVersion() uint16
}

type ...]]></description><link>https://roach-wiki.com/linkedlist-db</link><guid isPermaLink="true">https://roach-wiki.com/linkedlist-db</guid><dc:creator><![CDATA[roach]]></dc:creator><pubDate>Tue, 23 Dec 2025 05:49:06 GMT</pubDate><content:encoded><![CDATA[<p>전시간에는 간단한 파일을 다루는 기본기를 통해 파일을 쓰고, Offset 부터 읽는 등을 학습했다. 이번시간에는 LinkedList 자료구조를 통해 <code>unit32</code> 형태의 값을 저장하고 읽어와보자</p>
<h2 id="heading-7j247ysw7y6y7j207iqkioygleydma">인터페이스 정의</h2>
<pre><code class="lang-go"><span class="hljs-keyword">type</span> Handle <span class="hljs-keyword">struct</span> {
    File   *os.File
    Header HeaderRecord
}

<span class="hljs-keyword">type</span> HeaderRecord <span class="hljs-keyword">interface</span> {
    headerVersion() <span class="hljs-keyword">uint16</span>
}

<span class="hljs-keyword">type</span> LinkedListStore <span class="hljs-keyword">interface</span> {
    Open(path <span class="hljs-keyword">string</span>, truncate <span class="hljs-keyword">bool</span>) (*Handle, error)
    AppendTail(h *Handle, value <span class="hljs-keyword">uint32</span>) error
    DeleteFirstByValue(h *Handle, value <span class="hljs-keyword">uint32</span>) (<span class="hljs-keyword">bool</span>, error)
    TraverseValues(h *Handle) ([]<span class="hljs-keyword">uint32</span>, error)
    Close(h *Handle) error
}
</code></pre>
<p>일단 작업하기에 앞서 필수적인 Interface 들 부터 정의하고 작업을 시작하자. 우리는 <code>LinkedListStore</code> 라는 아래와 같은 연산을 지원하는 저장소를 만들것이다.</p>
<ul>
<li><p><code>AppendTail</code>: 제일 마지막에 value 를 저장하는 연산과</p>
</li>
<li><p><code>DeleteFirstByValue</code>: 첫번째 값을 삭제하는 연산(중복값 저장 가능시)</p>
</li>
<li><p><code>TraverseValues</code>: 그리고 값을 순회하는 연산을 지원한다.</p>
</li>
</ul>
<p><code>Handle</code> 구조체는 파일을 관리하는 역할을 하며 우리는 앞으로 이 저장소의 Header 를 버저닝하여 업그레이드 할 것이기 때문에 <code>headerVersion()</code> 이라는 함수도 하나 추가해준다.</p>
<p>참고로 지난 시간과 마찬가지로 <code>Endian</code> 은 <code>BigEndian</code> 을 의미한다.</p>
<h2 id="heading-header">Header 정의</h2>
<pre><code class="lang-go"><span class="hljs-keyword">type</span> Header <span class="hljs-keyword">struct</span> {
    Magic      [<span class="hljs-number">4</span>]<span class="hljs-keyword">byte</span> <span class="hljs-comment">// Magic: 포맷 식별자 [4]byte{'L', 'L', 'S', 'T'}</span>
    Version    <span class="hljs-keyword">uint16</span> <span class="hljs-comment">// Version: 버전</span>
    PageSize   <span class="hljs-keyword">uint16</span> <span class="hljs-comment">// PageSize: 추후에 페이지네이션으로 업그레이드 할때 이용</span>
    HeadOffset <span class="hljs-keyword">int64</span> <span class="hljs-comment">// HeadOffset: 첫 노드의 파일 오프셋(없으면 -1)</span>
    TailOffset <span class="hljs-keyword">int64</span> <span class="hljs-comment">// TailOffset: 마지막 노드의 파일 오프셋(없으면 -1)</span>
    Size       <span class="hljs-keyword">int64</span> <span class="hljs-comment">// Size: 통계 / 검증 용도</span>
}
</code></pre>
<p><code>Header</code> 는 우리가 저장한 시스템이 어떻게 이루어져있는지 나타낸다. <code>Version</code>, <code>PageSize</code> 는 지금 챕터에서는 중요하지 않기 때문에 현재 Chapter 에서 중요한 <code>HeadOffset</code>, <code>TailOffset</code> 을 보자.</p>
<p><img src="https://storage.googleapis.com/roach-wiki/images/012ec6ac-7ee7-47da-a91c-0645273d1f7c.webp" alt="image.png" /></p>
<p>우리가 <code>LinkedList</code> 를 구현할때는 위와 같이 <code>Head</code> 에 대한 정보가 필요하다. 어디서 부터 우리가 순회를 시작해야 할지 알아야 하기 때문에 <code>Head</code> 정보를 알아야 한다. 마찬가지로 우리가 <code>AppendTail</code> 같은 함수를 구현할때는 <code>Head</code> 에서 부터 시작해서 <code>Tail</code> 을 찾는 것보다, <code>Tail</code> 을 항상 관리해서 <code>AppendTail</code> 이 <code>O(1)</code> 시간안에 이뤄지게 하는 것이 낫다.</p>
<h2 id="heading-node">Node 정의</h2>
<pre><code class="lang-go"><span class="hljs-keyword">const</span> nodePadBytes = <span class="hljs-number">3</span>

<span class="hljs-keyword">type</span> Node <span class="hljs-keyword">struct</span> {
    Value <span class="hljs-keyword">uint32</span>             <span class="hljs-comment">// - Value: 실제 값(32비트 정수; 예제 단순화를 위해 uint32 이용)</span>
    Next  <span class="hljs-keyword">int64</span>              <span class="hljs-comment">// - Next: 다음 노드의 파일 오프셋 (없으면 -1)</span>
    Tomb  <span class="hljs-keyword">uint8</span>              <span class="hljs-comment">// - Tomb: 논리 삭제 마크 (0 == 유효, 1 == 삭제됨). 물리 삭제는 하지 않음</span>
    _pad  [nodePadBytes]<span class="hljs-keyword">byte</span> <span class="hljs-comment">// - _pad: 16 바이트 정렬을 위해 3바이트 패딩 (읽기 쉬운 고정 길이 유지)</span>
}
</code></pre>
<p>Node 정의는 간단하다. <strong>Value</strong> 는 우리가 저장할 값인 <code>unit32</code> 를 정의하고 <code>Next</code> 는 다음 노드의 파일 오프셋이 저장될 것이므로 <code>int64</code> 를, <strong>Tomb</strong> 는 논리 삭제 마크(is_deleted) 로 0 과 1 을 저장한다. <code>_pad</code> 는 Node 를 깔끔하게 16바이트로 저장하기 위해 3byte padding 을 일부러 주었다.</p>
<h2 id="heading-66mu7iam65ocioq1ro2yha">메소드 구현</h2>
<h3 id="heading-header-1">Header 쓰기</h3>
<p>이제 <code>Header</code> 를 쓰는 함수를 먼져 구현해보자. 저번 <code>offset</code> 시간에도 배웠듯이 무언가를 쓰는 경우 해당 값을 쓸만큼의 buffer 를 먼져 생성해주어야 한다. Header 의 경우 <code>4byte+2byte+2byte+8byte+8byte+8byte</code> 로 <strong>총 32 바이트</strong>가 필요하다.</p>
<pre><code class="lang-go">buf := <span class="hljs-built_in">make</span>([]<span class="hljs-keyword">byte</span>, <span class="hljs-number">0</span>, <span class="hljs-number">32</span>)
</code></pre>
<p>이제 이 <code>buffer</code> 에 쓰는 작업은 아주 쉽다.</p>
<pre><code class="lang-go"><span class="hljs-function"><span class="hljs-keyword">func</span> <span class="hljs-title">writeHeader</span><span class="hljs-params">(f *os.File, hdr *Header)</span> <span class="hljs-title">error</span></span> {
    <span class="hljs-keyword">if</span> _, err := f.Seek(<span class="hljs-number">0</span>, io.SeekStart); err != <span class="hljs-literal">nil</span> {
        <span class="hljs-keyword">return</span> err
    }

    buf := <span class="hljs-built_in">make</span>([]<span class="hljs-keyword">byte</span>, <span class="hljs-number">0</span>, <span class="hljs-number">32</span>)
    buf = <span class="hljs-built_in">append</span>(buf, hdr.Magic[:]...)
    buf = Endian.AppendUint16(buf, hdr.Version)
    buf = Endian.AppendUint16(buf, hdr.PageSize)
    buf = Endian.AppendUint64(buf, <span class="hljs-keyword">uint64</span>(hdr.HeadOffset))
    buf = Endian.AppendUint64(buf, <span class="hljs-keyword">uint64</span>(hdr.TailOffset))
    buf = Endian.AppendUint64(buf, <span class="hljs-keyword">uint64</span>(hdr.Size))

    _, err := f.Write(buf)
    <span class="hljs-keyword">return</span> err
}
</code></pre>
<p><code>buffer</code> 를 생성하고 <code>BigEndian</code> 의 함수를 이용하여 각 byte 크기에 맞게 byte 배열의 마지막에 값을 추가한뒤에 <strong>buffer 의 값을 파일에 쓴다</strong>.</p>
<h3 id="heading-header-2">Header 읽기</h3>
<pre><code class="lang-go"><span class="hljs-function"><span class="hljs-keyword">func</span> <span class="hljs-title">readHeader</span><span class="hljs-params">(f *os.File, h *Header)</span> <span class="hljs-title">error</span></span> {
    <span class="hljs-keyword">if</span> _, err := f.Seek(<span class="hljs-number">0</span>, io.SeekStart); err != <span class="hljs-literal">nil</span> {
        <span class="hljs-keyword">return</span> err
    }

    buf := <span class="hljs-built_in">make</span>([]<span class="hljs-keyword">byte</span>, <span class="hljs-number">4</span>+<span class="hljs-number">2</span>+<span class="hljs-number">2</span>+<span class="hljs-number">8</span>+<span class="hljs-number">8</span>+<span class="hljs-number">8</span>)

    <span class="hljs-keyword">if</span> _, err := io.ReadFull(f, buf); err != <span class="hljs-literal">nil</span> {
        <span class="hljs-keyword">return</span> err
    }

    <span class="hljs-built_in">copy</span>(h.Magic[:], buf[<span class="hljs-number">0</span>:<span class="hljs-number">4</span>])

    <span class="hljs-comment">// Magic 검증</span>
    <span class="hljs-keyword">if</span> h.Magic != Magic {
        <span class="hljs-keyword">return</span> ErrInvalidMagic
    }

    h.Version = Endian.Uint16(buf[<span class="hljs-number">4</span>:<span class="hljs-number">6</span>])
    h.PageSize = Endian.Uint16(buf[<span class="hljs-number">6</span>:<span class="hljs-number">8</span>])
    h.HeadOffset = <span class="hljs-keyword">int64</span>(Endian.Uint64(buf[<span class="hljs-number">8</span>:<span class="hljs-number">16</span>]))
    h.TailOffset = <span class="hljs-keyword">int64</span>(Endian.Uint64(buf[<span class="hljs-number">16</span>:<span class="hljs-number">24</span>]))
    h.Size = <span class="hljs-keyword">int64</span>(Endian.Uint64(buf[<span class="hljs-number">24</span>:<span class="hljs-number">32</span>]))

    <span class="hljs-keyword">return</span> <span class="hljs-literal">nil</span>
}
</code></pre>
<p>Header 를 읽는 과정 또한 쉽다. 우리는 항상 파일의 가장 앞부분에 Header 를 쓸것이기 때문에, <code>Header</code> 크기만큼(32) 읽어온다. 읽어온 뒤에 <code>Uint16</code> 과 같은 함수로 <strong>buffer 에서 각 값이 가진 크기에 맞게 byte 배열을 추출하여 원하는 type 으로 변환</strong>한다.</p>
<blockquote>
<p>이 구현부에서 Header 를 내부에서 생성해서 Return 하는 과정도 좋았을것 같다라는 생각이 들었다.</p>
</blockquote>
<h3 id="heading-7kca7j6l7iamioyxtoq4sa">저장소 열기</h3>
<p>이제 Interface 의 함수 중 하나인 <code>Open</code> 을 구현해보자. 우리가 저장소를 열때 원하는 결과는 무엇일까? 바로 우리가 탐색 또는 저장을 위해 필요한 Header 정보를 얻어오는 것이다. 따라서 <code>Handle</code> 의 정보를 얻어와야 할것이므로 아래와 같은 함수일 것이다.</p>
<pre><code class="lang-go">Open(path <span class="hljs-keyword">string</span>, truncate <span class="hljs-keyword">bool</span>) (*Handle, error) <span class="hljs-comment">// truncate 는 테스트 용으로 시작시 파일을 지우는 용도이다.</span>
</code></pre>
<p>그렇다면 이 Open 또한 어렵지 않게 구현해 볼 수 있다.</p>
<pre><code class="lang-go"><span class="hljs-function"><span class="hljs-keyword">func</span> <span class="hljs-params">(s *OffsetStore)</span> <span class="hljs-title">Open</span><span class="hljs-params">(path <span class="hljs-keyword">string</span>, truncate <span class="hljs-keyword">bool</span>)</span> <span class="hljs-params">(*Handle, error)</span></span> {
    flags := os.O_RDWR | os.O_CREATE
    <span class="hljs-keyword">if</span> truncate {
        flags |= os.O_TRUNC
    }
    f, err := os.OpenFile(path, flags, <span class="hljs-number">0666</span>)
    <span class="hljs-keyword">if</span> err != <span class="hljs-literal">nil</span> {
        <span class="hljs-keyword">return</span> <span class="hljs-literal">nil</span>, err
    }

    info, err := f.Stat()
    <span class="hljs-keyword">if</span> err != <span class="hljs-literal">nil</span> {
        f.Close()
        <span class="hljs-keyword">return</span> <span class="hljs-literal">nil</span>, err
    }

    <span class="hljs-keyword">if</span> info.Size() == <span class="hljs-number">0</span> || truncate {
        hdr := &amp;Header{
            Magic:      Magic,
            Version:    <span class="hljs-number">1</span>,
            PageSize:   DefaultPageSize,
            HeadOffset: NullOffset,
            TailOffset: NullOffset,
            Size:       <span class="hljs-number">0</span>,
        }
        <span class="hljs-keyword">if</span> err := writeHeader(f, hdr); err != <span class="hljs-literal">nil</span> {
            f.Close()
            <span class="hljs-keyword">return</span> <span class="hljs-literal">nil</span>, err
        }
    }

    hrd := &amp;Header{}

    <span class="hljs-keyword">if</span> err := readHeader(f, hrd); err != <span class="hljs-literal">nil</span> {
        f.Close()
        <span class="hljs-keyword">return</span> <span class="hljs-literal">nil</span>, err
    }

    <span class="hljs-keyword">return</span> &amp;Handle{
        File:   f,
        Header: hrd,
    }, <span class="hljs-literal">nil</span>
}
</code></pre>
<p>코드가 복잡해보일수 있는데 생각보다 간단하다. 첫번째 부분은 <strong>"파일을 열거나 생성 또는 Truncate"</strong> 시키는 부분이다. 그리고 두번째 부분은 만약 <strong>"파일을 열었는데 빈 파일이거나, truncate 플래그라면"</strong> 우리가 헤더 정보를 넣어야 하므로 <strong>Header 를 생성</strong>한다. Header 를 생성했다면 기존에 만든 <code>writeHeader</code> 를 통해 파일에 써주면 된다.</p>
<p>만약 이미 값이 있는 데이터베이스라면 위와 같은 행위를 하지 않아도 되므로 <code>readHeader</code> 함수를 통해 Header 를 읽어온다. 이 함수를 통해서 Header 가 없는 경우에는 써주고, 있는 경우에는 읽어와서 <strong>Handle 구조체에 Header 와 file 정보를 넣어 리턴</strong>해준다.</p>
<p>여기까지는 offset 에 익숙하다면 쉬울 것이다. 만약 익숙하지 않다면 <code>offset</code> 을 읽고 조금 더 이해해보길 바란다. 이제 <code>Node</code> 도 저장하고 읽어와 보자.</p>
<h3 id="heading-node-1">Node 쓰고 읽기</h3>
<pre><code class="lang-go"><span class="hljs-keyword">const</span> nodeOnDiskSize = <span class="hljs-number">4</span> + <span class="hljs-number">8</span> + <span class="hljs-number">1</span> + nodePadBytes

<span class="hljs-function"><span class="hljs-keyword">func</span> <span class="hljs-title">writeNodeAt</span><span class="hljs-params">(f *os.File, off <span class="hljs-keyword">int64</span>, n *Node)</span> <span class="hljs-title">error</span></span> {
    <span class="hljs-keyword">if</span> _, err := f.Seek(off, io.SeekStart); err != <span class="hljs-literal">nil</span> {
        <span class="hljs-keyword">return</span> err
    }

    buf := <span class="hljs-built_in">make</span>([]<span class="hljs-keyword">byte</span>, nodeOnDiskSize)

    Endian.PutUint32(buf[<span class="hljs-number">0</span>:<span class="hljs-number">4</span>], <span class="hljs-keyword">uint32</span>(n.Value))
    Endian.PutUint64(buf[<span class="hljs-number">4</span>:<span class="hljs-number">12</span>], <span class="hljs-keyword">uint64</span>(n.Next))
    buf[<span class="hljs-number">12</span>] = <span class="hljs-keyword">byte</span>(n.Tomb)

    <span class="hljs-keyword">if</span> _, err := f.Write(buf); err != <span class="hljs-literal">nil</span> {
        <span class="hljs-keyword">return</span> err
    }

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

<span class="hljs-function"><span class="hljs-keyword">func</span> <span class="hljs-title">readNodeAt</span><span class="hljs-params">(f *os.File, off <span class="hljs-keyword">int64</span>)</span> <span class="hljs-params">(*Node, error)</span></span> {
    <span class="hljs-keyword">if</span> _, err := f.Seek(off, io.SeekStart); err != <span class="hljs-literal">nil</span> {
        <span class="hljs-keyword">return</span> <span class="hljs-literal">nil</span>, err
    }

    buf := <span class="hljs-built_in">make</span>([]<span class="hljs-keyword">byte</span>, nodeOnDiskSize)

    <span class="hljs-keyword">if</span> _, err := io.ReadFull(f, buf); err != <span class="hljs-literal">nil</span> {
        <span class="hljs-keyword">return</span> <span class="hljs-literal">nil</span>, err
    }

    n := &amp;Node{
        Value: Endian.Uint32(buf[<span class="hljs-number">0</span>:<span class="hljs-number">4</span>]),
        Next:  <span class="hljs-keyword">int64</span>(Endian.Uint64(buf[<span class="hljs-number">4</span>:<span class="hljs-number">12</span>])),
        Tomb:  buf[<span class="hljs-number">12</span>],
    }

    <span class="hljs-keyword">return</span> n, <span class="hljs-literal">nil</span>
}
</code></pre>
<p>이제 byte 로 읽고 쓰는 부분은 익숙할테니 한번에 적도록 하겠다. <code>Node</code> 도 마찬가지로 Node 의 크기만큼 buffer 를 생성하고 읽거나 쓴다. 간단하게 설명하면 <code>writeNodeAt</code> 함수는 원하는 위치(offset) 에 Node 를 기록한다. <code>readNodeAt</code> 은 해당 offset 만큼 이동한뒤에 해당 offset 에서부터 <code>nodeSize</code> 만큼 읽어서 buffer 에 저장한다.</p>
<p>아마 익숙할거라 이 부분에 대해서는 더 설명하지는 않겠다. 이제 <code>Node</code> 와 <code>Header</code> 를 읽고 쓰는 부분은 마무리 됬으니 어떻게 Node 를 LinkedList 끝에 옮기는 연산을 계속할지 생각해보자.</p>
<h2 id="heading-appendtail">AppendTail</h2>
<p><code>AppendTail</code> 을 생각해보면 아래와 같은 알고리즘으로 구현될 것이다.</p>
<ol>
<li><p>일단 첫번째로 <strong>파일의 처음 부분에서</strong> <code>Header</code> 를 읽어온다.</p>
</li>
<li><p>새롭게 <code>value</code> 를 담은 Node 를 생성한다.</p>
</li>
<li><p>파일의 끝 <strong>Offset(io.SeekEnd)</strong> 에 Node 를 기록한다.</p>
</li>
<li><p>기존 Tail Node 를 읽기 위해 Header 에 기록된 <strong>TailOffset 으로 이동해서 Tail Node 를 읽어온다</strong>.</p>
</li>
<li><p>기존 TailNode 의 Next 로 새롭게 생성된(newNode) 가리킨다.</p>
</li>
<li><p>기존 Header 의 TailOffset 을 새롭게 생성된 노드의 오프셋으로 바꾼다.</p>
</li>
</ol>
<ul>
<li>만약 과정 도중 Header 가 없는 경우 새롭게 노드를쓴다!</li>
</ul>
<p>이정도 알고리즘으로 진행이 될 것이다. 코드로 작성해보면 아주 쉽게 확인해 볼 수 있다.</p>
<pre><code class="lang-go"><span class="hljs-function"><span class="hljs-keyword">func</span> <span class="hljs-params">(s *OffsetStore)</span> <span class="hljs-title">AppendTail</span><span class="hljs-params">(handle *Handle, value <span class="hljs-keyword">uint32</span>)</span> <span class="hljs-title">error</span></span> {
    h, err := ensureOffsetHeader(handle)
    <span class="hljs-keyword">if</span> err != <span class="hljs-literal">nil</span> {
        <span class="hljs-keyword">return</span> err
    }
    f := handle.File

    newNode := &amp;Node{
        Value: value,
        Next:  NullOffset,
        Tomb:  <span class="hljs-number">0</span>,
    }

    newOff, err := f.Seek(<span class="hljs-number">0</span>, io.SeekEnd)
    <span class="hljs-keyword">if</span> err != <span class="hljs-literal">nil</span> {
        <span class="hljs-keyword">return</span> err
    }

    <span class="hljs-keyword">if</span> err := writeNodeAt(f, newOff, newNode); err != <span class="hljs-literal">nil</span> {
        <span class="hljs-keyword">return</span> err
    }

    <span class="hljs-keyword">if</span> h.HeadOffset == NullOffset {
        h.HeadOffset = newOff
        h.TailOffset = newOff
        h.Size++
        <span class="hljs-keyword">return</span> writeHeader(f, h)
    }

    <span class="hljs-comment">// 기존 tail 노드의 Next 를 새 노드의 Next 로 설정</span>
    tailNode, err := readNodeAt(f, h.TailOffset)
    <span class="hljs-keyword">if</span> err != <span class="hljs-literal">nil</span> {
        <span class="hljs-keyword">return</span> err
    }

    tailNode.Next = newOff
    <span class="hljs-keyword">if</span> err := writeNodeAt(f, h.TailOffset, tailNode); err != <span class="hljs-literal">nil</span> {
        <span class="hljs-keyword">return</span> err
    }

    h.TailOffset = newOff
    h.Size++

    <span class="hljs-keyword">return</span> writeHeader(f, h)
}
</code></pre>
<p>아마 알고리즘을 먼져익히고 코드를 보면 이해가 쉽게 갈 것이다. 그렇다면 탐색은 어떨까? 탐색도 동일하다.</p>
<pre><code class="lang-go"><span class="hljs-function"><span class="hljs-keyword">func</span> <span class="hljs-params">(s *OffsetStore)</span> <span class="hljs-title">TraverseValues</span><span class="hljs-params">(handle *Handle)</span> <span class="hljs-params">([]<span class="hljs-keyword">uint32</span>, error)</span></span> {
    h, err := ensureOffsetHeader(handle)
    <span class="hljs-keyword">if</span> err != <span class="hljs-literal">nil</span> {
        <span class="hljs-keyword">return</span> <span class="hljs-literal">nil</span>, err
    }
    f := handle.File

    out := <span class="hljs-built_in">make</span>([]<span class="hljs-keyword">uint32</span>, <span class="hljs-number">0</span>, h.Size)
    off := h.HeadOffset

    <span class="hljs-keyword">for</span> off != NullOffset {
        node, err := readNodeAt(f, off)
        <span class="hljs-keyword">if</span> err != <span class="hljs-literal">nil</span> {
            <span class="hljs-keyword">return</span> <span class="hljs-literal">nil</span>, err
        }
        <span class="hljs-keyword">if</span> node.Tomb == <span class="hljs-number">0</span> {
            out = <span class="hljs-built_in">append</span>(out, node.Value)
        }
        off = node.Next
    }
    <span class="hljs-keyword">return</span> out, <span class="hljs-literal">nil</span>
}
</code></pre>
<p>파일을 열고 Header 에서 <code>HeadOffset</code> 을 읽어온 뒤에 <code>NullOffset</code> 에 도달할때까지 순회를 계속하며 <code>Next Offset</code> 으로 이동하며 Node 들을 읽어온다. (단. Tomb == 1 죽은 노드들은 제외한다.)</p>
<h2 id="heading-7jes6riw7iscioqwkuydhcdssl7riptri6trqbq">여기서 값을 찾는다면?</h2>
<p>만약이 <code>LinkedListStore</code> 에서 특정 값을 찾는다면 시간복잡도가 얼마나 소비될까? LinkedList 의 복잡도인 <code>O(N)</code> 만큼 소비될 것이다. 왜냐면, Header 로 부터 시작해서 Next 들을 순회하며 마지막 노드까지 도달할 수 있기 때문이다. 코드는 간단하게 아래와 같이 작성해 볼수 있을 것이다.</p>
<pre><code class="lang-go"><span class="hljs-function"><span class="hljs-keyword">func</span> <span class="hljs-params">(s *OffsetStore)</span> <span class="hljs-title">Where</span><span class="hljs-params">(handle *Handle, target <span class="hljs-keyword">uint32</span>)</span> <span class="hljs-params">(<span class="hljs-keyword">int64</span>, error)</span></span> {
    h, err := ensureOffsetHeader(handle)
    <span class="hljs-keyword">if</span> err != <span class="hljs-literal">nil</span> {
        <span class="hljs-keyword">return</span> <span class="hljs-number">0</span>, err
    }
    f := handle.File

    off := h.HeadOffset

    <span class="hljs-keyword">for</span> off != NullOffset {
        node, err := readNodeAt(f, off)
        <span class="hljs-keyword">if</span> err != <span class="hljs-literal">nil</span> {
            <span class="hljs-keyword">return</span> <span class="hljs-number">0</span>, err
        }
        <span class="hljs-keyword">if</span> node.Tomb == <span class="hljs-number">0</span> &amp;&amp; node.Value == target {
            <span class="hljs-keyword">return</span> off, <span class="hljs-literal">nil</span>
        }
        off = node.Next
    }
    <span class="hljs-keyword">return</span> NullOffset, <span class="hljs-literal">nil</span>
}
</code></pre>
<p>여기서 또 하나의 문제가 생긴다. <code>O(N)</code> 의 시간복잡도가 드는것도 문제지만 사실 파일을 읽는 I/O 작업은 상당히 헤비한 작업이다. 이 파일을 읽는 작업또한 오래걸릴 수 있다는 것이다. 여기서 어떻게 조금 더 최적화 해볼수 있을까?</p>
<p>떠오르는 방법으로는 만약 정렬된 순서라면 1~5 까지 노드의 HeadOffset 과 TailOffset 을 저장하고, 5~10, 10~15까지를 메모리에 Key-Value 상태로 인덱싱하고, 해당 Value 는 Offset 이므로 해당 그룹에서만 순회를 진행하는 것이다. 그렇게 되면 전체 크기에서 격자로 나눈 만큼이 M 이라면 O(N/M) 의 평균 시간복잡도로 변할것이고, 파일 I/O 또한 줄게된다.</p>
<p>이러한 방식을 생각하다보면 결국 Node 를 묶어서 관리하고 해당 그룹의 Offset 을 관리해야겠다는 생각이 든다. 따라서 다음 글에서는 이를 <code>Page</code> 라는 단위로 묶고, <code>Node</code> 는 <code>Page</code> 하부의 Slot 으로 관리하여 File I/O 를 줄이고, 메모리에서 순회하여 I/O 자체를 줄여보는 최적화를 진행해보겠다.</p>
]]></content:encoded></item><item><title><![CDATA[Go Interface 파헤치기]]></title><description><![CDATA[들어가며
Go를 쓰다 보면 “인터페이스가 뭔가 다른 언어와 다르게 신기하게 쓰이는 구나” 하는 순간이 종종 있다. 특히 제네릭이 없던 시절의 Go 코드를 보면 아래처럼 interface{}를 마치 만능 컨테이너처럼 쓰는 코드가 흔했다.
var values []interface{}
values = append(values, 10)
values = append(values, "hello")
values = append(values, []int{1,...]]></description><link>https://roach-wiki.com/go-interface</link><guid isPermaLink="true">https://roach-wiki.com/go-interface</guid><dc:creator><![CDATA[roach]]></dc:creator><pubDate>Tue, 23 Dec 2025 05:47:34 GMT</pubDate><content:encoded><![CDATA[<h2 id="heading-65ok7ja06rca66mw">들어가며</h2>
<p>Go를 쓰다 보면 <strong>“인터페이스가 뭔가 다른 언어와 다르게 신기하게 쓰이는 구나”</strong> 하는 순간이 종종 있다. 특히 제네릭이 없던 시절의 Go 코드를 보면 아래처럼 interface{}를 마치 만능 컨테이너처럼 쓰는 코드가 흔했다.</p>
<pre><code class="lang-go"><span class="hljs-keyword">var</span> values []<span class="hljs-keyword">interface</span>{}
values = <span class="hljs-built_in">append</span>(values, <span class="hljs-number">10</span>)
values = <span class="hljs-built_in">append</span>(values, <span class="hljs-string">"hello"</span>)
values = <span class="hljs-built_in">append</span>(values, []<span class="hljs-keyword">int</span>{<span class="hljs-number">1</span>, <span class="hljs-number">2</span>, <span class="hljs-number">3</span>})
</code></pre>
<p>그리고 나중에 이런 식으로 타입에 따라 분기한다.</p>
<pre><code class="lang-go"><span class="hljs-function"><span class="hljs-keyword">func</span> <span class="hljs-title">Print</span><span class="hljs-params">(v <span class="hljs-keyword">interface</span>{})</span></span> {
    <span class="hljs-keyword">switch</span> x := v.(<span class="hljs-keyword">type</span>) {
    <span class="hljs-keyword">case</span> <span class="hljs-keyword">int</span>:
        fmt.Println(<span class="hljs-string">"int:"</span>, x)
    <span class="hljs-keyword">case</span> <span class="hljs-keyword">string</span>:
        fmt.Println(<span class="hljs-string">"string:"</span>, x)
    <span class="hljs-keyword">case</span> []<span class="hljs-keyword">int</span>:
        fmt.Println(<span class="hljs-string">"slice:"</span>, x)
    <span class="hljs-keyword">default</span>:
        fmt.Printf(<span class="hljs-string">"unknown type: %T\n"</span>, x)
    }
}
</code></pre>
<p>정적 타입 언어에서 이렇게 “무한정 아무 타입이나 넣고 꺼내는" 구조가 되는 건 꽤 놀랍다. 이게 어떻게 가능할까? 이 질문의 진짜 핵심은 Go 인터페이스의 내부 구조에 있다.</p>
<h2 id="heading-eface-iface">eface / iface</h2>
<p>Go 언어는 인터페이스를 두 가지 형태로 나누어 구현하고 있다.</p>
<ul>
<li><p><strong>eface</strong>: 빈 인터페이스 (interface{})</p>
</li>
<li><p><strong>iface</strong>: 메서드가 있는 인터페이스</p>
</li>
</ul>
<p>이 두 구조를 알면 Go 인터페이스를 이해하는데 큰 도움이 된다.</p>
<h3 id="heading-eface-interface">eface — 빈 인터페이스 (interface{})</h3>
<p>Go 런타임에 정의된 eface 구조체는 정말 단순하다.</p>
<pre><code class="lang-go"><span class="hljs-keyword">type</span> eface <span class="hljs-keyword">struct</span> {
    typ  *_type         <span class="hljs-comment">// dynamic type metadata</span>
    data unsafe.Pointer <span class="hljs-comment">// pointer to actual data</span>
}
</code></pre>
<p>interface 값은 정적 타입이 interface이지만, 내부에는 <strong>“구체 타입 + 값”</strong>이 살아 있다.</p>
<p>예를 들어:</p>
<pre><code class="lang-go"><span class="hljs-keyword">var</span> x <span class="hljs-keyword">interface</span>{} = <span class="hljs-number">10</span>
</code></pre>
<p>런타임 구조는 다음과 같다:</p>
<pre><code class="lang-go">x = (<span class="hljs-keyword">type</span>=*<span class="hljs-keyword">int</span>, data=&amp;<span class="hljs-number">10</span>)
</code></pre>
<p>이 사실 하나만 이해해도 왜 여러 타입을 한 slice에 넣을 수 있는지 왜 type assertion이 가능한지 왜 JSON 언마샬 결과가 map[string]interface{} 인지 왜 reflect가 interface 기반으로 동작하는지 전부 설명된다.</p>
<h3 id="heading-iface">iface — 메서드가 있는 인터페이스</h3>
<p>예를 들어:</p>
<pre><code class="lang-go"><span class="hljs-keyword">type</span> Reader <span class="hljs-keyword">interface</span> {
    Read([]<span class="hljs-keyword">byte</span>) (<span class="hljs-keyword">int</span>, error)
}
</code></pre>
<p>이런 인터페이스는 메서드 테이블(itab)을 포함하는 iface 구조를 사용한다.</p>
<pre><code class="lang-go"><span class="hljs-keyword">type</span> iface <span class="hljs-keyword">struct</span> {
    tab  *itab
    data unsafe.Pointer
}
</code></pre>
<p>itab 안에는 아래와 같은 정보들이 존재한다.</p>
<ul>
<li><p><strong>인터페이스 정보</strong></p>
</li>
<li><p><strong>구체 타입 정보</strong></p>
</li>
<li><p><strong>인터페이스가 요구하는 메서드들의 함수 포인터(jump table)</strong></p>
</li>
</ul>
<pre><code class="lang-go"><span class="hljs-keyword">var</span> r io.Reader = bytes.NewBuffer([]<span class="hljs-keyword">byte</span>(<span class="hljs-string">"hi"</span>))
r.Read(buf)
</code></pre>
<p>그래서 위와 같은 코드를 실행시켜보면 런타임에서 실제로는</p>
<pre><code class="lang-go">itab.funcs[<span class="hljs-number">0</span>](data, buf)
</code></pre>
<p>이런 식으로 메서드가 호출된다.</p>
<h3 id="heading-nil-interface-trap">nil interface trap</h3>
<p>이제 구조를 봤으니 이 trap도 제대로 이해할 수 있다.</p>
<pre><code class="lang-go"><span class="hljs-keyword">var</span> x <span class="hljs-keyword">interface</span>{} = (*User)(<span class="hljs-literal">nil</span>)

fmt.Println(x == <span class="hljs-literal">nil</span>) <span class="hljs-comment">// false</span>
</code></pre>
<p>왜 false일까? 인터페이스가 “진짜 nil”이 되려면 <code>typ == nil &amp;&amp; data == nil</code> 이 되어야 한다. 그러나 위 코드는 <code>x = (typ = *User, data = nil)</code> 이다. 즉, type 포인터는 살아있고 data는 nil 이기 때문에 interface 전체는 nil이 아니다.</p>
<h3 id="heading-7iuk7kcciouplouqqoumrcdqtazsobdrpbwg7keb7kcrio2zleydua">실제 메모리 구조를 직접 확인</h3>
<pre><code class="lang-go"><span class="hljs-keyword">package</span> main

<span class="hljs-keyword">import</span> (
    <span class="hljs-string">"fmt"</span>
    <span class="hljs-string">"unsafe"</span>
)

<span class="hljs-keyword">type</span> eface <span class="hljs-keyword">struct</span> {
    typ  <span class="hljs-keyword">uintptr</span>
    data <span class="hljs-keyword">uintptr</span>
}

<span class="hljs-function"><span class="hljs-keyword">func</span> <span class="hljs-title">dump</span><span class="hljs-params">(label <span class="hljs-keyword">string</span>, v <span class="hljs-keyword">interface</span>{})</span></span> {
    p := (*eface)(unsafe.Pointer(&amp;v))

    fmt.Printf(<span class="hljs-string">"[%s]\n"</span>, label)
    fmt.Printf(<span class="hljs-string">"  interface address : %p\n"</span>, &amp;v)
    fmt.Printf(<span class="hljs-string">"  type pointer      : 0x%x\n"</span>, p.typ)
    fmt.Printf(<span class="hljs-string">"  data pointer      : 0x%x\n"</span>, p.data)

    <span class="hljs-comment">// data를 실제 타입으로 역참조할 수 있는 경우 출력</span>
    <span class="hljs-keyword">if</span> p.data != <span class="hljs-number">0</span> {
        fmt.Printf(<span class="hljs-string">"  data as int?      : %d\n"</span>, *(*<span class="hljs-keyword">int</span>)(unsafe.Pointer(p.data)))
    }
    fmt.Println()
}

<span class="hljs-function"><span class="hljs-keyword">func</span> <span class="hljs-title">main</span><span class="hljs-params">()</span></span> {
    dump(<span class="hljs-string">"int"</span>, <span class="hljs-number">10</span>)
    dump(<span class="hljs-string">"string"</span>, <span class="hljs-string">"hello"</span>)
    dump(<span class="hljs-string">"slice"</span>, []<span class="hljs-keyword">int</span>{<span class="hljs-number">1</span>, <span class="hljs-number">2</span>, <span class="hljs-number">3</span>})

    <span class="hljs-keyword">var</span> u *<span class="hljs-keyword">int</span> = <span class="hljs-literal">nil</span>
    dump(<span class="hljs-string">"nil pointer in interface"</span>, u)

    <span class="hljs-keyword">var</span> x <span class="hljs-keyword">interface</span>{}
    dump(<span class="hljs-string">"true nil interface"</span>, x)
}
</code></pre>
<h4 id="heading-7iuk7zajioqysoqzva">실행 결과</h4>
<pre><code class="lang-sh">[int]
  interface address : 0x140000a4010
  <span class="hljs-built_in">type</span> pointer      : 0x102a924c0
  data pointer      : 0x102a83538
  data as int?      : 10

[string]
  interface address : 0x140000a4030
  <span class="hljs-built_in">type</span> pointer      : 0x102a92280
  data pointer      : 0x102aad068
  data as int?      : 4339381145

[slice]
  interface address : 0x140000a4050
  <span class="hljs-built_in">type</span> pointer      : 0x102a912e0
  data pointer      : 0x140000aa000
  data as int?      : 1374390214680

[nil pointer <span class="hljs-keyword">in</span> interface]
  interface address : 0x140000a4070
  <span class="hljs-built_in">type</span> pointer      : 0x102a8f260
  data pointer      : 0x0

[<span class="hljs-literal">true</span> nil interface]
  interface address : 0x140000a4090
  <span class="hljs-built_in">type</span> pointer      : 0x0
  data pointer      : 0x0
</code></pre>
<p>nil pointer in interface 의 경우 type pointer 값이 존재하므로 interface 자체는 nil이 아니다. true nil interface 의 경우 type/data 둘 다 nil일 때만 진짜 nil 이다.</p>
<h2 id="heading-interface-pattern">자주보이는 interface pattern들</h2>
<h3 id="heading-interface">여러 타입을 한 컨테이너에 담기 ([]interface{})</h3>
<p><code>eface</code> 구조 덕분에 어떤 타입이든 담을 수 있다.</p>
<pre><code class="lang-go">list := []<span class="hljs-keyword">interface</span>{}{<span class="hljs-number">1</span>, <span class="hljs-string">"hello"</span>, []<span class="hljs-keyword">int</span>{<span class="hljs-number">1</span>,<span class="hljs-number">2</span>,<span class="hljs-number">3</span>}}
</code></pre>
<p>dynamic type이 살아있으니 type switch로 잘 처리된다.</p>
<h3 id="heading-type-switch">type switch로 다형성 처리</h3>
<pre><code class="lang-go"><span class="hljs-keyword">switch</span> v := x.(<span class="hljs-keyword">type</span>) {
<span class="hljs-keyword">case</span> <span class="hljs-keyword">int</span>, <span class="hljs-keyword">string</span>:
    ...
}
</code></pre>
<p>interface 내부 type pointer 비교만 하므로 성능도 꽤 빠른 편이다.</p>
<h3 id="heading-json-yaml-mapstringinterface">JSON / YAML 동적 구조 (map[string]interface{})</h3>
<p>Go의 정적 타입 구조상, JSON 같은 문서 기반 구조를 표현하려면 value를 interface로 받을 수밖에 없다. 그리고 자연스럽게 type switch로 핸들링한다.</p>
<h3 id="heading-error">error 인터페이스 기반 다형성</h3>
<pre><code class="lang-go"><span class="hljs-keyword">type</span> error <span class="hljs-keyword">interface</span> {
    Error() <span class="hljs-keyword">string</span>
}
</code></pre>
<p>iface 구조 덕분에 다양한 에러 타입을 단일 인터페이스로 다루고, wrapping/unwrapping도 dynamic type 정보 기반으로 자연스럽게 구현된다.</p>
<h2 id="heading-6re466ch64uk66m0iousuoygnouklcdsl4bsnytquyw">그렇다면 문제는 없을까?</h2>
<p>얼핏 보기에 정적 타입 내부에서 꽤나 자유롭게 <code>Interface</code> 를 이용할 수 있어 보인다. 하지만 <code>escape analysis</code> 에 의한 <code>interface</code> 타입의 <code>heap allocation</code> 이 발생할 수 있다. 아마 <code>rust</code> 를 공부했던 사람들은 익숙할 수 있는데 이 <code>heap allocation</code> 을 이해하기 위해 아래 예시를 함께 보자.</p>
<pre><code class="lang-go"><span class="hljs-function"><span class="hljs-keyword">func</span> <span class="hljs-title">f</span><span class="hljs-params">()</span> <span class="hljs-title">interface</span></span>{} {
    x := <span class="hljs-number">10</span>
    <span class="hljs-keyword">return</span> x
}
</code></pre>
<p>위 함수를 보면 x 에 정수(int) 값 10을 할당하고 return type 으로 <code>interface{}</code> 타입을 반환한다. 우리의 상식으로는 <code>int</code> 는 정수값으로 원래 스택 변수이기 때문에 <code>heap allocation</code> 이 발생하지 않을 것 같지만, 실제로는 <code>heap allocation</code> 이 발생한다. 이는 <code>escape analysis</code> 에 의한 <code>interface</code> 타입의 <code>heap allocation</code> 이 발생하기 때문이다.</p>
<p>이유는 무엇일까? 예를 들어 <code>a = f()</code> 라는 부분이 외부에 있다고 해보자. 그런데 x 의 값이 사라지면 interface 의 data pointer 가 dangling pointer 가 되어버린다. 따라서 컴파일러는 이를 최대한 안전하게 처리하기 위해 <code>heap</code> 에 올려버린다.</p>
<blockquote>
<p>x escapes to heap because it’s stored in interface</p>
</blockquote>
<p>실제 예시와 <code>go build</code> 에서 flag 를 통해 확인해보자</p>
<pre><code class="lang-go"><span class="hljs-keyword">package</span> main

<span class="hljs-function"><span class="hljs-keyword">func</span> <span class="hljs-title">aa</span><span class="hljs-params">()</span> <span class="hljs-title">interface</span></span>{} {
    x := <span class="hljs-number">100</span>
    <span class="hljs-keyword">return</span> x
}

<span class="hljs-function"><span class="hljs-keyword">func</span> <span class="hljs-title">main</span><span class="hljs-params">()</span></span> {
    a := aa()
    _ = a
}
</code></pre>
<pre><code class="lang-sh">❯ go build -gcflags=<span class="hljs-string">"-m"</span> interface_prac/main.go
<span class="hljs-comment"># command-line-arguments</span>
interface_prac/main.go:3:6: can inline aa
interface_prac/main.go:8:6: can inline main
interface_prac/main.go:9:13: inlining call to aa
interface_prac/main.go:5:9: 100 escapes to heap
interface_prac/main.go:9:13: 100 does not escape
</code></pre>
<p>위와 같이 100 이 heap 으로 escape[^1] 되는 것을 확인할 수 있다. 만약 <code>int</code> 타입으로 리턴됬다면 어떨까?</p>
<pre><code class="lang-go"><span class="hljs-keyword">package</span> main

<span class="hljs-function"><span class="hljs-keyword">func</span> <span class="hljs-title">aa</span><span class="hljs-params">()</span> <span class="hljs-title">int</span></span> {
    x := <span class="hljs-number">100</span>
    <span class="hljs-keyword">return</span> x
}

<span class="hljs-function"><span class="hljs-keyword">func</span> <span class="hljs-title">main</span><span class="hljs-params">()</span></span> {
    a := aa()
    _ = a
}
</code></pre>
<pre><code class="lang-sh">❯ go build -gcflags=<span class="hljs-string">"-m"</span> interface_prac/main.go
<span class="hljs-comment"># command-line-arguments</span>
interface_prac/main.go:3:6: can inline aa
interface_prac/main.go:8:6: can inline main
interface_prac/main.go:9:9: inlining call to aa
</code></pre>
<p><code>int</code> 를 리턴하는 경우에는 위 예시처럼 <code>escape</code> 가 일어나지 않음을 확인할 수 있다. <code>inlining call to escape</code>[^2] 는 escape analysis 와 무관하다.</p>
<hr />
<p>[^1]: “100 escapes to heap”은 x 변수가 escape했다는 뜻이 아니다. literal 100 자체가 interface wrapping 과정에서 힙으로 복사된 것이다. 함수의 반환 타입이 interface이기 때문에 literal은 임시 메모리에 둘 수 없어서 힙으로 올라가는 것이다.</p>
<p>[^2]: 여기서 “inlining call to aa”는 escape analysis와 무관하다. 단순히 컴파일러가 aa() 함수를 main 함수 안으로 인라인 최적화 한 것뿐이다. escape 여부는 '100 escapes to heap' 같은 별도의 메시지에서 판별된다.</p>
]]></content:encoded></item><item><title><![CDATA[[밑바닥 부터 구현하는 데이터베이스] 1 - 운영체제에 파일을 어떻게 읽고 쓸까?]]></title><description><![CDATA[우리가 저장하는 모든 데이터는 컴퓨터에 바이트(byte) 로 저장된다. 그래서 우리는 고 수준의 자료형을 직렬화(Serialize) 하여 저장하여야 한다. 예를 들어, 우리가 int[] 형을 직렬화 한다고 해보자.
[1, 2, 3] => [0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x02, 0x00, 0x00, 0x00, 0x03]

각 정수를 4 Byte 로 이어 붙이는걸 생각해볼 수 있다. 다만 여기서 by...]]></description><link>https://roach-wiki.com/1</link><guid isPermaLink="true">https://roach-wiki.com/1</guid><dc:creator><![CDATA[roach]]></dc:creator><pubDate>Tue, 23 Dec 2025 05:46:37 GMT</pubDate><content:encoded><![CDATA[<p>우리가 저장하는 모든 데이터는 컴퓨터에 <code>바이트(byte)</code> 로 저장된다. 그래서 우리는 고 수준의 자료형을 <strong>직렬화(Serialize)</strong> 하여 저장하여야 한다. 예를 들어, 우리가 <code>int[]</code> 형을 직렬화 한다고 해보자.</p>
<pre><code class="lang-python">[<span class="hljs-number">1</span>, <span class="hljs-number">2</span>, <span class="hljs-number">3</span>] =&gt; [<span class="hljs-number">0x00</span>, <span class="hljs-number">0x00</span>, <span class="hljs-number">0x00</span>, <span class="hljs-number">0x01</span>, <span class="hljs-number">0x00</span>, <span class="hljs-number">0x00</span>, <span class="hljs-number">0x00</span>, <span class="hljs-number">0x02</span>, <span class="hljs-number">0x00</span>, <span class="hljs-number">0x00</span>, <span class="hljs-number">0x00</span>, <span class="hljs-number">0x03</span>]
</code></pre>
<p>각 정수를 4 Byte 로 이어 붙이는걸 생각해볼 수 있다. 다만 여기서 byte 로 변환한 것을 어떻게 적어야 할지 고민해볼 수 있다. 예를 들어 <code>[1] =&gt; [0x00, 0x00, 0x00, 0x01]</code> 으로 적는 사람도 있을 것이고, <code>[1] =&gt; [0x01, 0x00, 0x00, 0x00]</code> 으로 적는 사람도 있을 것이다.</p>
<p>실제로 이러한 사유때문에 <strong>매핑(mapping)</strong> 방식이 별도로 존재하게 된다. <code>[0x00, 0x00, 0x00, 0x01]</code> 에서 <code>0x00</code> 을 <strong>가장 상위 바이트(MSB)</strong> 라고 표현하고, <code>0x01</code> 을 <strong>가장 하위 바이트(LSB)</strong> 라고 표현한다. 메모리는 일반적으로 <code>0x00</code>, <code>0x01</code>, <code>0x02</code>, … 와 같이 증가하므로 이 상위 바이트에서 하위 바이트의 흐름을 어떻게 메모리 주소상에 맵핑할지를 나타내는 방식을 <strong>엔디안(Endian)</strong> 이라고 한다.</p>
<h2 id="heading-bigendian">BigEndian</h2>
<p>네트워크나 포맷에서 자주 쓰는<code>BigEndian</code> 은 상위 바이트(MSB) 를 메모리에 낮은 주소(앞쪽)에 쓰는 방식이다. 즉, <code>[0x00, 0x00, 0x00, 0x01]</code> 이 된다.</p>
<h2 id="heading-littleendian">LittleEndian</h2>
<p>그와 반대로 <code>LittleEndian</code> 은 하위 바이트(LSB) 를 메모리의 낮은 주소(앞쪽)에 쓰는 방식이다. 따라서, <code>[0x01, 0x00, 0x00, 0x00]</code> 이 된다.</p>
<p>왜 이렇게 세분화 되어 있는걸까? 그 이유는 네트워크 프로토콜, CPU 아키텍쳐 마다 이와 같이 맵핑하는 방식이 다르기 때문이다. 따라서 이를 잘 인지하는 것이 중요하다. 다른 맵핑 방식으로 읽게 되면 아예 다른 값으로 해석될 수 있기 때문이다.</p>
<h2 id="heading-go-lang">Go lang 에서는?</h2>
<p>Golang 에서는 이를 어떻게 처리할까? <code>binary.BigEndian.PutUint32</code> 를 사용하면 쉽게 인코딩이 가능하다. 예시를 위해 정수값을 4 바이트로 변환하여 byte 로 변환한다고 해보자. 첫번째로 해야할 일은 무엇일까? 바로 <strong>len(nums) * 4</strong> 만큼의 byte 배열을 확보해주는 것이다.</p>
<pre><code class="lang-go">buffer := <span class="hljs-built_in">make</span>([]<span class="hljs-keyword">byte</span>, <span class="hljs-number">4</span>*<span class="hljs-built_in">len</span>(nums))
</code></pre>
<p>이후에는 <code>binary.BigEndian.PutUint32</code> 을 이용하면 쉽게 4byte 배열에 맞게 들어가도록 변환 가능하다.</p>
<pre><code class="lang-go"><span class="hljs-comment">// PutUint32 stores v into b[0:4].</span>
<span class="hljs-function"><span class="hljs-keyword">func</span> <span class="hljs-params">(bigEndian)</span> <span class="hljs-title">PutUint32</span><span class="hljs-params">(b []<span class="hljs-keyword">byte</span>, v <span class="hljs-keyword">uint32</span>)</span></span> {
    _ = b[<span class="hljs-number">3</span>] <span class="hljs-comment">// early bounds check to guarantee safety of writes below</span>
    b[<span class="hljs-number">0</span>] = <span class="hljs-keyword">byte</span>(v &gt;&gt; <span class="hljs-number">24</span>)
    b[<span class="hljs-number">1</span>] = <span class="hljs-keyword">byte</span>(v &gt;&gt; <span class="hljs-number">16</span>)
    b[<span class="hljs-number">2</span>] = <span class="hljs-keyword">byte</span>(v &gt;&gt; <span class="hljs-number">8</span>)
    b[<span class="hljs-number">3</span>] = <span class="hljs-keyword">byte</span>(v)
}
</code></pre>
<p>여기서 <strong>“비트연산”</strong> 이 생소하면 이 부분이 잘 이해가 안갈 수 있는데, 이 연산(right shift) 은 간단하게 비트를 오른쪽으로 미는 역할을 한다. 이 부분을 잘 이해하지 못하면 앞으로 시리즈가 어려우므로 예시를 들고 넘어가 보겠다.</p>
<h3 id="heading-67me7yq47jew7ikwkou2goqwgcdshktrqoup">비트연산(부가 설명)</h3>
<p>예를 들어 어떤 수를 <strong>이진수</strong>로 변환했는데 <code>00010010 00110100 01010110 01111000</code> 과 같이 나왔다고 해보자. 우리가 이걸 4byte 에 하나하나 담으려면 어떻게 해야할까? 이럴때 비트 연산을 사용하면 쉽다. 첫번째로 <code>00010010</code> 을 담으려면 총 24번 오른쪽으로 밀어야 <code>00000000 00000000 00000000 00010010</code> 형태가 된다. (정확히는 shift 연산이지만 이해를 위해 민다는 표현을 차용했다)</p>
<p>즉, 이 상태에서 1 byte = 8bit 이므로 byte (v &gt;&gt; 24) 를 하게 되면 byte 는 00010010 을 가지게 된다. 나머지도 똑같다. 즉 담고 싶은 부분을 마지막 8bit 로 만들기 위해 shift 연산을 하는 것이다. 이해가 갔다면 스스로 <strong>Little-endian</strong> 방식도 한번 구현해보길 바란다.</p>
<pre><code class="lang-go"><span class="hljs-keyword">import</span> <span class="hljs-string">"encoding/binary"</span>

<span class="hljs-function"><span class="hljs-keyword">func</span> <span class="hljs-title">IntSliceToBytes</span><span class="hljs-params">(nums []<span class="hljs-keyword">uint32</span>)</span> []<span class="hljs-title">byte</span></span> {
    buffer := <span class="hljs-built_in">make</span>([]<span class="hljs-keyword">byte</span>, <span class="hljs-number">4</span>*<span class="hljs-built_in">len</span>(nums))
    <span class="hljs-keyword">for</span> i, n := <span class="hljs-keyword">range</span> nums {
        binary.BigEndian.PutUint32(buffer[i*<span class="hljs-number">4</span>:], n)
    }
    <span class="hljs-keyword">return</span> buffer
}
</code></pre>
<p>본문으로 돌아와서 다시 코드를 보면 이제 이 코드가 어떤 동작을 하는지 명확하게 이해갔을 것이다. 그렇다면 이제 이 byte 들을 파일에 쓰는 작업을 진행해보자.</p>
<h2 id="heading-osfile">파일(os.File)</h2>
<p>일단 운영체제는 우리가 적어놨던 파일 또는 데이터들을 어떻게 읽고 가져올까? 우리가 파일을 읽을때 파일 전체를 로드해서 가져올까? 아니면 부분만 읽어서 가져올까? OS 를 공부해봤다면 들어봤겠지만 운영체제는 블럭단위로 파일을 읽게 된다.</p>
<p>그렇다면 운영체제는 이를 어떻게 나눠서 읽는걸까? 예를 들어 우리가 10KB 짜리 파일을 읽는데 1KB 블럭단위로 이 페이지를 읽어온다고 해보자. 첫번째로 1KB 를 읽고, 다음 부터는 마지막으로 읽은(1024~2048) 까지를 읽어야 할 것이다.</p>
<h3 id="heading-offset">오프셋(Offset)</h3>
<p>이를 위해 파일에서 어디까지 읽었는지를 알려주는 <strong>오프셋(Offset)</strong> 을 필요로 하게 되었고, 이는 파일 포인터에 값으로 저장되어 있다. 코드로 보면 조금 더 수월하게 이해할 수 있으니 코드로 한번 작성해보면서 알아보자.</p>
<pre><code class="lang-go">    f, err := os.OpenFile(<span class="hljs-string">"test.txt"</span>, os.O_RDWR|os.O_CREATE, <span class="hljs-number">0666</span>) <span class="hljs-comment">// 파일을 생성</span>

    <span class="hljs-keyword">if</span> err != <span class="hljs-literal">nil</span> {
        <span class="hljs-built_in">panic</span>(err)
    }

    arr := <span class="hljs-built_in">make</span>([]<span class="hljs-keyword">uint32</span>, <span class="hljs-number">12</span>) <span class="hljs-comment">// 정수형 배열(우리가 쓸값)</span>
    buf := <span class="hljs-built_in">make</span>([]<span class="hljs-keyword">byte</span>, <span class="hljs-number">12</span>*<span class="hljs-number">4</span>) <span class="hljs-comment">// 정수형 배열을 byte 로 변환할때 사용할 buffer (len(nums) * 4)</span>

    <span class="hljs-keyword">for</span> i := <span class="hljs-number">0</span>; i &lt; <span class="hljs-number">12</span>; i++ {
        arr[i] = i
    }

    n, err := f.Write(IntSliceToBytes(arr))
</code></pre>
<p>파일을 쓰기 위해 파일을 생성하고, 우리가 원하는 정수 배열을 byte 배열로 전환한 다음에 <code>f.Write(b byte[])</code> 를 통하여 해당 파일에 값을 쓰게 된다. 값을 쓸때 우리는 <strong>0번째 부터 48번째까지 offset 을 옮겨가며 파일에 값을 기록</strong>하게 된다.</p>
<pre><code class="lang-go">    _, err = f.Read(buf)

    <span class="hljs-keyword">if</span> err != <span class="hljs-literal">nil</span> {
        <span class="hljs-built_in">panic</span>(err)
    }

    fmt.Printf(<span class="hljs-string">"%v\n"</span>, BytesToIntSlice(buf))

<span class="hljs-comment">// error    </span>
<span class="hljs-built_in">panic</span>: EOF

goroutine <span class="hljs-number">1</span> [running]:
main.main()
        /home/roach/btree/file/main.<span class="hljs-keyword">go</span>:<span class="hljs-number">51</span> +<span class="hljs-number">0x246</span>
exit status <span class="hljs-number">2</span>
</code></pre>
<p>만약에, 우리가 이를 생각하지 않고 여기서 바로 <code>Read</code> 메소드를 호출하게 되면 어떻게 될까? 우리는 당연하게도 0번부터 읽을 것 같지만 <code>offset</code> 부터 읽게 된다. 즉, 48번째 부터 읽게 되므로 우리는 EOF 라는 에러를 마주하게 된다.</p>
<h3 id="heading-seek">Seek 메소드</h3>
<pre><code class="lang-go"><span class="hljs-comment">// Seek sets the offset for the next Read or Write on file to offset, interpreted</span>
<span class="hljs-comment">// according to whence: 0 means relative to the origin of the file, 1 means</span>
<span class="hljs-comment">// relative to the current offset, and 2 means relative to the end.</span>
<span class="hljs-comment">// It returns the new offset and an error, if any.</span>
<span class="hljs-comment">// The behavior of Seek on a file opened with O_APPEND is not specified.</span>
<span class="hljs-function"><span class="hljs-keyword">func</span> <span class="hljs-params">(f *File)</span> <span class="hljs-title">Seek</span><span class="hljs-params">(offset <span class="hljs-keyword">int64</span>, whence <span class="hljs-keyword">int</span>)</span> <span class="hljs-params">(ret <span class="hljs-keyword">int64</span>, err error)</span></span> {
    <span class="hljs-keyword">if</span> err := f.checkValid(<span class="hljs-string">"seek"</span>); err != <span class="hljs-literal">nil</span> {
        <span class="hljs-keyword">return</span> <span class="hljs-number">0</span>, err
    }
    r, e := f.seek(offset, whence)
    <span class="hljs-keyword">if</span> e == <span class="hljs-literal">nil</span> &amp;&amp; f.dirinfo.Load() != <span class="hljs-literal">nil</span> &amp;&amp; r != <span class="hljs-number">0</span> {
        e = syscall.EISDIR
    }
    <span class="hljs-keyword">if</span> e != <span class="hljs-literal">nil</span> {
        <span class="hljs-keyword">return</span> <span class="hljs-number">0</span>, f.wrapErr(<span class="hljs-string">"seek"</span>, e)
    }
    <span class="hljs-keyword">return</span> r, <span class="hljs-literal">nil</span>
}
</code></pre>
<p>따라서 이러한 문제를 마주하지 않기 위해서는 offset 을 우리가 원하는 위치로 이동시켜야 한다. <code>Seek</code> 함수를 보면 <code>offset</code> 은 양수/음수 값에 따라 현재 <code>기준점(whence)</code> 에서 오프셋을 계산하게 된다.</p>
<p>주석의 설명을 보면 <code>0</code> 은 파일의 시작 지점을 의미하고, <code>1</code> 은 현재 offset 의 위치에서, <code>2</code> 는 마지막을 기준으로 계산된다. 그렇다면 현재 Offset 위치를 알아낼때는 <a target="_blank" href="http://f.Seek"><code>f.Seek</code></a><code>(0, 1)</code> 을 이용하면 현재의 오프셋 위치를 알아낼 수 있을 것이다. 실제로 테스트를 한번 해보자.</p>
<pre><code class="lang-go">pos, err := f.Seek(<span class="hljs-number">0</span>, <span class="hljs-number">1</span>) <span class="hljs-comment">// 48 출력</span>
</code></pre>
<p>따라서 파일을 읽기전에 <a target="_blank" href="http://f.Seek"><code>f.Seek</code></a><code>(0, 0)</code> 으로 옮겨주자. 쓰기 이후 실행해보면 48이 잘 출력되는 걸 확인할 수 있다. 그렇다면 우리가 읽기 작업전에 해줘야 할 것은 무엇일까? 바로 파일의 offset 을 시작지점으로 옮겨줘야 한다.</p>
<pre><code class="lang-go">    f.Seek(<span class="hljs-number">0</span>, <span class="hljs-number">0</span>) <span class="hljs-comment">// 0 이점 시작지 이므로</span>

    _, err = f.Read(buf)

    <span class="hljs-keyword">if</span> err != <span class="hljs-literal">nil</span> {
        <span class="hljs-built_in">panic</span>(err)
    }

    fmt.Printf(<span class="hljs-string">"%v\n"</span>, BytesToIntSlice(buf)) <span class="hljs-comment">// [0 1 2 3 4 5 6 7 8 9 10 11]</span>
</code></pre>
<p>위와 같이 파일을 시작지점으로 옮기고 이후 읽기를 실행하면 값을 잘 읽어오는 것을 확인할 수 있다. 그렇다면 여기서 아까의 질문인 <code>운영 체제는 어떻게 파일을 나눠서 읽을 수 있을까?</code> 에 대답할 수 있게 된다. 즉, Offset 으로 일정 단위로 읽는다면 충분히 나눠 읽을 수 있다는 것이다.</p>
<h3 id="heading-7jmcioucmouiocdsnb3sp4a">왜 나눠 읽지?</h3>
<p>그렇다면 왜 나눠 읽는 것일까? 바이트를 하나하나 가져오면 안되는 걸까? 우리가 일반적으로 특정 데이터를 어디서 부터 가져오는 행위는 항상 가벼운 행위는 아니다. 따라서, 배치로 묶어서 처리하는 이유는 보통 I/O 와 같은 무거운 행위를 덜 하기 위해서이다.</p>
<p>즉, 운영체제가 나눠 읽는 이유도 이러한 성능적인 부분에서 최적화의 목적에 있다. 따라서 나눠 읽는 부분을 구현해보고 생각해보면서 어떤 최적화가 되는지 생각해보자.</p>
<h2 id="heading-page">Page</h2>
<p>예를 들어 우리가 <code>16 byte</code> 씩 데이터를 읽고, 이를 특정 구조체에 저장한다고 해보자. 우리는 이러한 블럭단위를 <code>Page</code> 라고 부를 것이고, 이를 아래와 같이 구조체로 정의할 것이다.</p>
<pre><code class="lang-go"><span class="hljs-keyword">const</span> PAGE_SIZE = <span class="hljs-number">16</span> <span class="hljs-comment">// Byte</span>

<span class="hljs-keyword">type</span> Page <span class="hljs-keyword">struct</span> {
    Id   <span class="hljs-keyword">int32</span>
    Data []<span class="hljs-keyword">byte</span>
}
</code></pre>
<p>만약 <strong>24개의 정수(96byte)</strong> 를 Page 로 읽게 되면 몇개의 Page 가 생성되게 될까? <code>96 / PAGE_SIZE = 96 / 16 = 6</code> 개가 필요하게 될 것이다. 즉, 우리는 <code>6</code> 개의 Page 를 가지게 된다. 하지만 Page 마다 순서가 있으므로 이를 구분하기 위해 Id 라는 값을 둔다.</p>
<p>첫번째 페이지를 읽고, 두번째 페이지를 읽을 때 [<a target="_blank" href="`[`http://f.Seek"><code>f.Seek</code>](http://f.Seek)`</a>(첫번째<code>](http://f.Seek\)\(첫번째)</code>페이지 이후, 0)<code>으로 만들어야 하기 때문에 Id 를 두는 것이다. 그렇다면 [</code>f.Seek<code>](http://f.Seek) 의 첫번째 인자로 들어갈 인자는</code>PAGE_SIZE * Id` 가 됨을 알 수 있다.</p>
<h3 id="heading-page-1">Page 로 읽기</h3>
<p>그렇다면 위의 예시(총 크기 96Byte) 에서 Page 단위로 우리가 읽는 부분의 프로세스는 어떻게 될까? 이미 예측하고 있을 수 있겠지만 아래와 같이 진행된다.</p>
<p><strong>읽기 알고리즘</strong></p>
<ol>
<li><p>총 크기 / PageSize 만큼의 루프를 생성한다.</p>
</li>
<li><p>루프 내부에서 PageSize 만큼의 Buffer(byte 배열) 을 생성한다.</p>
</li>
<li><p>루프 내부에서 현재 루프의 <code>I(반복 횟수) * PAGE_SIZE</code> 로 offset 을 설정한다. (<code>i=0 일때 0 * 16, i = 1 일때 1 * 16, …</code>)</p>
</li>
<li><p>파일 포인터의 offset 을 계산된 값으로 이동시킨다. <a target="_blank" href="http://f.seek"><code>f.seek</code></a><code>(I(반복 횟수) * PAGE_SIZE, 0)</code></p>
</li>
<li><p>제공된 버퍼를 넣어 값을 읽는다.</p>
</li>
</ol>
<p>여기서 고민이 한가지 생긴다. 어떻게 파일 단위로 이 페이지를 관리하지? 🤔 이를 위해 <code>PageManager</code> 라는 객체를 하나 만들어 볼수 있다.</p>
<pre><code class="lang-go"><span class="hljs-keyword">type</span> PageManager <span class="hljs-keyword">struct</span> {
    f     *os.File
    pages []*Page
}
</code></pre>
<p>이렇게 되면 우리가 읽은 페이지를 Id 를 인덱스 삼아 Page 를 관리할 수 있게 되고, 필요한 <code>전체 읽기(ReadAll)</code> 이라는 함수 또한 이 객체에서 관리하게 할 수 있다.</p>
<pre><code class="lang-go"><span class="hljs-function"><span class="hljs-keyword">func</span> <span class="hljs-params">(p *PageManager)</span> <span class="hljs-title">ReadAll</span><span class="hljs-params">()</span> <span class="hljs-title">error</span></span> {
    <span class="hljs-keyword">for</span> i := <span class="hljs-number">0</span>; i &lt; BYTE_LENGTH/PAGE_SIZE; i++ {
        buf := <span class="hljs-built_in">make</span>([]<span class="hljs-keyword">byte</span>, PAGE_SIZE)
        p.f.Seek(<span class="hljs-keyword">int64</span>(i*PAGE_SIZE), <span class="hljs-number">0</span>) <span class="hljs-comment">// 이건 사실 옮겨지기 때문에 불필요하나 명확한 예시를 위해 적음</span>
        p.f.Read(buf)
        p.pages[i] = &amp;Page{
            Id:   <span class="hljs-keyword">int32</span>(i),
            Data: buf,
        }
    }
    <span class="hljs-keyword">return</span> <span class="hljs-literal">nil</span>
}
</code></pre>
<p>이 ReadAll 이라는 함수를 이용하면 우리는 내부 SYSTEM_CALL 을 통해 디스크에서 파일을 읽어오게 된다. 근데 만약 정수 배열 <code>0~4 번째</code> 에 Read 가 유독 많다면 어떻게 될까? 이 SYSTEM_CALL 을 통해 계속 디스크로 부터 읽어오는 작업을 해야 할까?</p>
<p>이제는 그럴 필요가 없다. 우리가 이미 객체화 하여 메모리에 값을 올려뒀기 때문이다. <code>ReadAt(id int32)</code> 를 통해 메모리에서 값이 있다면 리턴하게 해보자.</p>
<pre><code class="lang-go"><span class="hljs-function"><span class="hljs-keyword">func</span> <span class="hljs-params">(p *PageManager)</span> <span class="hljs-title">ReadAt</span><span class="hljs-params">(id <span class="hljs-keyword">int32</span>)</span> []<span class="hljs-title">byte</span></span> {
    <span class="hljs-keyword">return</span> p.pages[id].Data
}
</code></pre>
<p>이제는 <code>SYSTEM_CALL</code> 이 아닌 단순 Memory Random Access 로 해당 부분에 적혀있는 값을 가져올 수 있게 됬다. 즉, 이전의 Disk 에서 읽어오는 것보다는 가벼운 행위가 되었다.</p>
<h2 id="heading-7kce7lk0ioy9loutna">전체 코드</h2>
<pre><code class="lang-go"><span class="hljs-keyword">package</span> main

<span class="hljs-keyword">import</span> (
    <span class="hljs-string">"encoding/binary"</span>
    <span class="hljs-string">"fmt"</span>
    <span class="hljs-string">"os"</span>
)

<span class="hljs-keyword">const</span> PAGE_SIZE = <span class="hljs-number">16</span> <span class="hljs-comment">// Byte</span>
<span class="hljs-keyword">const</span> INT_LENGTH = <span class="hljs-number">24</span>
<span class="hljs-keyword">const</span> BYTE_LENGTH = INT_LENGTH * <span class="hljs-number">4</span>

<span class="hljs-keyword">type</span> Page <span class="hljs-keyword">struct</span> {
    Id   <span class="hljs-keyword">int32</span>
    Data []<span class="hljs-keyword">byte</span>
}

<span class="hljs-keyword">type</span> PageManager <span class="hljs-keyword">struct</span> {
    f     *os.File
    pages []*Page
}

<span class="hljs-function"><span class="hljs-keyword">func</span> <span class="hljs-params">(p *PageManager)</span> <span class="hljs-title">ReadAt</span><span class="hljs-params">(id <span class="hljs-keyword">int32</span>)</span> []<span class="hljs-title">byte</span></span> {
    <span class="hljs-keyword">return</span> p.pages[id].Data
}

<span class="hljs-function"><span class="hljs-keyword">func</span> <span class="hljs-params">(p *PageManager)</span> <span class="hljs-title">ReadAll</span><span class="hljs-params">()</span> <span class="hljs-title">error</span></span> {
    <span class="hljs-keyword">for</span> i := <span class="hljs-number">0</span>; i &lt; BYTE_LENGTH/PAGE_SIZE; i++ {
        buf := <span class="hljs-built_in">make</span>([]<span class="hljs-keyword">byte</span>, PAGE_SIZE)
        p.f.Seek(<span class="hljs-keyword">int64</span>(i*PAGE_SIZE), <span class="hljs-number">0</span>)
        p.f.Read(buf)
        p.pages[i] = &amp;Page{
            Id:   <span class="hljs-keyword">int32</span>(i),
            Data: buf,
        }
    }
    <span class="hljs-keyword">return</span> <span class="hljs-literal">nil</span>
}

<span class="hljs-function"><span class="hljs-keyword">func</span> <span class="hljs-title">IntSliceToBytes</span><span class="hljs-params">(nums []<span class="hljs-keyword">uint32</span>)</span> []<span class="hljs-title">byte</span></span> {
    buf := <span class="hljs-built_in">make</span>([]<span class="hljs-keyword">byte</span>, <span class="hljs-number">4</span>*<span class="hljs-built_in">len</span>(nums))
    <span class="hljs-keyword">for</span> i, n := <span class="hljs-keyword">range</span> nums {
        binary.BigEndian.PutUint32(buf[i*<span class="hljs-number">4</span>:], n)
    }
    <span class="hljs-keyword">return</span> buf
}

<span class="hljs-function"><span class="hljs-keyword">func</span> <span class="hljs-title">BytesToIntSlice</span><span class="hljs-params">(buf []<span class="hljs-keyword">byte</span>)</span> []<span class="hljs-title">int</span></span> {
    n := <span class="hljs-built_in">len</span>(buf) / <span class="hljs-number">4</span>
    out := <span class="hljs-built_in">make</span>([]<span class="hljs-keyword">int</span>, n)
    <span class="hljs-keyword">for</span> i := <span class="hljs-number">0</span>; i &lt; n; i++ {
        out[i] = <span class="hljs-keyword">int</span>(binary.BigEndian.Uint32(buf[i*<span class="hljs-number">4</span>:]))
    }
    <span class="hljs-keyword">return</span> out
}

<span class="hljs-function"><span class="hljs-keyword">func</span> <span class="hljs-title">main</span><span class="hljs-params">()</span></span> {
    f, err := os.OpenFile(<span class="hljs-string">"test.txt"</span>, os.O_RDWR|os.O_CREATE, <span class="hljs-number">0666</span>)
    <span class="hljs-keyword">if</span> err != <span class="hljs-literal">nil</span> {
        <span class="hljs-built_in">panic</span>(err)
    }

    pageManager := &amp;PageManager{
        f:     f,
        pages: <span class="hljs-built_in">make</span>([]*Page, BYTE_LENGTH/PAGE_SIZE),
    }

    arr := <span class="hljs-built_in">make</span>([]<span class="hljs-keyword">uint32</span>, INT_LENGTH)

    <span class="hljs-keyword">for</span> i := <span class="hljs-number">0</span>; i &lt; INT_LENGTH; i++ {
        arr[i] = <span class="hljs-keyword">uint32</span>(i)
    }

    _, err = f.Write(IntSliceToBytes(arr))
    <span class="hljs-keyword">if</span> err != <span class="hljs-literal">nil</span> {
        <span class="hljs-built_in">panic</span>(err)
    }

    f.Seek(<span class="hljs-number">0</span>, <span class="hljs-number">0</span>)

    pageManager.ReadAll()

    fmt.Printf(<span class="hljs-string">"%v\n"</span>, BytesToIntSlice(pageManager.ReadAt(<span class="hljs-number">0</span>)))
}
</code></pre>
<h2 id="heading-7ze36rci66a0iounjo2vncdrtodrtoq">헷갈릴 만한 부분</h2>
<ul>
<li>우리가 Page 단위로 읽어야만 운영체제가 블럭 단위로 읽는 것은 아니다. 운영체제는 기본적으로 블럭단위로 읽고, 필요에 의해서는 더많은 데이터를 prefetch 하기도 한다.</li>
</ul>
]]></content:encoded></item><item><title><![CDATA[사진에서 경계를 찾는 방법]]></title><description><![CDATA[최근 회사에서 이미지와 관련된 Cropping 문제로 운영 공수가 많이 들어간다는 요구사항을 받았다. 그래서 여러가지 방법을 고안했는데, 머릿속에 딱 든 생각은 두 가지 정도였다. 첫 번째로는 경계를 지니고 있는 사진들은 보통 일정 공백을 지니고 있는데 이를 수학적으로 계산해내는 방법, 두 번째로는 LLM을 통해 크롭핑할 영역을 분류하는 방법이다.
일단 운영상의 리소스나 결과물의 유지보수를 생각했을 때 후자인 LLM의 경우, 사실 완벽한 해법이...]]></description><link>https://roach-wiki.com/7iks7kee7jeq7iscioqyveqzhoulvcdssl7ripqg67cp67kv</link><guid isPermaLink="true">https://roach-wiki.com/7iks7kee7jeq7iscioqyveqzhoulvcdssl7ripqg67cp67kv</guid><category><![CDATA[python, contours, image]]></category><dc:creator><![CDATA[roach]]></dc:creator><pubDate>Tue, 23 Dec 2025 05:45:51 GMT</pubDate><content:encoded><![CDATA[<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1767419058866/0e1a2042-d6bd-44d4-a0ce-307dba5f2546.png" alt class="image--center mx-auto" /></p>
<p>최근 회사에서 이미지와 관련된 Cropping 문제로 운영 공수가 많이 들어간다는 요구사항을 받았다. 그래서 여러가지 방법을 고안했는데, 머릿속에 딱 든 생각은 두 가지 정도였다. 첫 번째로는 경계를 지니고 있는 사진들은 보통 일정 공백을 지니고 있는데 이를 수학적으로 계산해내는 방법, 두 번째로는 LLM을 통해 크롭핑할 영역을 분류하는 방법이다.</p>
<p>일단 운영상의 리소스나 결과물의 유지보수를 생각했을 때 후자인 LLM의 경우, 사실 완벽한 해법이라고 항상 생각하지는 않는다. 생성형 모델에게 완벽한 답변을 요구하게 되면 오히려 더 많은 공수가 들어가는 경우가 많고, 모델이나 프롬프트의 성능/퀄리티에 따라 결과가 쉽게 흔들릴 수 있다. 이건 결국 AI 전문가가 없는 팀에서는 유지보수 측면에서 리스크로 작용할 가능성이 크다. 따라서, 구분이 쉬운 사진에 대해서는 기존에 잘 연구되어 있는 알고리즘을 활용해 멱등성 있는 결과를 확보하는 것이 더 옳다고 판단했다.</p>
<h2 id="heading-7iiy7zwz7kcb7jy866gcioqzhoycsd8">수학적으로 계산?</h2>
<p>그렇다면 이걸 어떻게 수학적으로 계산할 수 있을까? 예전에 배민 해커톤 때 cv2를 잠깐 건드렸던 기억 덕분에 떠올랐던 방법이 바로 <strong>경계선(Contours) 알고리즘</strong>이다.</p>
<p>컨투어 알고리즘은 Binary 또는 흑백화된 이미지를 기반으로 동작한다. 즉, 경계선을 찾을 때 색깔 정보는 필요 없고, 밝기의 변화(값의 차이)만 있으면 된다. 그래서 경계선을 찾는 데 필요하지 않은 채널을 줄이는(차원 축소하는) 과정이 먼저 필요했다.</p>
<p>또한 대부분의 사진들이 배경이 밝은 편이라, 특정 임계값 기준으로 이진화를 시켜주면 경계값을 쉽게 추출할 수 있다고 생각했다.</p>
<h3 id="heading-grayscale">흑백화(Grayscale)</h3>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1767419213016/639e9ff0-630c-40d1-a9db-f3bea63aef2c.png" alt class="image--center mx-auto" /></p>
<p>Grayscale은 쉽게 말하면 흑백화인데, 사진은 <strong>R,G,B의 3채널</strong>로 이루어져 있다. 하지만 실제로 경계선을 구분하는 과정에서는 색깔 정보가 크게 필요 없다. 즉, 불필요한 정보는 버리고, <strong>밝기(intensity)</strong> 정보만 남기는 게 더 이득이다.</p>
<p>이렇게 Grayscale을 통해 3채널 → 1채널로 차원축소를 해주면, 컨투어·이진화·모폴로지 같은 후처리 단계가 더 안정적으로 동작하게 된다.</p>
<p>코드는 아주 간단하다.</p>
<pre><code class="lang-python">gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
</code></pre>
<p>또한 상황에 따라 RGB → Gray로 변환할 때 사용하는 계수를 커스터마이징해볼 수도 있다. 작업 목적에 따라 밝기 가중치를 더 주거나 덜 줄 수도 있으니, 다양한 계수를 실험해보는 것도 좋은 방법이다.</p>
<h3 id="heading-7j207kee7zmu">이진화</h3>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1767419258115/e5eeafd3-d804-4e27-a2cc-5cc6389b0349.png" alt class="image--center mx-auto" /></p>
<p>이후에는 이진화 과정이 필요하다. <strong>이진화는 특정 임계값(Threshold)</strong>을 기준으로 픽셀을 흑/백 두 값으로만 나누는 과정이다.</p>
<p>수도 코드로는 아래와 같다.</p>
<pre><code class="lang-python"><span class="hljs-keyword">if</span> gray[y][x] &gt; threshold:
    thresh[y][x] = <span class="hljs-number">0</span> <span class="hljs-comment"># 흑백</span>
<span class="hljs-keyword">else</span>:
    thresh[y][x] = <span class="hljs-number">255</span> <span class="hljs-comment"># 백</span>
</code></pre>
<p>cv2 가 제공하는 함수를 사용하면 아래와 같이 쉽게 이 과정을 진행할 수 있다.</p>
<pre><code class="lang-python">_, thresh = cv2.threshold(gray, <span class="hljs-number">240</span>, <span class="hljs-number">255</span>, cv2.THRESH_BINARY_INV)
</code></pre>
<p>여기서 THRESH_BINARY_INV를 쓴 이유는, 밝은 배경은 0(검정), 내용물은 255(백색) 으로 만들기 위해서다. 이렇게 해야 이후 단계에서 <strong>“내용이 있는 덩어리(blob)”</strong> 를 쉽게 묶을 수 있다.</p>
<h3 id="heading-dilation">Dilation</h3>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1767419269999/4e215591-c8ad-47c0-9cf8-2a3f9d6e2345.png" alt class="image--center mx-auto" /></p>
<p>이진화의 결과를 보면 알겠지만 작은 노이즈나 끊어진 영역들이 보통 존재한다. 예를 들면, 옷 사진이나 인물 사진 같은 경우 내부에 작은 빈 공간이나 패턴들이 있어서 하나의 큰 영역으로 인식되지 않는 경우가 많다.</p>
<p>이때 사용하는 것이 <strong>Morphology - Dilation(팽창)</strong> 이다.</p>
<p>팽창은 255(흰색) 픽셀을 주변으로 퍼뜨려 조각난 부분들을 하나의 덩어리로 묶어주는 역할을 한다.</p>
<p>특히 세로로 긴 이미지들은 위아래 이미지 사이에 약간의 흰 여백이 존재하고, 그 여백만 잘 검출하면 자연스럽게 <strong>한 장씩 분리되는 형태</strong>가 만들어진다. 따라서 Dilate를 적절한 커널 크기로 적용하면 구간 단위로 깔끔하게 묶여, 이후 컨투어 탐지 시 <strong>이미지 단위의 블록</strong>을 찾기 쉬워진다. (CNN 의 kernel 을 공부해봤다면 아마 이 개념이 익숙할 것이다.)</p>
<p>코드로는 아래와 같이 작성해볼수있다.</p>
<pre><code class="lang-python">kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (<span class="hljs-number">100</span>, <span class="hljs-number">4</span>)) 
dilated = cv2.dilate(thresh, kernel, iterations=<span class="hljs-number">2</span>)
</code></pre>
<p><strong>팽창(Dilate) 연산</strong>은 커널이 겹치는 영역 중 1개라도 흰색(255)이 있으면 그 커널의 중심 픽셀도 255로 바뀌는 방식이다. 즉, 커널 영역 내 흰색 픽셀의 존재 여부를 기준으로 주변을 흰색으로 확장한다.</p>
<p>이 Kernel 도 결국에 튜닝을 해서 찾아야 한다. 이 값을 잡을때 기본적으로 세로형 이미지이기 때문에 가로픽셀에서 합칠게 더 많아도 생각해서 가로를 100, 세로를 4를 주었다. 여러 이미지를 통해 디버깅을 진행하다보면 적당한 값을 찾을 수 있다.</p>
<h3 id="heading-contours">경계선(Contours) 찾기</h3>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1767419283613/76dce861-d4a9-4d7c-a8d4-f61949e2846e.png" alt class="image--center mx-auto" /></p>
<p>이제 마지막 단계는 경계선(Contours)을 찾는 것이다. Dilate까지 끝난 결과물에서는 이미지 내부의 유효한 영역들이 하나의 큰 덩어리(blob) 형태로 묶여 있다. 따라서 우리는 내부의 윤곽선은 상관없고 가장 바깥쪽 윤곽선을 찾으면 되므로 <code>RETR_EXTERNAL</code> 그리고 직사각형의 점만 얻으며 되므로 윤곽선 점들을 우리 상황에 맞게 효율적으로 반환하는 <code>CHAIN_APPROX_SIMPLE</code> 옵션을 사용하면, 각 “콘텐츠 블록”의 사각형 영역을 쉽게 얻을 수 있다.</p>
<p>이렇게 얻은 bounding box는 (x, y, w, h) 형태로 반환되는데, 여기서 우리는 특히 y와 h, 즉 “세로 위치와 높이”만 활용해서 세로로 긴 이미지 내에서 “각각 분리되어야 할 이미지”들을 판단할 수 있다.</p>
<h2 id="heading-6rkw6ro8">결과</h2>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1767419296094/e7018a9f-c409-4009-91f8-d79f23e32378.png" alt class="image--center mx-auto" /></p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1767419300205/9764c199-83b3-496c-b08c-0f075a0ec770.png" alt class="image--center mx-auto" /></p>
<p>최종적으로는 위와 같이, 하나의 세로로 긴 이미지 안에 여러 장의 독립적인 이미지가 붙어 있을 경우, 각 영역을 조건에만 맞는다면 정확하게 검출해 개별 이미지로 분리할 수 있는 구조가 완성된다.</p>
<p>중간 과정에서 Grayscale → 이진화 → Dilate → Contours 로 이어지는 처리과정이 <strong>“불필요한 정보 제거 → 유효한 영역 강조 → 덩어리 묶기 → 외곽선 검출”</strong> 이라는 논리적 흐름으로 이어지기 때문에, 단순한 이미지라면 LLM 기반 접근보다 훨씬 안정적이고 유지보수가 쉬운 방식으로 동작할 수 있게 되었다.</p>
<p>물론 배경이 복잡하거나 조명 편차가 심한 이미지에서는 Threshold나 커널 사이즈를 별도로 튜닝해야 한다. 하지만 단순한 형태의 세로형 이미지라면 이 파이프라인이 가장 안정적이고 재현성 있는 접근이라고 판단했다. 경계가 명확하지 않고 모호한 이미지 또한 이 알고리즘을 통한 분리가 불가능했다.</p>
<p>그 부분은 아마 인공지능을 이용하거나 다른 방법을 찾아야 할거 같다. 이제 이 부분을 파이프라인에 넣어 운영리소스의 공수를 효율적으로 줄이는 방법만 찾으면 될거 같다.</p>
]]></content:encoded></item><item><title><![CDATA[크롤링 파이프라인 개선기 - 코드 구조화]]></title><description><![CDATA[크롤링 기반 서비스를 만들다 보면 크롤링 결과를 그대로 운영에 넣을 수 있는 경우는 사실 거의 없다. 보통 실제 운영까지 가기 위해서는 아래와 같은 형태의 전처리 파이프라인을 무수히 많이 거치게 된다.
크롤링 → 데이터 정제(이미지 사이징, 중복 제거, ...) → 분류 → 기타 가공 → 검수 → 운영 배포

초기에는 이런 과정을 함수 호출로만 연결해도 큰 문제가 없었다. 하지만 서비스가 오래되고, 비즈니스 로직이 추가되고, 예외 케이스가 늘어...]]></description><link>https://roach-wiki.com/7ygs66gk66ebio2mjoydto2uhoudvoyducdqsjzshkdqulaglsdsvztrk5wg6rws7kgw7zmu</link><guid isPermaLink="true">https://roach-wiki.com/7ygs66gk66ebio2mjoydto2uhoudvoyducdqsjzshkdqulaglsdsvztrk5wg6rws7kgw7zmu</guid><dc:creator><![CDATA[roach]]></dc:creator><pubDate>Tue, 23 Dec 2025 05:44:59 GMT</pubDate><content:encoded><![CDATA[<p><img src="https://storage.googleapis.com/roach-wiki/images/9b0b63df-e379-4690-b819-23bf85a29b70.webp" alt="generated_image_for_blog.png" /></p>
<p>크롤링 기반 서비스를 만들다 보면 크롤링 결과를 그대로 운영에 넣을 수 있는 경우는 사실 거의 없다. 보통 실제 운영까지 가기 위해서는 아래와 같은 형태의 전처리 파이프라인을 무수히 많이 거치게 된다.</p>
<pre><code class="lang-plaintext">크롤링 → 데이터 정제(이미지 사이징, 중복 제거, ...) → 분류 → 기타 가공 → 검수 → 운영 배포
</code></pre>
<p>초기에는 이런 과정을 함수 호출로만 연결해도 큰 문제가 없었다. 하지만 서비스가 오래되고, 비즈니스 로직이 추가되고, 예외 케이스가 늘어나면서 어느 순간 코드가 점점 중간에서 끼어들고 비집고 들어오는 로직들이 생겨나다보면 읽기 힘들어지는 시점이 오게된다.</p>
<h2 id="heading-6rcc7isgioupmeq4sa">개선 동기</h2>
<p>새롭게 작업하기 위해 코드를 보다보니 기존에 이 코드에 익숙한 사람이 아니라면, 코드 자체의 흐름을 파악하기 어렵다는 생각이 들었다. 그 이유는 코드에는 아래와 같은 몇가지 문제들이 존재했기 때문이다.</p>
<ol>
<li><p><strong>코드의 실행 흐름을 함수를 하나하나 따라 읽어가며 파악해야 한다.</strong></p>
</li>
<li><p><strong>수십가지의 함수들의 입력/출력값을 단 한번에 보기 어렵다.</strong></p>
</li>
</ol>
<p>위와 같은 문제는 익숙하지 않은 상태에서 코드를 이해할때 필요치 않은 코스트를 생성하고, 코드를 작성하는 시점에 실수가 일어나기 쉬운 상태라고 생각이 들었다.</p>
<p>그래서 유지보수를 위해 코드를 해체해서 구조적으로 작성하게 끔 만들지 않으면 유지보수 비용이 꾸준히 늘어날 것 이고, 최대한 작업자가 현재 비즈니스 로직에 집중하여 코드의 작성하게 끔 만드는 것이 중요했다.</p>
<p>따라서 모든 프로세스가 처리되는 구조와 실행 흐름을 한눈에 어떤 순서로 실행되는지 알 수 있게끔 만들수 있는 구조로 변경하기로 했다.</p>
<h2 id="heading-pipeline">Pipeline 설계 방향</h2>
<p>핵심은 아래와 같이 매우 단순하게 만드는 것이다. 각 <code>Stage</code> 는 한가지의 책임만을 가질수 있도록 구조화하여 해당 부분에만 집중할 수 있게끔 만들고, <code>Pipeline</code> 은 이 연결된 단계들을 순차적으로 실행할 수 있게끔 한다.</p>
<ol>
<li><p>Stage는 하나의 입력을 받아 하나의 출력을 만든다.</p>
</li>
<li><p>StageResult로 <strong>성공/실패/부분성공을 일관되게 표현</strong>한다.</p>
</li>
<li><p>Pipeline은 <strong>Stage를 순차적으로 실행하면서 흐름을 제어</strong>한다.</p>
</li>
</ol>
<p>그래야 단계가 늘어나도 <strong>“단순한 연결”</strong> 처럼 읽히게 되고 파이프라인 도중에 <code>Stage</code>(새로운 비즈니스 로직) 을 추가하더라도 해당 부분에만 집중하고, 파이프라인에만 연결하면 되어 쉽다.</p>
<pre><code class="lang-python">TInput = TypeVar(<span class="hljs-string">"TInput"</span>)
TOutput = TypeVar(<span class="hljs-string">"TOutput"</span>)


<span class="hljs-comment"># 모든 Stage가 공통으로 반환하는 표준 구조</span>
<span class="hljs-class"><span class="hljs-keyword">class</span> <span class="hljs-title">StageStatus</span>(<span class="hljs-params">str, Enum</span>):</span>
    SUCCESS = <span class="hljs-string">"success"</span>
    PARTIAL_SUCCESS = <span class="hljs-string">"partial"</span>
    FAILURE = <span class="hljs-string">"failure"</span>
    SKIPPED = <span class="hljs-string">"skipped"</span>


<span class="hljs-comment"># StageResult는 Stage가 반환하는 유일한 타입</span>
<span class="hljs-meta">@dataclass</span>
<span class="hljs-class"><span class="hljs-keyword">class</span> <span class="hljs-title">StageResult</span>(<span class="hljs-params">Generic[TOutput]</span>):</span>
    status: StageStatus
    data: Optional[TOutput] = <span class="hljs-literal">None</span>
    errors: List[str] = field(default_factory=list)
    metrics: Dict[str, Any] = field(default_factory=dict)

<span class="hljs-meta">    @property</span>
    <span class="hljs-function"><span class="hljs-keyword">def</span> <span class="hljs-title">is_successful</span>(<span class="hljs-params">self</span>) -&gt; bool:</span>
        <span class="hljs-keyword">return</span> self.status <span class="hljs-keyword">in</span> (StageStatus.SUCCESS, StageStatus.PARTIAL_SUCCESS)

<span class="hljs-meta">    @property</span>
    <span class="hljs-function"><span class="hljs-keyword">def</span> <span class="hljs-title">should_continue</span>(<span class="hljs-params">self</span>) -&gt; bool:</span>
        <span class="hljs-keyword">return</span> self.status <span class="hljs-keyword">in</span> (
            StageStatus.SUCCESS,
            StageStatus.PARTIAL_SUCCESS,
            StageStatus.SKIPPED,
        )
</code></pre>
<p><code>StageResult</code> 를 생성하고 각 함수에서 이를 반환하게끔 한 이유는 모든 Stage의 반환 형태가 같으니 예측 가능해지므로 Result 를 활용하여 <code>Stage</code> 의 결과를 통일된 구조로 확인해볼 수 있다. 또한 Stage 를 진행시키는 Pipeline은 should_continue 하나만 보면 되므로 쉽다.</p>
<p><code>metric</code> 은 모니터링을 위한 편의성 기능으로 각 Stage는 필요한 데이터만 metrics로 추가하면 된다. DB 기록, 모니터링, Slack 알림 등 확장에 유연하게 가져가기 위함이다.</p>
<pre><code class="lang-python"><span class="hljs-class"><span class="hljs-keyword">class</span> <span class="hljs-title">PipelineStage</span>(<span class="hljs-params">ABC, Generic[TInput, TOutput]</span>):</span>
<span class="hljs-meta">    @property</span>
<span class="hljs-meta">    @abstractmethod</span>
    <span class="hljs-function"><span class="hljs-keyword">def</span> <span class="hljs-title">name</span>(<span class="hljs-params">self</span>) -&gt; str:</span>
        <span class="hljs-keyword">pass</span>

<span class="hljs-meta">    @abstractmethod</span>
    <span class="hljs-keyword">async</span> <span class="hljs-function"><span class="hljs-keyword">def</span> <span class="hljs-title">execute</span>(<span class="hljs-params">self, input_data: TInput</span>) -&gt; StageResult[TOutput]:</span>
        <span class="hljs-keyword">pass</span>

    <span class="hljs-keyword">async</span> <span class="hljs-function"><span class="hljs-keyword">def</span> <span class="hljs-title">on_success</span>(<span class="hljs-params">self, result: StageResult[TOutput]</span>) -&gt; <span class="hljs-keyword">None</span>:</span>
        <span class="hljs-keyword">pass</span>

    <span class="hljs-keyword">async</span> <span class="hljs-function"><span class="hljs-keyword">def</span> <span class="hljs-title">on_failure</span>(<span class="hljs-params">self, result: StageResult[TOutput]</span>) -&gt; <span class="hljs-keyword">None</span>:</span>
        <span class="hljs-keyword">pass</span>
</code></pre>
<p><code>Pipeline</code> 에서 <code>Stage</code> 의 핵심은 다음 두 가지로 아래와 같다.</p>
<ol>
<li><p><code>execute</code> 는 입력을 받아서 출력만 만든다. <strong>side-effect는 최소화하고, 필요하면 metrics에 기록하는 방식</strong>이다. <code>execute</code> 를 작성하는 팀원은 이 <code>Stage</code> 에서 Input 으로 무엇을 할지에만 집중하면 된다.</p>
</li>
<li><p><code>on_success / on_failure(옵션)</code> 와 같은 <strong>Hook 형태의 함수</strong>들로 각 <code>Stage</code> 가 성공하거나 실패했을때 알림을 발송하거나 특정 작업을 트리거하는 형태가 가능하다.</p>
</li>
</ol>
<h2 id="heading-pipeline-1">Pipeline</h2>
<p>Pipeline은 정말로 <strong>“흐름만”</strong> 관리하도록 만들었다. <strong>Stage를 정의된 순서대로 하나하나씩 실행</strong>시킨다.</p>
<ol>
<li><p>실패 시 즉시 중단</p>
</li>
<li><p>부분 성공 또는 성공이면 다음 Stage로 진행</p>
</li>
<li><p>마지막 Stage의 결과를 그대로 반환</p>
</li>
</ol>
<p>Production 에는 멀티 프로세스 환경을 대비하기 위한 <code>Message Queue</code> 를 놓아 Input 과 Output 을 여러 <code>worker</code> 에서 실행될 수 있게 하는 부분과 실행 <code>metadata</code> 를 저장하는 DB 연결부 부분도 존재한다. 예제에서는 복잡할 수 있어 코드를 최대한 간소화하였다</p>
<pre><code class="lang-python"><span class="hljs-class"><span class="hljs-keyword">class</span> <span class="hljs-title">Pipeline</span>:</span>
    <span class="hljs-function"><span class="hljs-keyword">def</span> <span class="hljs-title">__init__</span>(<span class="hljs-params">self, stages: List[PipelineStage]</span>):</span>
        <span class="hljs-keyword">if</span> <span class="hljs-keyword">not</span> stages:
            <span class="hljs-keyword">raise</span> ValueError(<span class="hljs-string">"Pipeline must have at least one stage"</span>)
        self.stages = stages

    <span class="hljs-keyword">async</span> <span class="hljs-function"><span class="hljs-keyword">def</span> <span class="hljs-title">run</span>(<span class="hljs-params">self, initial_input: Any = None</span>) -&gt; StageResult:</span>
        current_input = initial_input
        last_result: Optional[StageResult] = <span class="hljs-literal">None</span>

        <span class="hljs-keyword">for</span> index, stage <span class="hljs-keyword">in</span> enumerate(self.stages, <span class="hljs-number">1</span>):
            <span class="hljs-keyword">try</span>:
                result = <span class="hljs-keyword">await</span> stage.execute(current_input)

                <span class="hljs-comment"># Stage Hook 호출</span>
                <span class="hljs-keyword">if</span> result.is_successful:
                    <span class="hljs-keyword">await</span> stage.on_success(result)
                <span class="hljs-keyword">else</span>:
                    <span class="hljs-keyword">await</span> stage.on_failure(result)

                <span class="hljs-comment"># 실패면 즉시 종료</span>
                <span class="hljs-keyword">if</span> <span class="hljs-keyword">not</span> result.should_continue:
                    <span class="hljs-keyword">return</span> result

                <span class="hljs-comment"># 다음 Stage 에게 넘길 data</span>
                current_input = result.data
                last_result = result

            <span class="hljs-keyword">except</span> Exception <span class="hljs-keyword">as</span> e:
                error_msg = <span class="hljs-string">f"Unexpected error in '<span class="hljs-subst">{stage.name}</span>': <span class="hljs-subst">{e}</span>"</span>
                failure = StageResult(
                    status=StageStatus.FAILURE,
                    data=<span class="hljs-literal">None</span>,
                    errors=[error_msg],
                    metrics={<span class="hljs-string">"stage"</span>: stage.name},
                )
                <span class="hljs-keyword">await</span> stage.on_failure(failure)
                <span class="hljs-keyword">return</span> failure

        <span class="hljs-keyword">return</span> last_result <span class="hljs-keyword">or</span> StageResult(status=StageStatus.SUCCESS)
</code></pre>
<p>이 Pipeline은 매우 단순하게 정의된 <code>Stage</code> 를 처리할 수 있는 <code>Orchestrator</code> 이다. 단순하게 각 <code>Stage</code> 만을 순차적으로 처리해주는 역할을 진행한다.</p>
<h2 id="heading-6rce64uo7zwcioylpoygncdsmijsojw6io2brouhpoungsdihpig7j206647keaioygleygncdihpig67ae66wy">간단한 실제 예제: 크롤링 → 이미지 정제 → 분류</h2>
<p>이제 실제로 서비스에서 자주 쓰는 구조를 예제로 만들어보자.</p>
<ol>
<li>크롤링 Stage</li>
</ol>
<pre><code class="lang-python"><span class="hljs-class"><span class="hljs-keyword">class</span> <span class="hljs-title">CrawlStage</span>(<span class="hljs-params">PipelineStage[None, List[dict]]</span>):</span>
<span class="hljs-meta">    @property</span>
    <span class="hljs-function"><span class="hljs-keyword">def</span> <span class="hljs-title">name</span>(<span class="hljs-params">self</span>) -&gt; str:</span>
        <span class="hljs-keyword">return</span> <span class="hljs-string">"crawl"</span>

    <span class="hljs-keyword">async</span> <span class="hljs-function"><span class="hljs-keyword">def</span> <span class="hljs-title">execute</span>(<span class="hljs-params">self, _</span>):</span>
        items = <span class="hljs-keyword">await</span> crawl_products()
        <span class="hljs-keyword">return</span> StageResult(status=StageStatus.SUCCESS, data=items)
</code></pre>
<ol start="2">
<li>이미지 중복 제거 Stage</li>
</ol>
<pre><code class="lang-python"><span class="hljs-class"><span class="hljs-keyword">class</span> <span class="hljs-title">DeduplicateImageStage</span>(<span class="hljs-params">PipelineStage[List[dict], List[dict]]</span>):</span>
<span class="hljs-meta">    @property</span>
    <span class="hljs-function"><span class="hljs-keyword">def</span> <span class="hljs-title">name</span>(<span class="hljs-params">self</span>) -&gt; str:</span>
        <span class="hljs-keyword">return</span> <span class="hljs-string">"dedupe_images"</span>

    <span class="hljs-keyword">async</span> <span class="hljs-function"><span class="hljs-keyword">def</span> <span class="hljs-title">execute</span>(<span class="hljs-params">self, items</span>):</span>
        cleaned, errors = [], []

        <span class="hljs-keyword">for</span> item <span class="hljs-keyword">in</span> items:
            <span class="hljs-keyword">try</span>:
                item[<span class="hljs-string">"images"</span>] = remove_duplicates(item[<span class="hljs-string">"images"</span>])
                cleaned.append(item)
            <span class="hljs-keyword">except</span> Exception <span class="hljs-keyword">as</span> e:
                errors.append(str(e))

        status = StageStatus.PARTIAL_SUCCESS <span class="hljs-keyword">if</span> errors <span class="hljs-keyword">else</span> StageStatus.SUCCESS

        <span class="hljs-keyword">return</span> StageResult(
            status=status,
            data=cleaned,
            errors=errors,
            metrics={<span class="hljs-string">"input"</span>: len(items), <span class="hljs-string">"output"</span>: len(cleaned)},
        )
</code></pre>
<ol start="3">
<li>분류 Stage</li>
</ol>
<pre><code class="lang-python"><span class="hljs-class"><span class="hljs-keyword">class</span> <span class="hljs-title">ClassifyStage</span>(<span class="hljs-params">PipelineStage[List[dict], List[dict]]</span>):</span>
<span class="hljs-meta">    @property</span>
    <span class="hljs-function"><span class="hljs-keyword">def</span> <span class="hljs-title">name</span>(<span class="hljs-params">self</span>) -&gt; str:</span>
        <span class="hljs-keyword">return</span> <span class="hljs-string">"classify"</span>

    <span class="hljs-keyword">async</span> <span class="hljs-function"><span class="hljs-keyword">def</span> <span class="hljs-title">execute</span>(<span class="hljs-params">self, items</span>):</span>
        classified = []
        <span class="hljs-keyword">for</span> item <span class="hljs-keyword">in</span> items:
            item[<span class="hljs-string">"category"</span>] = <span class="hljs-keyword">await</span> classify(item)
            classified.append(item)
        <span class="hljs-keyword">return</span> StageResult(status=StageStatus.SUCCESS, data=classified)
</code></pre>
<pre><code class="lang-python">pipeline = Pipeline(
    stages=[
        CrawlStage(),
        DeduplicateImageStage(),
        ClassifyStage(),
    ]
)

result = <span class="hljs-keyword">await</span> pipeline.run()
</code></pre>
<p>이렇게 호출하면 읽는 사람으로 하여끔 전체 흐름이 아래처럼 읽힌다. <code>크롤링 → 중복제거 → 분류</code>. 보는 순간 어떻게 절차적으로 실행됨을 빠르게 알수 있으며, 내가 구현할 비즈니스 로직의 코드가 어디쯤 위치해야 하는지도 알기 쉽다.</p>
<p>결론적으로는 Stage마다 단일 책임 원칙이 지켜지고, Pipeline은 흐름을 제어할 뿐이며, 각 Stage 실행 결과는 StageResult로 표준화돼 있기 때문에 코드가 길어져도 읽기가 편하다.</p>
<h2 id="heading-66qo64ui7ysw66eb">모니터링</h2>
<p><img src="https://storage.googleapis.com/roach-wiki/images/638f5f81-3c68-4d07-8d3f-ed0d1d2c1cbe.webp" alt="image.png" /></p>
<p>구조를 바꾸며 얻게 된 결과 중 하나인데, 코드를 구조화 하여 결과물이 각 단계의 결과물을 저장하기 쉽게끔 변하여 각 단계에서 나오는 산출물을 통해 모니터링 대시보드를 구축하게 되었다. 각 단계에서의 결과를 확인하고, 해당 단계에서 추가적으로 모니터링 하고 싶은 부분들은 <code>StageResult</code> 에 넣기만 하면 자동으로 데이터베이스에 저장되고 모니터링 대시보드를 통해 출력되게 된다.</p>
<h2 id="heading-66ei7lmy66mw">마치며</h2>
<p>이번에 Pipeline 구조를 도입하면서 얻은 가장 큰 성과는 각 단계를 구조적으로 처리할 수 있게끔 만들었다는 점이다. 각 단계가 무엇을 책임지는지 명확해지고, 파이프라인 전체 흐름이 눈에 자연스럽게 들어오다 보니 새로운 비즈니스 로직을 추가하거나 기존 로직을 고치는 작업이 훨씬 편해졌다.</p>
<p>다만, 구현하며 살짝 아쉬운 포인트들도 있다. 개선하지 못한 부분을 정리해보면 아래와 같다.</p>
<p><strong>1) FastAPI DI 구조(FastAPI Depends)에 더 자연스럽게 녹아들도록 개선</strong></p>
<p>현재 구성은 우리 프로젝트 특성에 맞춰 살짝 바인딩되어 있어 DI 주입 방식이 Pipeline/Stage 바깥에 노출되는 경우가 몇 군데 있다. 이건 내부적으로는 큰 문제는 아니지만, Pipeline이 “독립적인 실행 단위"가 되려면 DI도 자연스럽게 숨겨져야 한다.</p>
<p>그래서 다음 단계에서는 Stage 내부에서 필요한 의존성들이 깔끔하게 캡슐화되는 구조로 리팩토링할 계획이다.</p>
<p><strong>2) Message Queue 추상화 레이어 분리</strong></p>
<p>프로덕션 환경에서 Pipeline은 여러 worker가 동시에 실행하므로 Message Queue 를 이용하게 되는데, 이 부분에서 자체 구현체인 QueueService 를 이용하다보니 <code>DI</code> 를 씀에도 쉽게 고치는것이 쉽지만은 않다. 따라서 이 부분을 Interface 로 사용하고, <code>DI</code> 를 통해 쉽게 끔 구현체를 갈아 낄 수 있게끔 구현해보려고 한다.</p>
<p><strong>3) DB Tracking / Execution Metadata 구조 독립화</strong></p>
<p>현재 Execution Metadata(DB에 저장되는 파이프라인 실행 기록)가 Pipeline 내부에서 직접 호출되는 형태로 되어 있다. 이건 편하긴 한데, 오픈소스 형태를 목표로 하면 <strong>“DB를 쓰지 않는 환경에서도 쓸 수 있는 구조”</strong> 로 만드는 게 맞을거 같다는 생각이 들었다.</p>
]]></content:encoded></item></channel></rss>