<?xml version="1.0" encoding="utf-8"?>
<?xml-stylesheet href="/feeds/atom-style.xsl" type="text/xsl"?>
<feed xmlns="http://www.w3.org/2005/Atom">
    <id>https://yousangson.github.io/</id>
    <title>Yousang</title>
    <updated>2026-09-10T09:27:05.625Z</updated>
    <generator>Astro-Theme-Retypeset with Feed for Node.js</generator>
    <author>
        <name>Yousang</name>
        <uri>https://yousangson.github.io/</uri>
    </author>
    <link rel="alternate" href="https://yousangson.github.io/"/>
    <link rel="self" href="https://yousangson.github.io/atom.xml"/>
    <subtitle>백엔드 개발과 시스템 설계, 문제를 해결하며 배운 것을 기록합니다.</subtitle>
    <rights>Copyright © 2026 Yousang</rights>
    <entry>
        <title type="html"><![CDATA[오류 로그의 빈칸에는 이유가 있어야 한다]]></title>
        <id>https://yousangson.github.io/posts/diagnostic-capture-status-and-trusted-identity/</id>
        <link href="https://yousangson.github.io/posts/diagnostic-capture-status-and-trusted-identity/"/>
        <updated>2026-09-10T00:00:00.000Z</updated>
        <summary type="html"><![CDATA[요청 진단에서 값과 수집 상태를 함께 남기고, 사용자가 보낸 식별자와 서버가 확인한 객체를 구분한 경험을 정리한다.]]></summary>
        <content type="html"><![CDATA[<p>400 응답과 Trace ID가 남아 있어도 어떤 입력이 거절됐는지 알 수 없는 경우가 있다. 상세 화면에 필요한 값이 비어 있으면 요청을 찾는 데서 조사가 멈춘다. 이번 주 요청 진단 작업에서 다룬 문제도 이 간격이었다.</p>
<p>이번 주에는 이 간격을 줄이는 작업을 했다. 요청과 응답을 무조건 복사하는 방식은 쓸 수 없었다. 진단 저장소에 인증정보나 파일 내용이 쌓이면 문제 하나를 고치면서 다른 문제를 만들기 때문이다.</p>
<p>아래 경로와 값은 설명을 위해 재구성한 예시다. 실제 서비스의 식별자와 로그 원문은 사용하지 않았다.</p>
<h2>빈 객체 하나로는 설명할 수 없는 상태</h2>
<p>요청 본문이 <code>{}</code>로 보일 때 가능한 이유는 여러 가지다. 클라이언트가 빈 JSON을 보냈을 수도 있고 인증 단계에서 요청이 끝나 본문을 읽지 않았을 수도 있다. 파일 업로드라서 JSON 수집 대상이 아니었을 수도 있다.</p>
<p>이 상태들을 모두 빈 객체로 바꾸면 보는 사람은 빈칸의 의미를 추측해야 한다. 그래서 값과 수집 상태를 함께 다뤘다.</p>
<table>
<thead>
<tr>
<th>상태 예시</th>
<th>해석</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>captured</code></td>
<td>허용된 범위에서 값을 수집했다</td>
</tr>
<tr>
<td><code>not_read</code></td>
<td>handler가 본문을 읽지 않았거나 끝까지 읽기 전에 처리가 종료됐다</td>
</tr>
<tr>
<td><code>too_large</code></td>
<td>크기 제한을 넘어 수집하지 않았다</td>
</tr>
<tr>
<td><code>non_json</code></td>
<td>JSON 수집 대상이 아닌 형식이었다</td>
</tr>
<tr>
<td><code>policy_omitted</code></td>
<td>정책에 따라 값을 남기지 않았다</td>
</tr>
</tbody>
</table>
<p>상태는 요청 전체에 하나만 붙이면 부족하다. 경로와 query는 읽었지만 본문은 수집하지 않은 요청도 있기 때문이다. 예를 들어 진단 정보를 아래처럼 해석할 수 있어야 한다. 실제 API 응답을 복사한 형식은 아니다.</p>
<pre><code>{
  "path": {"state": "captured", "values": {"id": "document-example"}},
  "query": {"state": "captured", "values": {"tag": ["guide", "draft"]}},
  "body": {"state": "policy_omitted"}
}
</code></pre>
<p>query의 반복값도 배열로 남긴다. <code>?tag=guide&amp;tag=draft</code>를 문자열 하나로 줄이면 서버에 들어온 입력과 화면에서 읽는 입력이 달라진다.</p>
<h2>요청에 적힌 ID는 처리한 객체의 ID가 아니다</h2>
<p>경로 값을 보여주고 나면 다음 문제가 생긴다. <code>/documents/document-example</code>로 요청했다고 해서 서버가 그 문서를 찾았거나 접근을 허용했다는 뜻은 아니다.</p>
<pre><code>요청 경로의 ID       → 클라이언트가 보낸 주장
조회·권한 확인 후 ID → 서버가 확인한 처리 대상
</code></pre>
<p>실패한 요청의 경로 파라미터는 진단용 입력으로 남길 수 있다. 하지만 그 값을 그대로 처리 객체 필드에 넣으면 존재하지 않는 문서를 처리한 것처럼 보인다. 권한 검사 전에 거절된 요청도 같은 문제가 생긴다.</p>
<p>입력과 확인된 객체를 분리하면 같은 Trace ID 안에서도 해석이 명확해진다. 무엇을 요청했는지와 무엇을 실제로 처리했는지가 각각 드러난다. 사용자 입력을 사실로 승격하지 않는 경계다.</p>
<h2>진단을 위해 요청을 더 읽을 때의 비용</h2>
<p>본문을 수집하려고 handler보다 먼저 스트림을 끝까지 읽으면 업무 요청의 동작 자체를 바꿀 수 있다. 느린 클라이언트가 보내는 본문을 기다리느라 오류 응답이 늦어질 수도 있다.</p>
<p>이번 구현은 인증된 요청 중 크기가 알려진 작은 JSON에 한해 제한된 시간 동안 관측 가능한 본문을 보완해서 읽었다. 길이가 불명확한 전송, 초과하거나 미완성인 본문, <code>Expect: 100-continue</code>, multipart와 바이너리는 값 대신 수집 상태로 설명했다.</p>
<p>multipart 안에 JSON 이벤트가 들어 있는 경로는 handler가 엄격한 decode를 끝낸 뒤 논리 이벤트를 등록했다. 업무 값 검증에서 거절됐다면 decode된 필드를 볼 수 있지만 JSON 문법이나 타입부터 틀렸다면 부분적으로 채워진 객체를 정상 입력인 것처럼 기록하지 않는다.</p>
<p>진단 코드도 요청 처리의 일부다. 수집량과 대기 시간에 상한이 있어야 오류를 설명하는 기능 때문에 원래 오류 응답이 더 늦어지는 일을 피할 수 있다.</p>
<h2>정제는 저장 전과 조회 후에 필요했다</h2>
<p>관리자만 보는 화면이어도 인증정보를 저장할 이유가 생기지는 않는다. <a href="https://cheatsheetseries.owasp.org/cheatsheets/Logging_Cheat_Sheet.html#data-to-exclude">OWASP Logging Cheat Sheet</a>도 접근 토큰과 비밀번호 같은 값을 로그에 직접 기록하지 않도록 안내한다.</p>
<p>이번 작업에서는 생산 단계에서 민감 값을 정제하고 저장된 값을 관리자 응답으로 바꾸는 경계에서도 다시 정제했다. 이전 버전이 저장한 데이터나 예상과 다른 저장 형식이 들어오는 경우까지 같은 화면이 처리하기 때문이다. 다운로드용 서명 URL과 파일 본문도 그대로 옮기지 않았다.</p>
<p>필드 이름만 보는 규칙으로는 부족한 경우가 있다. 설정 목록의 <code>value</code>가 공개 설정인지 비밀 설정인지는 같은 객체의 key와 schema가 결정한다. schema 조회에 실패했는데 공개 값으로 간주하면 단순한 문자열 마스킹으로는 막기 어렵다. 공개 여부를 확인하지 못한 값은 숨기는 쪽으로 처리했다.</p>
<p>상세 조회 자체가 다시 활동 기록을 만든다는 점도 고려했다. 조회 응답을 다음 진단 본문에 복사하면 민감한 상세가 계속 중첩될 수 있다. 따라서 상세 조회 경로는 조회 사실을 남기되 query와 본문 등 재귀 수집 대상은 정책상 생략했다.</p>
<h2>무엇을 검증했는가</h2>
<p>로컬 회귀 검사는 성공·실패 요청의 path와 반복 query, 읽을 수 있는 JSON뿐 아니라 본문 읽기가 지연되는 경우와 수집하지 않는 경우도 다뤘다. 관리자 응답과 화면에서는 수집 상태가 값 옆에 남고 민감 값은 노출되지 않는지 확인했다.</p>
<p>당시 서버의 저장소 검증 명령과 관리자 화면의 전체 검증 명령은 통과했다. 다만 이것은 로컬 코드와 테스트의 결과다. 배포된 producer에서 메시지 전달과 저장을 거쳐 화면까지 같은 정보가 도착하는지는 별도로 확인할 일이다. Trace ID가 있다고 다른 서비스까지 전파됐다고 단정할 수도 없다.</p>
<p>자기 서비스에 적용할 때는 같은 API에 두 요청을 보내보면 좋다. 하나는 작은 정상 JSON, 다른 하나는 인증 단계에서 거절되는 요청이다. 두 상세가 모두 빈 본문으로만 보인다면 아직 수집 상태를 구분하지 못한다. 정상 입력과 숨긴 값, 읽지 않은 값을 화면에서 서로 다르게 설명할 수 있어야 한다.</p>
]]></content>
        <author>
            <name>Yousang</name>
            <uri>https://yousangson.github.io/</uri>
        </author>
        <published>2026-09-10T00:00:00.000Z</published>
    </entry>
    <entry>
        <title type="html"><![CDATA[삭제 코드를 옮길 때는 실행 책임까지 옮겨야 한다]]></title>
        <id>https://yousangson.github.io/posts/moving-cleanup-execution-ownership/</id>
        <link href="https://yousangson.github.io/posts/moving-cleanup-execution-ownership/"/>
        <updated>2026-09-10T00:00:00.000Z</updated>
        <summary type="html"><![CDATA[파일 정리 로직을 모듈로 옮기면서 기존 CLI 계약과 소유권 재검사를 유지하고, 예전 계층으로 로직이 돌아오지 않게 검증한 경험을 정리한다.]]></summary>
        <content type="html"><![CDATA[<p>파일 정리 함수를 새 모듈로 옮겼는데도 예전 서비스가 여전히 삭제 후보를 고르고 삭제 여부를 판단한다면, 무엇이 달라진 걸까. 파일 위치는 바뀌었지만 실제 실행 책임은 남아 있다.</p>
<p>이번 주에는 업로드 뒤 늦게 남은 파생 파일을 정리하는 코드를 모듈로 옮겼다. 단건 복구와 전체 순회가 대상이었다. 기존 CLI는 그대로 쓸 수 있어야 했고, 현재 사용 중인 파일을 보호하는 검사도 유지해야 했다.</p>
<p>삭제 코드는 반환값이 같다는 이유만으로 안전하다고 보기 어렵다. 같은 개수의 객체를 지워도 그중 하나가 현재 사용 중인 파일의 객체라면 실패다. 그래서 이번 이동에서는 코드의 위치보다 누가 후보를 고르고 누가 소유권을 확인하고 누가 삭제를 실행하는지부터 정리했다. 아래 이름과 코드는 구조를 설명하기 위해 재구성했다.</p>
<h2>늦게 남은 파일과 버려진 파일은 다르다</h2>
<p>본문 처리나 변환 작업이 요청보다 늦게 끝나면 원래 작업이 종료된 뒤에도 파생 객체가 남을 수 있다. 그렇다고 특정 확장자나 디렉터리에 있는 파일을 모두 지워도 되는 것은 아니다. 같은 위치에 정상 업로드가 있거나 현재 파일이 그 객체를 참조할 수 있다.</p>
<p>단건 정리의 중요한 순서는 다음과 같았다.</p>
<pre><code>정리할 원본 위치 확인
  → 현재 살아 있는 참조인지 검사
  → 후보를 찾고 소유권으로 필터링
  → 실제 삭제 직전에 소유권 재확인
  → 남은 정확한 key만 삭제
</code></pre>
<p>이 순서는 단순한 준비 코드가 아니다. 삭제 권한을 판단하는 과정이다. 새 모듈을 만들었다는 이유로 이 검사를 호출자에게 흩어 놓으면, 다음 호출자가 하나를 빠뜨릴 때 안전 조건이 달라진다.</p>
<p>전체 순회에도 별도 계약이 있었다. 허용한 bucket과 key만 대상으로 삼고 목록의 다음 marker가 전진하지 않으면 실패로 처리했다. 삭제 실패와 제한된 결과 표본도 기존 방식대로 유지했다. 대량 순회가 끝나지 않거나 일부 실패를 성공으로 숨기는 문제는 모듈 이동 중에도 그대로 방어해야 했다.</p>
<h2>호환 계층은 남기고 실행은 한곳으로 모았다</h2>
<p>기존 CLI가 사용하는 함수와 결과 타입을 모두 바꾸면 코드 이동과 호출자 수정이 한 diff에 섞인다. 이번에는 기존 진입점을 남기고 구현만 새 모듈로 위임했다.</p>
<p>축약하면 다음 구조다.</p>
<pre><code>// 기존 패키지: 호출 형태와 결과 타입을 유지한다.
type CleanupResult = content.CleanupResult

func Cleanup(ctx context.Context, key string) (CleanupResult, error) {
    return content.Cleanup(ctx, key)
}
</code></pre>
<p>이 코드는 실행 예제가 아니라 의존 방향을 나타내는 예시다. 별도 wrapper 결과를 만들어 복사하지 않고 Go의 type alias로 같은 타입을 가리켰다. 기존 CLI는 같은 진입점을 호출하지만 후보 조회와 소유권 판단, 삭제 호출은 새 모듈이 맡는다. 스토리지별 실제 삭제 전략은 기존 조립 계층에서 주입했다. 저장소 구현까지 전부 새로 옮긴 것은 아니다.</p>
<p>이번 단계에서는 호환 함수의 존재보다 그 안에 후보 선정과 삭제 판단이 남아 있는지를 봤다. 전체 전환에서 호환 계층을 언제 제거할지는 별도 과제다. 단순 위임이면 호출자 전환 시기를 나눌 수 있다. 반대로 wrapper가 후보를 추가로 조회하거나 삭제 조건을 바꾸면 책임은 다시 두 군데로 갈라진다.</p>
<p>새 모듈이 예전 서비스 패키지를 다시 import하는 것도 막았다. 기존 계층이 새 모듈을 호출하고 새 모듈은 자기 구현을 실행하는 방향이 유지돼야 한다.</p>
<h2>동작 검사와 구조 검사는 다른 질문에 답한다</h2>
<p>기존 테스트를 통과하면 이동 전후 동작을 비교할 근거가 생긴다. 하지만 그것만으로 구현이 올바른 계층에 있는지까지 알 수는 없다. 모든 실행을 예전 함수로 되돌려도 결과만 보는 테스트는 통과할 수 있기 때문이다.</p>
<p>그래서 구조 검사도 남겼다. 기존 호환 계층에서 쓰던 특정 DB 조회·스토리지 삭제 메서드가 다시 들어오지 않았는지, 새 모듈이 예전 패키지를 역으로 참조하지 않는지 확인하는 검사다. 모든 가능한 우회 호출을 증명하는 검사는 아니다.</p>
<p>이 검사는 이미 정리한 경계를 다시 흐리지 못하게 하는 장치다. 모든 함수 이름이나 파일 줄 수를 고정할 필요는 없다. 실제로 금지하려는 의존 방향과 실행 호출만 검사하면 된다. 검사 범위가 너무 넓으면 다음 리팩터링의 정상 변경까지 막는다.</p>
<p>동작 검사는 무엇을 지우고 무엇을 남겼는지 확인한다. 구조 검사는 그 결정을 어느 계층이 맡는지 확인한다. 둘 중 하나로 다른 하나를 대신하지 않았다.</p>
<h2>이동을 검증한 범위</h2>
<p>해당 이동의 집중 테스트는 통과했다. 전체 검증의 첫 실행은 사용하지 않는 alias 때문에 실패했다. 이를 제거한 뒤 전체 lint를 다시 통과시켰고 실제 로컬 DB와 객체 저장소를 사용하는 정리 통합 테스트도 통과했다.</p>
<p>이 기록을 전체 리팩터링 완료로 해석하지는 않았다. 특정 정리 경로의 이동과 로컬 검증만 확인했다. 배포된 여러 인스턴스가 동시에 파일을 만들고 지우는 상황이나 운영 저장소의 전체 정리는 별도 증거가 필요하다.</p>
<p>삭제 직전 재검사도 원자적 잠금은 아니다. 검사 뒤 삭제 사이에 다른 writer가 객체를 만들거나 참조를 갱신할 수 있다. 모듈 이동은 이 경쟁을 자동으로 해결하지 않는다. 운영에서 전체 정리를 실행하려면 writer 정지나 generation·claim 같은 별도 실행 경계, 실행 후 잔여 후보 확인 같은 조건을 따로 검토해야 한다.</p>
<h2>다음 코드 이동에서 확인할 것</h2>
<p>다른 기능을 모듈로 옮길 때도 기존 공개 함수를 하나 골라 끝까지 따라가 보면 된다. 진입점 아래에서 누가 저장소를 읽고 권한이나 소유권을 판단하고 실제 변경을 실행하는지 표시한다.</p>
<p>새 폴더가 생겼는데 이 세 작업이 예전 계층에 남아 있다면 실행 책임은 아직 그대로다. 기존 호출자가 새 모듈까지 도달하고 되돌아오는 의존이 없으며 기존 안전 검사가 그 경로 안에 남아 있는지 확인한다. 모듈의 경계는 디렉터리 이름보다 이 호출 흐름에 더 선명하게 드러난다.</p>
]]></content>
        <author>
            <name>Yousang</name>
            <uri>https://yousangson.github.io/</uri>
        </author>
        <published>2026-09-10T00:00:00.000Z</published>
    </entry>
    <entry>
        <title type="html"><![CDATA[취소는 스레드를 죽이지 않는다: Python, Rust, Kotlin의 실행 중단 경계]]></title>
        <id>https://yousangson.github.io/posts/cancellation-does-not-kill-running-threads/</id>
        <link href="https://yousangson.github.io/posts/cancellation-does-not-kill-running-threads/"/>
        <updated>2026-09-04T00:00:00.000Z</updated>
        <summary type="html"><![CDATA[asyncio.to_thread, Tokio spawn_blocking, Kotlin coroutine과 MCP 요청을 취소할 때 실제로 멈추는 것과 계속 실행되는 것을 구분한다.]]></summary>
        <content type="html"><![CDATA[<p><code>task.cancel()</code>은 성공했다. 호출자는 곧바로 <code>CancelledError</code>를 받았다. 그런데 별도 스레드에서 시작한 작업은 잠시 뒤 정상적으로 끝났다.</p>
<pre><code>import asyncio
import threading
import time

finished = threading.Event()


def blocking_work():
    time.sleep(0.25)
    finished.set()


async def main():
    task = asyncio.create_task(asyncio.to_thread(blocking_work))
    await asyncio.sleep(0.05)

    task.cancel()
    try:
        await task
    except asyncio.CancelledError:
        print("awaiting_task=cancelled")

    print(f"thread_finished_immediately={finished.is_set()}")
    await asyncio.sleep(0.30)
    print(f"thread_finished_later={finished.is_set()}")


asyncio.run(main())
</code></pre>
<p>실행 결과는 다음과 같았다.</p>
<pre><code>awaiting_task=cancelled
thread_finished_immediately=False
thread_finished_later=True
</code></pre>
<p>취소된 것은 무엇이고, 계속 실행된 것은 무엇일까? 이 차이를 놓치면 에이전트나 스케줄러에서 사용자가 실행을 취소했는데도 외부 API 호출이 계속되고, 정상 취소가 도구 장애로 기록되거나, 자동 재시도로 같은 작업이 다시 수행될 수 있다.</p>
<h2>취소는 실행 단위마다 의미가 다르다</h2>
<p>취소를 하나의 동작으로 보면 혼란스럽다. 실제로는 다음 세 가지가 따로 움직인다.</p>
<pre><code>flowchart LR
    A[호출자] --&gt;|취소 요청| B[Task 또는 Future]
    B --&gt;|결과 대기 중단| A
    B -.취소 전달.-&gt; C[실행 중인 작업]
    C --&gt;|지원하는 경우만 중단| D[하위 I/O 또는 원격 서버]
</code></pre>
<ol>
<li>호출자가 결과를 기다리는 일을 중단한다.</li>
<li>런타임이 실행 중인 작업에 취소 신호를 전달한다.</li>
<li>실제 작업과 하위 시스템이 신호를 이해하고 안전한 지점에서 멈춘다.</li>
</ol>
<p>첫 번째 단계가 끝났다고 세 번째 단계까지 끝났다고 볼 수 없다. 이 원칙은 Python만의 제약이 아니다. Rust와 Kotlin도 async 작업과 blocking 작업의 경계에서 같은 차이를 가진다.</p>
<h2>Python: <code>to_thread()</code>는 스레드 종료 API가 아니다</h2>
<p>Python의 <a href="https://docs.python.org/3/library/asyncio-task.html#asyncio.to_thread"><code>asyncio.to_thread()</code></a>는 blocking 함수를 별도 스레드에서 실행하고 그 결과를 기다릴 수 있게 한다. 개념적으로 핵심은 다음 한 줄이다.</p>
<pre><code>return await loop.run_in_executor(None, func_call)
</code></pre>
<p>호출자가 취소되면 <code>await</code>는 중단된다. 그러나 executor에 제출된 함수가 이미 실행 중이라면 상황이 다르다. <a href="https://docs.python.org/3/library/concurrent.futures.html#concurrent.futures.Future.cancel"><code>concurrent.futures.Future.cancel()</code></a>은 작업이 실행 중이거나 완료됐다면 <code>False</code>를 반환한다.</p>
<pre><code>대기열에 있음 → 시작을 취소할 수 있음
이미 RUNNING  → 스레드는 계속 실행됨
</code></pre>
<p>Python이 임의의 스레드에 예외를 주입해 죽이지 않는 데는 이유가 있다. 스레드가 lock을 획득한 직후이거나, 파일을 쓰는 중이거나, 트랜잭션의 절반을 처리한 시점일 수 있다. 그 위치에서 갑자기 사라지면 공유 상태와 자원 정리를 보장할 수 없다.</p>
<p>따라서 다음과 같은 blocking 요청은 바깥 task만 취소해서는 멈추지 않는다.</p>
<pre><code>def upload_file():
    return requests.post(url, files=files, timeout=300)


await asyncio.to_thread(upload_file)
</code></pre>
<p>호출자는 취소됐지만 <code>requests.post()</code>는 응답을 받거나 300초 timeout이 발생할 때까지 실행될 수 있다. 이미 서버가 업로드를 commit했다면 그 결과도 취소로 되돌아가지 않는다.</p>
<h2>Rust: async task는 중단할 수 있지만 <code>spawn_blocking</code>은 다르다</h2>
<p>Rust 표준 라이브러리의 <a href="https://doc.rust-lang.org/std/thread/struct.JoinHandle.html"><code>std::thread::JoinHandle</code></a>은 thread를 기다리는 <code>join()</code>을 제공한다. 반대로 thread를 강제로 죽이는 안전한 표준 API는 없다. <code>JoinHandle</code>을 drop하면 thread가 종료되는 것이 아니라 detach되어 계속 실행된다.</p>
<p>Tokio의 async task는 조금 다르다. <a href="https://docs.rs/tokio/latest/tokio/task/struct.JoinHandle.html#method.abort"><code>JoinHandle::abort()</code></a>를 호출하면 task가 다음 <code>.await</code>에서 runtime에 제어권을 돌려줄 때 취소된다. task 내부의 값은 destructor를 거쳐 정리된다.</p>
<pre><code>let handle = tokio::spawn(async {
    long_async_operation().await;
});

handle.abort();
let result = handle.await;
assert!(result.unwrap_err().is_cancelled());
</code></pre>
<p>하지만 async 코드가 제어권을 돌려주지 않는 긴 계산을 수행하면 취소도 그동안 처리되지 않는다.</p>
<p>Python의 <code>to_thread()</code>와 대응되는 Tokio API는 <a href="https://docs.rs/tokio/latest/tokio/task/fn.spawn_blocking.html"><code>spawn_blocking()</code></a>이다. 공식 문서도 이미 시작된 blocking task에는 <code>abort()</code>가 효과가 없다고 명시한다.</p>
<pre><code>let handle = tokio::task::spawn_blocking(|| {
    blocking_http_call()
});

handle.abort(); // 이미 실행 중이면 작업은 계속된다.
</code></pre>
<p>Rust로 다시 작성하면 메모리 안전성과 타입 모델의 이점은 얻을 수 있다. 그러나 blocking 작업을 외부에서 즉시 죽이는 문제 자체가 사라지지는 않는다.</p>
<h2>Kotlin: coroutine 취소와 thread interrupt는 별개다</h2>
<p>Kotlin coroutine의 <code>Job.cancel()</code>도 협력적 취소다. suspending 함수가 취소를 확인하거나 코드가 <code>isActive</code>, <code>ensureActive()</code>, <code>yield()</code> 같은 지점에서 상태를 확인해야 한다.</p>
<p>다음 코드는 timeout이 발생해도 <code>Thread.sleep()</code>이 끝날 때까지 thread를 계속 막을 수 있다.</p>
<pre><code>withTimeout(500) {
    Thread.sleep(10_000)
}
</code></pre>
<p>Kotlin 공식 <a href="https://kotlinlang.org/api/kotlinx.coroutines/kotlinx-coroutines-core/kotlinx.coroutines/with-timeout.html"><code>withTimeout</code></a> 문서도 cancellation은 cooperative하며, blocking JVM 코드는 자동으로 모두 멈추지 않는다고 설명한다.</p>
<p>Kotlin/JVM에는 한 단계 더 나아간 <a href="https://kotlinlang.org/api/kotlinx.coroutines/kotlinx-coroutines-core/kotlinx.coroutines/run-interruptible.html"><code>runInterruptible()</code></a>이 있다.</p>
<pre><code>withTimeout(500) {
    runInterruptible(Dispatchers.IO) {
        blockingQueue.take()
    }
}
</code></pre>
<p>coroutine이 취소되면 실행 중인 Java thread에 <code>Thread.interrupt()</code>를 전달한다. <code>BlockingQueue.take()</code>처럼 interrupt를 지원하는 API는 <code>InterruptedException</code>을 내고 멈출 수 있다.</p>
<p>하지만 <code>runInterruptible()</code>도 강제 thread kill은 아니다. 호출한 라이브러리가 interrupt를 무시하거나 interrupt로 깨울 수 없는 native I/O에 들어가 있으면 즉시 종료되지 않는다. Kotlin/Native에는 JVM과 같은 thread interrupt도 없다.</p>
<h2>언어를 바꾸면 해결되는가</h2>
<p>주요 런타임은 대부분 임의의 in-process thread를 강제로 죽이는 기능을 제공하지 않거나 위험하다고 본다.</p>
<table>
<thead>
<tr>
<th>환경</th>
<th>일반적인 취소 방식</th>
<th>이미 실행 중인 blocking 작업</th>
</tr>
</thead>
<tbody>
<tr>
<td>Python <code>asyncio</code></td>
<td><code>Task.cancel()</code></td>
<td><code>to_thread()</code> 작업은 계속될 수 있음</td>
</tr>
<tr>
<td>Rust Tokio</td>
<td><code>JoinHandle::abort()</code></td>
<td><code>spawn_blocking()</code>은 시작 후 abort 불가</td>
</tr>
<tr>
<td>Kotlin coroutine</td>
<td><code>Job.cancel()</code></td>
<td>협력적 확인 필요</td>
</tr>
<tr>
<td>Kotlin/JVM</td>
<td><code>runInterruptible()</code></td>
<td>대상 API가 interrupt를 지원할 때만 중단</td>
</tr>
<tr>
<td>Java</td>
<td><code>Thread.interrupt()</code></td>
<td>interrupt를 확인하거나 지원해야 함</td>
</tr>
<tr>
<td>Go</td>
<td><code>context.Context</code></td>
<td>goroutine이 <code>Done()</code>을 확인해야 함</td>
</tr>
<tr>
<td>현대 .NET</td>
<td><code>CancellationToken</code></td>
<td>작업이 token을 확인해야 함</td>
</tr>
<tr>
<td>Node.js Worker</td>
<td><code>worker.terminate()</code></td>
<td>실행 중간에 종료 가능, 일관성 주의</td>
</tr>
<tr>
<td>별도 process/container</td>
<td>종료 신호</td>
<td>강제 종료 가능, 외부 side effect는 유지</td>
</tr>
</tbody>
</table>
<p>Java의 <code>Thread.stop()</code>은 공유 객체가 불완전한 상태로 노출될 수 있어 제거 방향이고, 현대 .NET의 <code>Thread.Abort()</code>도 지원되지 않는다. Go의 <code>CancelFunc</code> 역시 작업을 중단하라는 신호일 뿐 작업 종료를 기다리거나 goroutine을 강제 종료하지 않는다.</p>
<p>Node.js Worker처럼 runtime이 별도로 관리하는 worker는 terminate할 수 있다. 그러나 실행이 어느 지점에서 멈출지 알 수 없다는 문제와 외부 시스템에 이미 반영된 결과는 그대로 남는다.</p>
<p>결국 선택 기준은 언어가 아니라 격리 수준이다.</p>
<pre><code>cooperative cancellation이 충분하다
    → coroutine/task + cancellation token

blocking 작업이 취소 신호를 지원한다
    → token/event/interrupt + 짧은 I/O timeout

신뢰할 수 없는 작업을 반드시 강제 종료해야 한다
    → 별도 process 또는 container
</code></pre>
<h2>MCP 취소도 “중단 요청”이지 rollback이 아니다</h2>
<p>에이전트가 원격 MCP 도구를 실행하고 있다면 한 단계가 더 생긴다. 로컬 task 취소가 원격 MCP 서버의 작업 종료까지 자동으로 보장하지 않는다.</p>
<p>MCP의 <a href="https://modelcontextprotocol.io/specification/2024-11-05/basic/utilities/cancellation"><code>notifications/cancelled</code></a>는 진행 중인 request ID와 선택적인 이유를 전송한다. 수신자는 가능하면 처리를 멈추고 자원을 해제해야 하지만, 이미 완료됐거나 취소할 수 없는 요청은 알림을 무시할 수 있다. 취소 뒤 늦게 도착한 응답은 송신자가 무시해야 한다.</p>
<p>장기 작업을 위한 MCP Tasks의 <a href="https://modelcontextprotocol.io/extensions/tasks/overview"><code>tasks/cancel</code></a>도 cooperative하고 eventually consistent하다. 서버는 취소 의도를 확인하지만 실제 작업이 반드시 멈추거나 반드시 <code>cancelled</code> 상태가 된다고 보장하지 않는다.</p>
<p>따라서 에이전트 실행 취소는 다음 의미로 정의하는 편이 안전하다.</p>
<blockquote>
<p>취소 이후에는 새 작업을 시작하지 않고, 늦게 도착한 결과를 채택하지 않으며, 취소된 실행을 자동 재시도하지 않는다.</p>
</blockquote>
<p>이 정의에는 이미 commit된 외부 작업의 rollback이 포함되지 않는다.</p>
<h2>취소를 실패로 기록하지 않는다</h2>
<p>구현에서 자주 생기는 버그는 cancellation 예외를 일반 예외로 바꾸는 것이다.</p>
<pre><code>try:
    await connect_to_tool_server()
except asyncio.CancelledError:
    raise RuntimeError("tool server timeout")  # 잘못된 분류
</code></pre>
<p>이렇게 하면 사용자가 취소한 실행이 도구 장애로 기록된다. 상위 스케줄러는 실패 정책에 따라 알림을 보내거나 실행을 재시도할 수 있다.</p>
<p>취소는 실행 경계를 통과하는 동안 취소로 유지해야 한다.</p>
<pre><code>try:
    await connect_to_tool_server()
except asyncio.CancelledError:
    raise
</code></pre>
<p>최상위 실행 경계에서만 취소를 terminal outcome으로 변환한다. 취소 의도가 먼저 확정됐다면 그 뒤에 도착한 도구 오류나 늦은 성공보다 취소가 우선한다.</p>
<pre><code>cancel intent 저장
    → task와 원격 요청에 취소 전달
    → 새 tool call 차단
    → 늦은 결과 폐기
    → 실행 상태를 cancelled로 종료
    → 실행이 소유한 자원 정리
</code></pre>
<p>연결 cleanup은 취소에 같이 휩쓸리지 않도록 제한된 시간 동안 보호해야 한다. 반대로 cleanup이 실패했다고 취소된 실행을 <code>failed</code>로 뒤집어서는 안 된다. cleanup 실패는 별도의 warning과 metric으로 남기는 것이 낫다.</p>
<h2>외부 side effect는 취소가 아니라 별도 설계로 다룬다</h2>
<p>이메일 전송, 결제, 파일 업로드, 배포 요청처럼 외부 상태를 바꾸는 도구가 있다고 하자.</p>
<pre><code>클라이언트: 취소 요청 전송
서버: 이미 이메일 발송 완료
클라이언트: 응답을 받기 전에 task 취소
</code></pre>
<p>이 상황에서 로컬 상태는 <code>cancelled</code>일 수 있지만 이메일은 이미 발송됐다. 이를 <code>failed</code>로 기록하는 것도, rollback됐다고 표시하는 것도 사실과 다르다.</p>
<p>외부 side effect에는 취소와 별도로 다음 장치가 필요하다.</p>
<ul>
<li>동일 실행의 중복 요청을 막는 idempotency key</li>
<li>commit 전후를 구분하는 원격 operation ID</li>
<li>실제 지원되는 경우에만 제공하는 cancel API</li>
<li>취소가 불가능한 작업의 compensation 절차</li>
<li>“일부 외부 작업이 완료됐을 수 있음”을 표시하는 UI</li>
</ul>
<p>모든 도구를 처음부터 별도 process로 감싸는 것은 과하다. 먼저 cancellation-aware async API, bounded timeout, 늦은 결과 폐기, 재시도 차단을 적용한다. 실제로 중단 비용이 큰 blocking 도구만 process나 container 경계로 옮기면 된다.</p>
<h2>최소 검증 체크리스트</h2>
<p>취소 기능은 정상 실행 테스트만으로 검증할 수 없다. 최소한 다음 시점을 각각 만들어야 한다.</p>
<ol>
<li>대기열에 있을 때 취소하면 작업이 시작되지 않는가?</li>
<li>async I/O 중 취소하면 하위 cancellation이 전파되는가?</li>
<li>blocking thread 중 취소하면 호출자는 종료되고 늦은 결과는 폐기되는가?</li>
<li>취소와 성공이 동시에 도착하면 정한 우선순위가 유지되는가?</li>
<li>취소가 실패 알림이나 자동 재시도를 만들지 않는가?</li>
<li>MCP 연결과 session cleanup이 한 번만 수행되는가?</li>
<li>이미 완료된 외부 side effect를 rollback 완료로 잘못 표시하지 않는가?</li>
</ol>
<p>마지막으로 다음 상황을 예측해 보면 취소 모델이 제대로 잡혔는지 알 수 있다.</p>
<blockquote>
<p>blocking HTTP 업로드가 이미 시작된 뒤 coroutine을 취소하면 무엇이 멈추는가?</p>
</blockquote>
<p>호출자의 대기와 이후 에이전트 진행은 멈출 수 있다. 하지만 업로드 thread와 원격 서버 작업은 계속될 수 있다. 즉시 중단이 제품 요구라면 async cancellation을 지원하는 client와 server 계약을 사용하거나, 해당 작업을 강제 종료 가능한 process/container 경계에 격리해야 한다.</p>
<h2>참고</h2>
<ul>
<li><a href="https://docs.python.org/3/library/asyncio-task.html#asyncio.to_thread">Python asyncio.to_thread</a></li>
<li><a href="https://docs.python.org/3/library/concurrent.futures.html#concurrent.futures.Future.cancel">Python concurrent.futures.Future.cancel</a></li>
<li><a href="https://doc.rust-lang.org/std/thread/struct.JoinHandle.html">Rust std::thread::JoinHandle</a></li>
<li><a href="https://docs.rs/tokio/latest/tokio/task/#cancellation">Tokio task cancellation</a></li>
<li><a href="https://docs.rs/tokio/latest/tokio/task/fn.spawn_blocking.html">Tokio spawn_blocking</a></li>
<li><a href="https://kotlinlang.org/api/kotlinx.coroutines/kotlinx-coroutines-core/kotlinx.coroutines/with-timeout.html">Kotlin cancellation and timeout</a></li>
<li><a href="https://kotlinlang.org/api/kotlinx.coroutines/kotlinx-coroutines-core/kotlinx.coroutines/run-interruptible.html">Kotlin runInterruptible</a></li>
<li><a href="https://docs.oracle.com/en/java/javase/25/docs/api/java.base/java/lang/Thread.html">Java Thread</a></li>
<li><a href="https://learn.microsoft.com/en-us/dotnet/core/compatibility/core-libraries/5.0/thread-abort-obsolete">.NET Thread.Abort obsoletion</a></li>
<li><a href="https://pkg.go.dev/context">Go context</a></li>
<li><a href="https://nodejs.org/api/worker_threads.html">Node.js Worker threads</a></li>
<li><a href="https://modelcontextprotocol.io/specification/2024-11-05/basic/utilities/cancellation">MCP cancellation</a></li>
<li><a href="https://modelcontextprotocol.io/extensions/tasks/overview">MCP Tasks</a></li>
</ul>
]]></content>
        <author>
            <name>Yousang</name>
            <uri>https://yousangson.github.io/</uri>
        </author>
        <published>2026-09-04T00:00:00.000Z</published>
    </entry>
    <entry>
        <title type="html"><![CDATA[DRM API를 고객사별로 배포하며 만난 GitLab CI, RBAC, PVC 문제]]></title>
        <id>https://yousangson.github.io/posts/gitlab-ci-kubernetes-networkpolicy-rbac-debugging/</id>
        <link href="https://yousangson.github.io/posts/gitlab-ci-kubernetes-networkpolicy-rbac-debugging/"/>
        <updated>2026-09-03T00:00:00.000Z</updated>
        <summary type="html"><![CDATA[하나의 DRM API를 고객사별 런타임으로 분리해 Kubernetes에 배포하면서 만난 통합 테스트 환경변수 충돌, PV 권한, NetworkPolicy RBAC, PVC Pending 문제와 해결 과정을 정리한다.]]></summary>
        <content type="html"><![CDATA[<p>하나의 DRM API를 두 고객사 환경으로 분리해 Kubernetes에 배포하는 작업을 진행했다. 소스 코드는 같지만 고객사마다 라이선스와 네이티브 런타임, 이미지, Secret, PVC가 달랐다. 개발과 운영 클러스터도 분리돼 있었다.</p>
<p>최종 구조는 다음과 같았다.</p>
<pre><code>같은 Spring Boot 애플리케이션
  ├─ 고객사 A dev/prod 이미지와 PVC
  └─ 고객사 B dev/prod 이미지와 PVC

GitLab CI
  ├─ 고객사별 벤더 런타임 추출
  ├─ 테스트와 애플리케이션 이미지 빌드
  └─ 환경별 Kubernetes manifest 배포
</code></pre>
<p>배포 자동화 자체보다 어려웠던 부분은 경계였다. 테스트와 런타임 설정의 경계, namespace 권한과 cluster 권한의 경계, 개발과 운영의 경계가 각각 다른 방식으로 파이프라인을 멈췄다.</p>
<p>실제로는 다음 네 문제가 순서대로 나타났다.</p>
<table>
<thead>
<tr>
<th>단계</th>
<th>증상</th>
<th>원인</th>
<th>해결</th>
</tr>
</thead>
<tbody>
<tr>
<td>통합 테스트</td>
<td>profile owner 불일치와 비동기 완료 타임아웃</td>
<td>두 선행 job의 dotenv가 같은 런타임 profile key를 전달</td>
<td>테스트 context와 fixture에 고정된 테스트 owner 사용</td>
</tr>
<tr>
<td>배포 사전 검사</td>
<td>PV 조회 <code>Forbidden</code></td>
<td>namespace Role로 cluster-scoped PV 조회</td>
<td>불필요한 PV 직접 조회 제거, PVC <code>Bound</code>를 gate로 사용</td>
</tr>
<tr>
<td>manifest 적용</td>
<td>NetworkPolicy 조회 <code>Forbidden</code></td>
<td>배포 Role에 새 리소스 권한 누락</td>
<td>SA→RoleBinding→Role을 추적해 최소 권한 추가</td>
</tr>
<tr>
<td>신규 고객사 배포</td>
<td>PVC <code>Pending</code> 타임아웃</td>
<td>정적 local PV와 노드 디렉터리 미준비</td>
<td>고객사·환경별 전용 PV를 먼저 준비하고 <code>Bound</code> 확인</td>
</tr>
</tbody>
</table>
<p>중요한 점은 앞의 실패를 해결해야 다음 실패가 보였다는 것이다. 한꺼번에 추측해서 수정했다면 테스트 설정, RBAC, 스토리지 문제를 섞어 판단했을 가능성이 높다.</p>
<h2>작업 배경: 고객사별 DRM 런타임 분리</h2>
<p>DRM API는 일반적인 stateless API와 달랐다. 고객사별 네이티브 라이브러리와 라이선스 설정을 사용하고, 작업 파일도 서로 다른 볼륨에 저장해야 했다. 한 고객사의 Pod가 다른 고객사의 작업을 가져가거나 같은 저장 경로를 사용하는 상황을 막아야 했다.</p>
<p>그래서 다음 항목을 profile별로 분리했다.</p>
<ul>
<li>런타임 base image와 최종 애플리케이션 image</li>
<li>라이선스 profile owner</li>
<li>Deployment, Service, ConfigMap, Secret</li>
<li>PVC와 local PV</li>
<li>Gateway가 호출할 내부 endpoint</li>
</ul>
<p>GitLab CI에서는 고객사별 추출 job이 벤더 JAR와 profile 정보를 artifact로 전달하고, 신뢰된 테스트 job이 두 artifact를 받은 뒤 애플리케이션을 검증했다. 테스트가 통과하면 profile별 image를 만들고 개발 또는 운영 manifest를 적용했다.</p>
<p>배포 템플릿도 함께 강화했다. <code>kubectl</code> 이미지는 mutable tag 대신 digest로 고정했고, FIPS 환경에서 내부 Git 서버의 manifest를 가져올 수 있도록 P-256 OpenSSL 설정을 Git 명령에만 적용했다. 이 부분은 정상 동작했다. 이후 발생한 실패는 Gradle 테스트, Kubernetes 인가, 스토리지 순서로 서로 다른 계층에서 나왔다.</p>
<h2>첫 번째 실패: 테스트가 CI의 dotenv에 오염됐다</h2>
<p>첫 실패는 Kubernetes가 아니라 통합 테스트였다.</p>
<pre><code>GatewayMetadataRepositoryIntegrationTest FAILED
IllegalArgumentException: job profile owner does not match repository profile

DrmFilePathKafkaIntegrationTest FAILED
timeout waiting for COMPLETED status
</code></pre>
<p>로컬에서는 통과하던 테스트가 protected branch CI에서만 실패했다. 선행하는 두 profile 추출 job이 각각 dotenv artifact에 같은 환경변수 key를 기록했고, downstream 테스트 job이 두 artifact를 모두 내려받고 있었다. 그 결과 Spring context에는 비어 있지 않은 런타임 profile owner가 주입됐지만 테스트 fixture가 만든 작업에는 owner가 없었다.</p>
<p>저장소는 다른 profile의 작업을 처리하지 않도록 owner 일치를 강제한다. 따라서 첫 테스트는 즉시 예외를 냈고, 두 번째 테스트에서는 owner 없는 mock 작업이 처리 대상에서 제외돼 완료 이벤트를 기다리다 타임아웃이 발생했다.</p>
<p>런타임 환경변수를 테스트 job에서 제거하는 것은 올바른 해결이 아니었다. 운영 코드가 non-blank owner를 요구하는 이유는 고객사 간 작업 격리이기 때문이다. 테스트가 런타임 불변식을 우회하면 실제 계약을 검증하지 못한다.</p>
<p>대신 통합 테스트 context와 모든 공용 fixture가 같은 테스트 전용 owner를 사용하도록 고정했다.</p>
<pre><code>private static final String PROFILE_OWNER = "integration-test";

registry.add("drm.metadata.profile-owner", () -&gt; PROFILE_OWNER);

DrmJob.builder()
    .withProfileOwner(PROFILE_OWNER)
    .build();
</code></pre>
<p>원래 실패한 두 테스트를 실제 런타임 profile 환경변수와 함께 재현한 뒤 수정 전 RED, 수정 후 GREEN을 확인했다. 고객사 A와 B profile을 각각 주입한 실행과 전체 <code>clean test integrationTest bootJar</code>도 통과시켰다.</p>
<p>이 문제에서 얻은 결론은 단순했다.</p>
<blockquote>
<p>CI artifact가 전달한 환경변수도 테스트 입력이다.</p>
</blockquote>
<p>소스 diff가 없어도 upstream dotenv 하나가 Spring context와 테스트 동작을 바꿀 수 있다. <code>needs</code>로 여러 dotenv artifact를 받는 job이라면 같은 key의 소유권과 precedence를 명시해야 한다.</p>
<h2>두 번째 실패: namespace 계정으로 PV를 직접 조회했다</h2>
<p>테스트와 이미지 빌드가 통과한 뒤 배포 job이 처음으로 실행됐다. 이번에는 정적 PV 존재 여부를 확인하는 단계에서 실패했다.</p>
<pre><code>Error from server (Forbidden): persistentvolumes "..." is forbidden:
User "system:serviceaccount:platform:gitlab-deployer"
cannot get resource "persistentvolumes" at the cluster scope
</code></pre>
<p>PersistentVolume은 cluster-scoped 리소스다. 반면 CI deployer는 특정 namespace에만 권한을 가진 Role을 사용했다. 여기서 PV 조회를 위해 ClusterRole을 추가하면 하나의 애플리케이션 배포 계정이 클러스터의 모든 PV 이름과 상태를 읽을 수 있게 된다.</p>
<p>배포 계약을 다시 보니 직접 <code>kubectl get pv</code>를 호출할 필요가 없었다.</p>
<ol>
<li>PVC manifest의 <code>volumeName</code>이 승인된 입력과 같은지 검사한다.</li>
<li>PVC를 적용한다.</li>
<li>PVC의 phase가 <code>Bound</code>가 될 때까지 기다린다.</li>
</ol>
<p>지정한 PV가 없거나 조건이 맞지 않으면 PVC가 <code>Bound</code>되지 않는다. 따라서 중복된 cluster-scoped PV 조회를 제거하고 namespace 안에서 확인할 수 있는 PVC 상태를 배포 gate로 사용했다.</p>
<pre><code>kubectl apply -f pvc.yml

kubectl wait \
  --for=jsonpath='{.status.phase}'=Bound \
  pvc/app-data-pvc \
  -n platform \
  --timeout=120s
</code></pre>
<p>권한을 추가해서 검사를 통과시키는 대신 같은 안전 조건을 더 좁은 권한으로 증명한 것이다.</p>
<h2>세 번째 실패: NetworkPolicy 권한 누락</h2>
<p>PV 직접 조회를 제거하자 기존 PVC는 정상적으로 <code>Bound</code>됐다. 파이프라인은 다음 manifest로 진행했고 이번에는 아래 오류와 함께 멈췄다.</p>
<pre><code>Error from server (Forbidden):
networkpolicies.networking.k8s.io "app-gateway-only" is forbidden:
User "system:serviceaccount:platform:gitlab-deployer"
cannot get resource "networkpolicies"
in API group "networking.k8s.io"
in the namespace "platform"
</code></pre>
<p>NetworkPolicy가 새 manifest에 추가됐지만 CI ServiceAccount의 Role은 이전 배포 리소스만 알고 있었다. 이 경우 애플리케이션 코드나 이미지가 아니라 Kubernetes RBAC 경계를 확인해야 한다.</p>
<h3>오류 메시지에 이미 계정이 적혀 있다</h3>
<p>먼저 다음 문자열을 분해한다.</p>
<pre><code>system:serviceaccount:platform:gitlab-deployer
</code></pre>
<p>Kubernetes ServiceAccount 사용자는 아래 형식을 사용한다.</p>
<pre><code>system:serviceaccount:&lt;namespace&gt;:&lt;serviceaccount-name&gt;
</code></pre>
<p>따라서 이 배포 요청은 <code>platform</code> namespace의 <code>gitlab-deployer</code> ServiceAccount로 실행됐다. GitLab 사용자 계정이나 Runner 컨테이너 사용자를 찾을 필요가 없다. CI에 주입된 Kubernetes 토큰이 이 ServiceAccount를 가리킨다.</p>
<p>계정이 실제로 존재하는지 확인한다.</p>
<pre><code>kubectl --context dev-cluster \
  -n platform get serviceaccount gitlab-deployer -o yaml
</code></pre>
<p>ServiceAccount가 존재한다면 토큰을 다시 만들기 전에 어떤 Role이 연결돼 있는지 확인해야 한다.</p>
<h3>RoleBinding을 따라 실제 Role 찾기</h3>
<p>하나의 ServiceAccount에는 여러 RoleBinding이 연결될 수 있다. 이름만 추측하지 않고 subject를 기준으로 검색한다.</p>
<pre><code>kubectl --context dev-cluster \
  -n platform get rolebinding -o json |
  jq -r '.items[]
    | select(any(.subjects[]?;
        .kind == "ServiceAccount"
        and .name == "gitlab-deployer"))
    | [.metadata.name, .roleRef.kind, .roleRef.name]
    | @tsv'
</code></pre>
<p>결과는 다음과 같았다.</p>
<pre><code>gitlab-deployer-binding    Role    deployment-manager
</code></pre>
<p>권한을 고칠 대상은 ServiceAccount나 토큰이 아니라 <code>Role/deployment-manager</code>다.</p>
<p>현재 Role을 확인한다.</p>
<pre><code>kubectl --context dev-cluster \
  -n platform get role deployment-manager -o yaml
</code></pre>
<p>Role에는 Deployment, Service, ConfigMap, Secret, PVC, Ingress 권한이 있었지만 <code>networkpolicies</code>가 없었다. manifest에 NetworkPolicy가 추가됐는데 deployer의 권한 계약은 함께 갱신되지 않은 것이 직접적인 원인이었다.</p>
<h3><code>auth can-i</code>로 가설 확인하기</h3>
<p>Role을 수정하기 전에 실제 인가 결과를 확인한다. 이 명령은 해당 ServiceAccount를 impersonate할 수 있는 관리자 계정으로 실행해야 한다.</p>
<pre><code>kubectl --context dev-cluster auth can-i \
  get networkpolicies.networking.k8s.io \
  -n platform \
  --as=system:serviceaccount:platform:gitlab-deployer
</code></pre>
<p>수정 전 결과는 <code>no</code>였다. CI 로그의 <code>Forbidden</code>과 동일한 권한 누락을 클러스터에서 직접 재현한 셈이다.</p>
<p><code>kubectl apply</code>는 리소스가 있으면 조회하고 patch하며, 없으면 생성한다. 이 배포 경로에 필요한 동작을 각각 확인한다.</p>
<pre><code>for verb in get create patch; do
  kubectl --context dev-cluster auth can-i "$verb" \
    networkpolicies.networking.k8s.io \
    -n platform \
    --as=system:serviceaccount:platform:gitlab-deployer
done
</code></pre>
<p>수정 전에는 세 항목 모두 <code>no</code>였다.</p>
<h3>Role을 누가 공유하는지 먼저 확인하기</h3>
<p>Role에 권한을 추가하면 그 Role에 연결된 모든 subject가 같은 권한을 얻는다. 따라서 수정 전에 <code>deployment-manager</code>를 공유하는 계정을 확인한다.</p>
<pre><code>kubectl --context dev-cluster \
  -n platform get rolebinding -o json |
  jq -r '.items[]
    | select(.roleRef.kind == "Role"
        and .roleRef.name == "deployment-manager")
    | [.metadata.name,
       (.subjects | map(.kind + "/" + .name) | join(","))]
    | @tsv'
</code></pre>
<p>확인 결과 배포 전용 ServiceAccount만 이 Role을 사용하고 있었다. 다른 애플리케이션 계정도 같은 Role을 사용하고 있다면 공용 Role을 넓히기보다 NetworkPolicy 전용 Role과 RoleBinding을 분리하는 편이 안전하다.</p>
<h3>필요한 권한만 별도 rule로 추가하기</h3>
<p>기존 Role에는 <code>ingresses</code> 규칙이 있었고 <code>delete</code>까지 허용했다. 여기에 <code>networkpolicies</code>를 함께 넣으면 NetworkPolicy 삭제 권한도 따라온다. 배포에 필요하지 않은 권한이다.</p>
<p>따라서 다음 rule을 별도로 추가했다.</p>
<pre><code>- apiGroups:
    - networking.k8s.io
  resources:
    - networkpolicies
  verbs:
    - get
    - create
    - patch
</code></pre>
<p>긴급 복구에서는 live Role을 편집할 수 있다.</p>
<pre><code>kubectl --context dev-cluster \
  -n platform edit role deployment-manager
</code></pre>
<p>다만 live edit만으로 끝내면 다음 배포나 인프라 동기화가 Role을 이전 상태로 되돌릴 수 있다. 같은 rule을 Terraform, Helm 또는 Kubernetes manifest처럼 Role을 관리하는 실제 소스에도 반영해야 한다.</p>
<p>수정 후 다시 확인한다.</p>
<pre><code>for verb in get create patch; do
  kubectl --context dev-cluster auth can-i "$verb" \
    networkpolicies.networking.k8s.io \
    -n platform \
    --as=system:serviceaccount:platform:gitlab-deployer
done
</code></pre>
<pre><code>yes
yes
yes
</code></pre>
<p>여기까지 확인된 것은 NetworkPolicy 인가 문제의 해결이다. 전체 Deployment rollout이나 애플리케이션 smoke test 성공을 의미하지는 않는다. CI job을 다시 실행하고 rollout과 Pod readiness를 별도로 확인해야 한다.</p>
<h3>운영 클러스터는 별도로 추적한다</h3>
<p>개발과 운영 클러스터가 분리돼 있다면 개발 Role을 수정해도 운영 권한은 바뀌지 않는다. 운영 CI가 별도 ServiceAccount를 사용한다면 처음부터 같은 절차를 반복한다.</p>
<pre><code>kubectl --context prod-cluster \
  -n platform get serviceaccount gitlab-deployer-prod

kubectl --context prod-cluster \
  -n platform get rolebinding -o json |
  jq -r '.items[]
    | select(any(.subjects[]?;
        .name == "gitlab-deployer-prod"))
    | [.metadata.name, .roleRef.kind, .roleRef.name]
    | @tsv'
</code></pre>
<p>운영 ServiceAccount도 <code>deployment-manager</code> Role에 연결돼 있었지만 NetworkPolicy 권한은 없었다. PVC 조회 권한과 NetworkPolicy 권한을 각각 확인하니 차이가 명확해졌다.</p>
<pre><code>kubectl --context prod-cluster auth can-i \
  get persistentvolumeclaims \
  -n platform \
  --as=system:serviceaccount:platform:gitlab-deployer-prod

for verb in get create patch; do
  kubectl --context prod-cluster auth can-i "$verb" \
    networkpolicies.networking.k8s.io \
    -n platform \
    --as=system:serviceaccount:platform:gitlab-deployer-prod
done
</code></pre>
<pre><code>yes
no
no
no
</code></pre>
<p>계정이나 토큰 전체가 잘못된 것이 아니다. PVC 권한은 정상이고 NetworkPolicy 권한만 빠져 있었다. 운영 Role에도 같은 최소 rule을 추가한 뒤 <code>yes</code> 세 개를 확인했다.</p>
<p>개발에서 통과한 설정을 운영에도 그대로 적용하면 된다는 뜻은 아니다. 다음 항목은 환경별로 따로 확인해야 한다.</p>
<ul>
<li>실제 CI 토큰이 가리키는 ServiceAccount</li>
<li>RoleBinding과 Role</li>
<li>Role을 공유하는 다른 subject</li>
<li>namespace와 kube-context</li>
<li>GitOps 또는 IaC에 저장된 Role 원본</li>
</ul>
<h2>네 번째 실패: 신규 고객사의 PVC가 Pending에 머물렀다</h2>
<p>NetworkPolicy 권한을 해결하는 동안 신규 고객사 배포는 더 앞선 PVC gate에서 120초 동안 대기하다 실패했다.</p>
<pre><code>persistentvolumeclaim/customer-b-drm-pvc created
error: timed out waiting for the condition
</code></pre>
<p>NetworkPolicy 권한을 추가해도 이 문제는 해결되지 않는다. 정적 PV를 사용하는 PVC라면 요청한 <code>volumeName</code>, StorageClass, 용량, access mode, node affinity를 별도로 확인해야 한다.</p>
<pre><code>kubectl --context dev-cluster \
  -n platform describe pvc customer-b-drm-pvc

kubectl --context dev-cluster \
  get pv customer-b-drm-dev -o yaml
</code></pre>
<p>특히 local PV는 실제 노드 디렉터리와 <code>kubernetes.io/hostname</code> label이 manifest의 node affinity와 일치해야 한다. 존재하지 않는 PV를 기다리는 PVC는 권한을 아무리 넓혀도 <code>Bound</code>되지 않는다.</p>
<p>이번 작업에서는 기존 고객사와 신규 고객사가 파일을 섞지 않도록 환경별 전용 local PV를 준비했다.</p>
<table>
<thead>
<tr>
<th>환경</th>
<th>PV</th>
<th>저장 경로 예시</th>
<th>용량</th>
</tr>
</thead>
<tbody>
<tr>
<td>개발</td>
<td><code>customer-b-drm-dev</code></td>
<td><code>/data/pv/customer-b-drm</code></td>
<td>100Gi</td>
</tr>
<tr>
<td>운영</td>
<td><code>customer-b-drm-prod</code></td>
<td><code>/pv/customer-b-drm</code></td>
<td>500Gi</td>
</tr>
</tbody>
</table>
<p>PV는 각 클러스터의 지정 노드에 고정했다.</p>
<pre><code>apiVersion: v1
kind: PersistentVolume
metadata:
  name: customer-b-drm-dev
spec:
  capacity:
    storage: 100Gi
  accessModes:
    - ReadWriteOnce
  persistentVolumeReclaimPolicy: Retain
  storageClassName: local-path-pv
  local:
    path: /data/pv/customer-b-drm
  nodeAffinity:
    required:
      nodeSelectorTerms:
        - matchExpressions:
            - key: kubernetes.io/hostname
              operator: In
              values:
                - worker-dev
</code></pre>
<p>적용 순서는 다음과 같다.</p>
<ol>
<li>대상 노드의 실제 디스크 여유 공간을 확인한다.</li>
<li>host directory를 만들고 Pod가 쓸 수 있는 소유권과 권한을 설정한다.</li>
<li>해당 클러스터에 환경별 PV를 적용한다.</li>
<li>기존 Pending PVC가 자동으로 <code>Bound</code>되는지 확인한다.</li>
</ol>
<pre><code>kubectl --context dev-cluster get pv customer-b-drm-dev
kubectl --context dev-cluster \
  -n platform get pvc customer-b-drm-pvc --watch
</code></pre>
<p>PV의 이름과 조건이 PVC와 일치하면 기존 Pending PVC는 자동으로 바인딩될 수 있다. 먼저 PVC부터 삭제하면 원인 증거를 잃거나 Retain 볼륨의 claim 관계를 더 복잡하게 만들 수 있다. <code>Released</code> PV가 보인다면 이전 claim과 보존 데이터를 확인한 뒤 처리해야 한다.</p>
<p>개발 PV와 운영 PV manifest는 준비했지만, 운영 배포는 별도 승인과 실제 <code>Bound</code> 증거가 필요한 단계로 남겼다. RBAC의 <code>yes</code> 결과와 manifest 준비만으로 운영 배포가 끝났다고 기록하지 않았다.</p>
<p>하나의 CI job에서 연달아 발견됐더라도 다음 두 문제는 분리해서 다뤄야 한다.</p>
<pre><code>Forbidden NetworkPolicy → ServiceAccount RBAC 문제
PVC Pending             → PV/StorageClass/노드 저장소 문제
</code></pre>
<h2>잘못된 해결책 피하기</h2>
<p>가장 빠르게 보이는 해결책이 가장 위험할 때가 있다.</p>
<h3><code>cluster-admin</code>을 부여한다</h3>
<p>배포 하나를 통과시키려고 CI ServiceAccount에 클러스터 전체 권한을 주면 사고 범위가 namespace 밖으로 커진다. NetworkPolicy apply에 필요한 namespaced 권한만 추가하면 된다.</p>
<h3>토큰부터 다시 만든다</h3>
<p>오류 로그에 기대한 ServiceAccount가 표시되고 다른 리소스 작업도 성공했다면 인증은 이미 통과한 것이다. 이때 토큰 재발급은 권한 누락을 해결하지 못하고 불필요한 credential rotation만 만든다.</p>
<h3>live Role만 수정한다</h3>
<p>즉시 실패는 사라질 수 있지만 관리 원본이 그대로면 다시 되돌아간다. live 수정과 IaC 수정을 한 작업으로 관리해야 한다.</p>
<h3>개발 결과를 운영에도 적용됐다고 본다</h3>
<p>클러스터가 다르면 ServiceAccount와 Role도 별개다. 동일한 이름을 사용하더라도 live 상태는 각각 확인해야 한다.</p>
<h2>배포 재시도 전 체크리스트</h2>
<ul>
<li>오류의 <code>system:serviceaccount:&lt;namespace&gt;:&lt;name&gt;</code>을 확인했다.</li>
<li>ServiceAccount가 실제 클러스터에 존재한다.</li>
<li>RoleBinding을 subject 기준으로 추적했다.</li>
<li>Role을 공유하는 모든 subject를 확인했다.</li>
<li><code>auth can-i</code>로 실패 권한을 재현했다.</li>
<li>필요한 resource와 verb만 별도 rule로 추가했다.</li>
<li>개발과 운영 클러스터를 각각 확인했다.</li>
<li>live Role과 IaC 원본을 함께 수정했다.</li>
<li>PVC/PV 문제를 RBAC 문제와 분리했다.</li>
<li>CI 재실행 후 rollout과 smoke test를 별도로 확인한다.</li>
</ul>
<p>이번 배포에서 네 번의 실패는 서로 다른 답을 요구했다. 테스트에는 결정적인 fixture owner가 필요했고, PV 사전 검사에는 더 큰 권한이 아니라 더 좁은 PVC gate가 필요했다. NetworkPolicy에는 ServiceAccount가 실제로 사용하는 Role의 최소 권한이 필요했고, Pending PVC에는 클러스터 밖 노드의 실제 저장 공간이 필요했다.</p>
<p><code>Forbidden</code>은 막연한 Kubernetes 오류가 아니다. 메시지에 요청한 사용자, 리소스, API group, namespace, verb가 모두 들어 있다. <code>Pending</code>도 단순한 대기 상태가 아니라 PV와 노드 계약을 확인하라는 신호다. 실패한 계층을 먼저 분리하면 토큰 재발급이나 <code>cluster-admin</code> 같은 큰 처방 없이 필요한 경계만 고칠 수 있다.</p>
]]></content>
        <author>
            <name>Yousang</name>
            <uri>https://yousangson.github.io/</uri>
        </author>
        <published>2026-09-03T00:00:00.000Z</published>
    </entry>
    <entry>
        <title type="html"><![CDATA[1바이트가 250GB가 될 때: Cloudflare DNS 캐시 메모리 최적화]]></title>
        <id>https://yousangson.github.io/posts/cloudflare-dns-cache-memory-optimization/</id>
        <link href="https://yousangson.github.io/posts/cloudflare-dns-cache-memory-optimization/"/>
        <updated>2026-08-31T00:00:00.000Z</updated>
        <summary type="html"><![CDATA[Cloudflare가 1.1.1.1 DNS 캐시의 엔트리 구조를 바꿔 메모리 100TB를 줄이면서 처리 성능까지 높인 과정을 정리한다.]]></summary>
        <content type="html"><![CDATA[<p>Cloudflare의 DNS 플랫폼에는 항상 <strong>2,500억 개가 넘는 캐시 엔트리</strong>가 저장되어 있다. 이 규모에서는 엔트리마다 1바이트만 낭비해도 전체 시스템에서 250GB가 넘는 메모리를 사용하게 된다.</p>
<p>Cloudflare는 1.1.1.1을 포함한 DNS 서비스의 캐시 구조를 다섯 번에 걸쳐 개선했다. 그 결과 엔트리당 메모리 사용량은 953바이트에서 420바이트로 줄었고, 전체 서버에서 약 100TB의 메모리를 확보했다. 더 흥미로운 점은 메모리만 줄어든 것이 아니라는 것이다. 캐시 삽입 처리량은 43% 증가했고 조회 지연 시간은 19% 감소했다.</p>
<p>이 글은 Cloudflare의 <a href="https://blog.cloudflare.com/dns-cache-memory-optimization-1111/">How we saved 100 terabytes of memory by optimizing 1.1.1.1’s DNS cache</a>를 읽고, 어떤 데이터 구조의 선택이 이런 차이를 만들었는지 정리한 글이다.</p>
<h2>먼저 캐시의 특성을 확인했다</h2>
<p>Cloudflare의 DNS 플랫폼인 Big Pineapple은 DNS 질의가 들어오면 응답을 캐시에 저장한다. 캐시 키에는 질의 도메인, 레코드 타입, 인증 여부 같은 정보가 들어가고, 값에는 DNS 응답과 TTL, 생성 시각, 조회 횟수 등이 저장된다.</p>
<p>중요한 특성은 다음 한 문장으로 정리할 수 있다.</p>
<blockquote>
<p>DNS 응답은 캐시에 삽입된 뒤 수정되지 않는다.</p>
</blockquote>
<p>기존 구조는 데이터를 만드는 동안에는 편리했지만, 저장이 끝난 뒤에도 변경 가능성을 위한 비용을 계속 지불하고 있었다. Cloudflare의 최적화는 이 불일치를 하나씩 제거하는 과정이었다.</p>
<h2>1. 변경되지 않는 <code>Vec</code>를 <code>Box&lt;[T]&gt;</code>로 바꾸기</h2>
<p>Rust의 <code>Vec&lt;T&gt;</code>는 다음 세 정보를 가진다.</p>
<ul>
<li>힙 데이터의 주소</li>
<li>현재 원소 수인 <code>length</code></li>
<li>재할당 없이 담을 수 있는 원소 수인 <code>capacity</code></li>
</ul>
<p><code>capacity</code>는 원소를 계속 추가할 때 필요하다. 하지만 캐시에 저장된 DNS 응답은 더 이상 커지지 않는다. 저장 이후에는 사용되지 않는 8바이트짜리 필드와 여분으로 할당된 공간만 남는다.</p>
<pre><code>// 생성 중에는 크기가 변할 수 있다.
let records: Vec&lt;Record&gt; = build_records(response);

// 캐시에 넣을 때는 정확한 크기로 고정한다.
let cached_records: Box&lt;[Record]&gt; = records.into_boxed_slice();
</code></pre>
<p>문자열도 같은 원리로 <code>String</code> 대신 <code>Box&lt;str&gt;</code>로 고정할 수 있다. Cloudflare는 캐시 엔트리 안의 <code>Vec</code>와 <code>String</code> 필드 8개를 고정 크기 구조로 바꿨다. 필드마다 8바이트, 엔트리마다 64바이트를 줄였고 과도하게 예약된 힙 공간까지 제거했다. 전체 규모에서는 이 변경만으로 15TB가 넘는 메모리가 절약됐다.</p>
<p>여기서 핵심은 <code>Box</code>가 <code>Vec</code>보다 항상 낫다는 것이 아니다. <strong>수정 가능한 생성 단계와 불변인 저장 단계를 서로 다른 타입으로 표현한 것</strong>이 핵심이다.</p>
<h2>2. 세 개의 목록을 하나의 연속된 목록으로 합치기</h2>
<p>DNS 응답은 answer, authority, additional이라는 세 영역으로 나뉜다. 기존 캐시는 각 영역을 별도의 목록으로 저장했다. 목록마다 포인터와 길이가 필요하므로, 데이터가 적어도 관리 정보는 세 벌이 필요했다.</p>
<p>Cloudflare는 모든 레코드를 하나의 연속된 목록에 넣고 각 영역이 시작되는 위치만 저장했다.</p>
<pre><code>struct CachedRecords {
    records: Box&lt;[Record]&gt;,
    authority_start: u16,
    additional_start: u16,
}
</code></pre>
<p>각 영역의 레코드 수는 <code>u16</code> 범위에 들어가기 때문에 2바이트 오프셋이면 충분했다. 두 개의 목록을 제거하고 두 개의 오프셋으로 바꾸면서 엔트리당 28바이트를 줄였다.</p>
<p>여러 <code>bool</code> 필드도 하나의 bit flag로 묶었다. 이 변경은 단순히 필드 크기만 줄인 것이 아니다. Rust 구조체가 메모리 정렬을 위해 삽입하는 padding까지 줄어들면서 실제 구조체 크기가 더 작아졌다.</p>
<h2>3. 반복되는 도메인 소유자 이름 저장하지 않기</h2>
<p>DNS 레코드에는 해당 레코드가 속한 도메인인 owner가 있다. 그런데 일반적인 <code>A</code> 레코드 응답에서는 owner와 질의한 도메인이 같다.</p>
<pre><code>질의: example.com A

example.com.  300  IN  A  198.51.100.1
example.com.  300  IN  A  198.51.100.2
</code></pre>
<p>기존 캐시는 각 레코드에 <code>example.com</code>을 반복해서 저장했다. Cloudflare는 owner가 캐시 키의 질의 도메인과 같으면 저장하지 않고, 조회할 때 이미 가지고 있는 캐시 키에서 복원하도록 바꿨다.</p>
<pre><code>struct Record {
    // None이면 캐시 키의 질의 도메인을 사용한다.
    owner: Option&lt;Box&lt;Name&gt;&gt;,
    data: RecordData,
}
</code></pre>
<p><code>CNAME</code>처럼 실제 owner가 다른 경우에만 전체 이름을 저장한다. 레코드 하나만 보면 자기완결성이 줄어들지만, 캐시 조회 경로에는 항상 키가 함께 존재하므로 별도 조회나 추가 할당은 필요하지 않다.</p>
<p>이 변경은 모든 중복 문자열을 전역에서 관리하는 복잡한 interning 구조를 도입하지 않았다. 이미 조회 경로에 존재하는 값을 재사용하는 것으로 문제를 끝냈다.</p>
<h2>4. 가장 큰 enum variant에 맞춰지는 비용 줄이기</h2>
<p>Rust의 enum은 가장 큰 variant를 담을 수 있는 크기로 만들어진다. DNS 레코드 데이터를 하나의 enum으로 표현하면 작은 <code>A</code> 레코드도 가장 큰 레코드 타입의 영향을 받는다.</p>
<p>Cloudflare의 기존 구조에서 가장 큰 <code>NAPTR</code> 데이터는 136바이트였고, variant 구분 값과 padding을 포함한 enum 전체는 144바이트였다. 반면 IPv4 주소를 담는 <code>A</code> 레코드는 4바이트, <code>AAAA</code> 레코드는 16바이트면 충분했다. 실제 트래픽에서 두 타입이 80% 이상을 차지했기 때문에 대부분의 레코드가 120바이트가 넘는 빈 공간을 가지고 있었다.</p>
<p>첫 번째 해결 방법은 작고 자주 사용되는 variant만 enum 안에 두고, 큰 variant를 <code>Box</code>로 힙에 옮기는 것이었다.</p>
<pre><code>enum RecordData {
    A(Ipv4Addr),
    Aaaa(Ipv6Addr),
    Txt(Box&lt;Txt&gt;),
    Naptr(Box&lt;Naptr&gt;),
}
</code></pre>
<p>이렇게 하면 <code>A</code>와 <code>AAAA</code>는 레코드마다 120바이트를 절약한다. 드물게 등장하는 가장 큰 타입은 포인터와 별도 할당 비용이 늘지만, 실제 데이터 분포를 기준으로 보면 전체 시스템에는 이득이었다.</p>
<p>하지만 boxing에도 비용이 있다.</p>
<ul>
<li>작은 할당이 늘어나 allocator의 크기 반올림과 관리 비용이 발생한다.</li>
<li>데이터가 힙의 여러 위치에 흩어져 CPU cache locality가 나빠진다.</li>
<li>조회할 때 포인터를 한 번 더 따라가야 한다.</li>
</ul>
<p>그래서 Cloudflare는 이 구조를 최종 답으로 두지 않았다.</p>
<h2>5. 파싱된 enum 대신 wire format을 연속된 바이트로 저장하기</h2>
<p>DNS 응답 전체를 wire format 그대로 저장하면 조회할 때마다 DNSSEC 포함 여부나 메시지 ID 같은 클라이언트별 차이를 다시 처리해야 한다. 반대로 모든 레코드를 파싱된 enum으로 저장하면 앞에서 본 크기와 할당 문제가 생긴다.</p>
<p>Cloudflare는 두 방식의 중간 지점을 선택했다. 캐시 엔트리의 메타데이터는 구조화된 필드로 유지하고, 레코드 데이터만 다음과 같은 하나의 <code>Box&lt;[u8]&gt;</code>에 저장했다.</p>
<pre><code>[2바이트 길이][레코드 바이트][2바이트 길이][레코드 바이트]...
</code></pre>
<p>이 구조에는 세 가지 효과가 있다.</p>
<ol>
<li>enum의 최대 variant 크기에 맞춘 빈 공간이 사라진다.</li>
<li>레코드마다 만들었던 힙 할당이 하나로 합쳐진다.</li>
<li>데이터가 연속해서 배치되어 CPU cache locality가 좋아진다.</li>
</ol>
<p>대신 임의 인덱스로 특정 레코드에 바로 접근할 수 없고 처음부터 순차 탐색해야 한다. 하지만 캐시 엔트리 하나에 들어가는 레코드 수가 작기 때문에 실제 비용은 무시할 수 있었다.</p>
<p>조회 과정도 빨라졌다. <code>A</code>, <code>AAAA</code>, <code>TXT</code>, DNSSEC 레코드는 이미 인코딩된 바이트를 응답 버퍼에 바로 복사할 수 있다. 도메인 이름 압축이 필요한 <code>CNAME</code>, <code>NS</code>, <code>MX</code>, <code>SOA</code> 등만 다시 파싱한다. 이 변경으로 조회 지연 시간은 벤치마크에서 5% 줄었다.</p>
<p>삽입 경로에서는 재사용 가능한 scratch buffer에 레코드를 먼저 직렬화한 뒤, 정확한 크기의 <code>Box&lt;[u8]&gt;</code>를 한 번만 할당해 복사했다. 이 변경만으로 캐시 삽입 처리량이 13% 증가했다.</p>
<h2>메모리를 줄였는데 성능도 좋아진 이유</h2>
<p>메모리 절약과 성능 향상이 동시에 일어난 이유는 저장하는 데이터만 줄인 것이 아니라 <strong>할당 횟수와 포인터 추적도 함께 줄였기 때문</strong>이다.</p>
<table>
<thead>
<tr>
<th>지표</th>
<th>변경 전</th>
<th>변경 후</th>
<th>변화</th>
</tr>
</thead>
<tbody>
<tr>
<td>엔트리당 실사용 크기</td>
<td>953 bytes</td>
<td>420 bytes</td>
<td>-56%</td>
</tr>
<tr>
<td>엔트리당 할당량</td>
<td>1.1 KB</td>
<td>461 bytes</td>
<td>-58%</td>
</tr>
<tr>
<td>캐시 삽입 처리량</td>
<td>625,000 entries/s</td>
<td>893,000 entries/s</td>
<td>+43%</td>
</tr>
<tr>
<td>캐시 조회 지연 시간</td>
<td>828 ns</td>
<td>670 ns</td>
<td>-19%</td>
</tr>
</tbody>
</table>
<p>운영 환경의 p99 프로세스 메모리는 9.3GB에서 5.3GB로 43% 감소했고, p90은 6.5GB에서 3.8GB로 42% 감소했다. 벤치마크의 엔트리당 감소율보다 작은 이유는 프로세스 메모리에는 DNS 캐시 외의 데이터도 포함되기 때문이다.</p>
<p>Cloudflare는 벤치마크 숫자만으로 결과를 확정하지 않았다. 실제 트래픽 비율에 가깝게 <code>A</code> 56%, <code>AAAA</code> 25%, <code>TXT</code> 19%로 캐시를 채우고, custom allocator로 엔트리별 할당을 측정했다. 동시에 삽입 처리량과 조회 지연 시간을 확인하고, 배포 후에는 운영 인스턴스의 resident memory까지 비교했다.</p>
<h2>이 사례에서 배운 점</h2>
<h3>평균보다 분포가 중요하다</h3>
<p>enum boxing은 가장 큰 타입만 보면 손해다. 하지만 작은 <code>A</code>와 <code>AAAA</code>가 트래픽의 80% 이상이라는 분포를 반영하면 전체로는 큰 이득이 된다. 자료구조를 최적화할 때는 가능한 값의 목록보다 실제로 어떤 값이 얼마나 자주 등장하는지 먼저 봐야 한다.</p>
<h3>데이터의 생명주기가 타입 선택을 결정한다</h3>
<p>생성 중에는 <code>Vec</code>가 적합하지만 저장 후 불변인 값에는 <code>Box&lt;[T]&gt;</code>가 더 정확하다. 같은 데이터를 처리하더라도 생성 단계와 조회 단계가 요구하는 자료구조는 다를 수 있다.</p>
<h3>메모리 사용량은 필드 크기의 합이 아니다</h3>
<p>구조체 padding, allocator의 size class, 여분의 capacity, 힙 할당 메타데이터, CPU cache miss까지 포함해야 실제 비용이 보인다. <code>size_of</code>만 줄였다고 운영 메모리가 같은 비율로 줄어드는 것은 아니다.</p>
<h3>큰 규모에서는 작은 낭비가 설계 문제가 된다</h3>
<p>일반적인 서비스에서는 엔트리당 8바이트가 중요하지 않을 수 있다. 하지만 2,500억 번 반복되면 약 2TB가 된다. 최적화의 우선순위는 절약 가능한 바이트 수가 아니라 다음 식으로 판단해야 한다.</p>
<pre><code>전체 효과 = 엔트리당 절약량 × 엔트리 수 × 복제 수
</code></pre>
<p>이번 사례에서 가장 인상적인 부분은 특별한 압축 알고리즘이 아니다. 캐시에 들어간 뒤 변하지 않는 값에서 <code>capacity</code>를 없애고, 반복되는 이름을 저장하지 않고, 여러 할당을 하나의 연속된 버퍼로 합쳤다. 데이터의 실제 생명주기와 분포에 맞게 표현을 바꾼 결과가 100TB였다.</p>
<h2>참고</h2>
<ul>
<li><a href="https://blog.cloudflare.com/dns-cache-memory-optimization-1111/">Cloudflare - How we saved 100 terabytes of memory by optimizing 1.1.1.1’s DNS cache</a></li>
<li><a href="https://doc.rust-lang.org/std/vec/struct.Vec.html">Rust 표준 라이브러리 - Vec</a></li>
<li><a href="https://doc.rust-lang.org/std/boxed/struct.Box.html">Rust 표준 라이브러리 - Box</a></li>
<li><a href="https://datatracker.ietf.org/doc/html/rfc1035">RFC 1035 - Domain Names: Implementation and Specification</a></li>
</ul>
]]></content>
        <author>
            <name>Yousang</name>
            <uri>https://yousangson.github.io/</uri>
        </author>
        <published>2026-08-31T00:00:00.000Z</published>
    </entry>
</feed>