<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/"><channel><title>설계 on jyukki's Blog</title><link>https://jyukki.com/tags/%EC%84%A4%EA%B3%84/</link><description>Recent content in 설계 on jyukki's Blog</description><generator>Hugo -- 0.147.0</generator><language>ko-KR</language><lastBuildDate>Fri, 06 Nov 2026 00:00:00 +0000</lastBuildDate><atom:link href="https://jyukki.com/tags/%EC%84%A4%EA%B3%84/index.xml" rel="self" type="application/rss+xml"/><item><title>[5편] 성능을 재 봤더니, 설계를 다시 하게 됐다</title><link>https://jyukki.com/posts/sqs-05-measure-and-redesign/</link><pubDate>Fri, 06 Nov 2026 00:00:00 +0000</pubDate><guid>https://jyukki.com/posts/sqs-05-measure-and-redesign/</guid><description>4편의 숙제였던 성능 측정을 해 보니 측정 도구부터 틀렸고, 저장 구조는 큐가 아니라 캐시처럼 설계돼 있었다. 다른 큐들이 메시지를 어디에 두는지 조사하고, Rust로 다시 설계한 이야기</description><content:encoded><![CDATA[<blockquote>
<p>&ldquo;4편 마지막에 &lsquo;성능 측정&rsquo;이라고 적어 놨으니&hellip; 이제 재 볼까?&rdquo;</p></blockquote>
<p>지난편 끝에 &ldquo;다음에 할 것&rdquo; 첫 줄로 <strong>성능 측정</strong>을 적어 뒀는데요. 막상 재 보니 숫자 하나하나가 이상했어요. 그걸 따라가다 보니 측정 도구, 받기 경로, 결국 저장 구조 전체를 다시 보게 됐습니다.</p>
<p>이번 편은 <strong>내가 만든 큐가 사실은 캐시였다</strong>는 걸 깨닫고 다시 설계하기까지의 이야기입니다.</p>
<h2 id="-첫-번째-이상한-점-지연이-0ms-아니면-1000ms">🤔 첫 번째 이상한 점: 지연이 0ms 아니면 1000ms</h2>
<p>예전에 만들어 둔 <code>benchmark-suite.sh</code> 결과를 다시 열어 봤어요.</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-fallback" data-lang="fallback"><span style="display:flex;"><span>test_number,latency_ms
</span></span><span style="display:flex;"><span>1,0
</span></span><span style="display:flex;"><span>2,0
</span></span><span style="display:flex;"><span>3,0
</span></span><span style="display:flex;"><span>...
</span></span></code></pre></div><p>지연 측정 100번 중 <strong>98번이 0ms, 2번이 1000ms</strong>였습니다. 처리량도 <code>Duration: 0s</code>, <code>1.0s</code>, <code>2.0s</code> 세 가지뿐이었고요.</p>
<blockquote>
<p>&ldquo;중앙값 1ms 미만, P99 1000ms&hellip; 이거 그냥 초 단위로 잰 거잖아?&rdquo;</p></blockquote>
<p>스크립트는 <code>date +%s.%N</code>으로 시각을 찍는데, 결과가 0과 1000밖에 없다는 건 초 단위로만 찍혔다는 뜻이에요. 당시 맥의 <code>date</code>가 나노초를 못 찍었던 것 같습니다(지금 쓰는 맥에서는 찍혀요).</p>
<p>처리량 테스트도 문제였어요.</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span><span style="color:#ff79c6">for</span> i in <span style="color:#ff79c6">$(</span>seq <span style="color:#bd93f9">1</span> <span style="color:#8be9fd;font-style:italic">$batch_count</span><span style="color:#ff79c6">)</span>; <span style="color:#ff79c6">do</span>
</span></span><span style="display:flex;"><span>    curl -s -X POST <span style="color:#8be9fd;font-style:italic">$BASE_URL</span>/api/v1/queues/throughput-test-queue/messages ... &amp;
</span></span><span style="display:flex;"><span><span style="color:#ff79c6">done</span>
</span></span><span style="display:flex;"><span><span style="color:#8be9fd;font-style:italic">wait</span>
</span></span></code></pre></div><p><code>curl</code> 프로세스를 최대 1000개 한꺼번에 띄우고 있었어요. 이러면 서버 속도가 아니라 <strong>맥이 프로세스를 만드는 속도</strong>를 재게 됩니다.</p>
<p><strong>교훈:</strong> 숫자를 믿기 전에 측정 도구부터 의심하자.</p>
<h2 id="-제대로-재-보기-redis-vs-rocksdb">🔬 제대로 재 보기: Redis vs RocksDB</h2>
<p>그래서 저장소만 떼어서 µs 단위로 다시 쟀습니다. 환경은 M4 Pro 맥, Java 21 + rocksdbjni(RocksDB의 JNI 바인딩)이고, 메시지 256B, 10만 건, 클라이언트 1개예요. RocksDB는 WAL에 쓰되 fsync는 하지 않았습니다.</p>
<p>Redis는 AOF를 켠 임시 컨테이너 안에서 <code>redis-benchmark</code>로, TCP 루프백으로 쟀어요. 클라이언트와 서버가 같은 컨테이너에 있어서 실제 배포보다 Redis에 유리한 조건입니다. 다만 맥의 Docker는 리눅스 VM 안에서 돌아서, RocksDB를 잰 환경과 완전히 같지는 않아요.</p>
<table>
  <thead>
      <tr>
          <th>연산</th>
          <th>평균</th>
          <th>p99</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>RocksDB 쓰기 (같은 JVM, fsync 안 함)</td>
          <td>1.9µs</td>
          <td>4.4µs</td>
      </tr>
      <tr>
          <td>RocksDB 읽기 (memtable)</td>
          <td>0.96µs</td>
          <td>2.1µs</td>
      </tr>
      <tr>
          <td>RocksDB 읽기 (디스크 파일, 캐시 적중)</td>
          <td>1.2µs</td>
          <td>4.0µs</td>
      </tr>
      <tr>
          <td>Redis SET (AOF 1초마다 fsync)</td>
          <td>42µs</td>
          <td>71µs</td>
      </tr>
      <tr>
          <td>Redis GET</td>
          <td>39µs</td>
          <td>55µs</td>
      </tr>
  </tbody>
</table>
<blockquote>
<p>&ldquo;&hellip;Hot이 Redis인데, Warm인 RocksDB가 20~40배 빠르다고?&rdquo;</p></blockquote>
<p>저장 엔진 자체는 둘 다 µs 단위로 빨라요. 차이는 <strong>요청마다 붙는 왕복</strong>입니다. Redis는 별도 프로세스라 요청마다 시스템 콜, 문맥 전환, 루프백 왕복이 붙어요. RocksDB는 앱 안에서 함수 호출로 끝나고요.</p>
<p>3편에서 &ldquo;최근 메시지는 빠른 Redis에&rdquo; 두려고 계층을 나눴는데, 측정해 보니 <strong>Hot 계층이 오히려 Warm보다 느린 길</strong>이었습니다.</p>
<h2 id="-받기-경로를-따라가-봤더니">😱 받기 경로를 따라가 봤더니</h2>
<p>숫자가 이상하니 코드도 다시 봤어요. 메시지를 받을 때 어디서 꺼내는지 따라가 보니 더 큰 문제가 나왔습니다.</p>
<h3 id="1-오래된-메시지부터-내려보낸다">1. 오래된 메시지부터 내려보낸다</h3>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-kotlin" data-lang="kotlin"><span style="display:flex;"><span><span style="color:#6272a4">// Hot → Warm 으로 옮길 메시지 고르기
</span></span></span><span style="display:flex;"><span><span style="color:#6272a4"></span><span style="color:#ff79c6">private</span> <span style="color:#ff79c6">fun</span> <span style="color:#50fa7b">getOldestMessages</span>(queueKey: String, count: Long): Flux&lt;String&gt; {
</span></span><span style="display:flex;"><span>    <span style="color:#ff79c6">..</span>.
</span></span><span style="display:flex;"><span>    redisTemplate.opsForList().range(queueKey, <span style="color:#bd93f9">0</span>, safeCount - <span style="color:#bd93f9">1</span>)
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p>계층 이동은 <strong>가장 오래된 메시지</strong>부터 Warm·Cold로 내려요. 캐시라면 맞는 방식이에요. 오래된 데이터일수록 덜 읽으니까요.</p>
<p>그런데 큐는 <strong>오래된 메시지부터 읽습니다.</strong> 처리가 밀리면 밀릴수록, 다음에 읽어야 할 메시지부터 Hot 밖으로 나가요.</p>
<blockquote>
<p>&ldquo;아&hellip; 이거 큐가 아니라 캐시를 만들었네&hellip;&rdquo;</p></blockquote>
<h3 id="2-내려간-메시지는-받을-수가-없다">2. 내려간 메시지는 받을 수가 없다</h3>
<p>Hot 밖으로 나간 메시지는 어떻게 다시 받을까 싶어서 찾아봤는데&hellip; 다시 받는 길이 없었어요.</p>
<p>Standard 큐는 Hot에서 Warm으로 옮길 때 메시지를 받기 목록에서 지우고, 다시 넣지 않습니다. 받기는 그 목록에서만 꺼내고요. 그래서 Hot 한도(큐당 100건 또는 2시간)를 넘겨 Warm으로 내려간 메시지는 <strong>더 이상 받을 수 없어요.</strong> 큐에 100건 남짓만 밀려도 생기는 일입니다.</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-kotlin" data-lang="kotlin"><span style="display:flex;"><span><span style="color:#ff79c6">private</span> <span style="color:#ff79c6">fun</span> <span style="color:#50fa7b">getMessageFromColdStorage</span>(messageId: String): Mono&lt;Message&gt; {
</span></span><span style="display:flex;"><span>    <span style="color:#6272a4">// TODO: 실제 COLD 스토리지 구현체와 연동
</span></span></span><span style="display:flex;"><span><span style="color:#6272a4"></span>    logger.warn(<span style="color:#f1fa8c">&#34;COLD 스토리지 조회 미구현: messageId=</span><span style="color:#f1fa8c">$messageId</span><span style="color:#f1fa8c">&#34;</span>)
</span></span><span style="display:flex;"><span>    <span style="color:#ff79c6">return</span> <span style="color:#50fa7b">Mono</span>.empty()
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p>그 다음 단계도 막혀 있었어요. 4편에서 Cold 저장은 만들었는데, <strong>Cold에서 꺼내는 건 TODO</strong>로 남아 있었습니다. FIFO 큐는 Warm에서 Hot으로 되돌리는 코드가 있어서 Warm까지는 괜찮지만, Cold로 내려가면(Warm에 1000건 넘게 쌓이거나 24시간이 지나면) 역시 전달이 안 됩니다.</p>
<h3 id="3-standard-큐가-lifo였다">3. Standard 큐가 LIFO였다</h3>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-kotlin" data-lang="kotlin"><span style="display:flex;"><span><span style="color:#ff79c6">val</span> popOperation = <span style="color:#ff79c6">if</span> (queue.attributes.fifoQueue) {
</span></span><span style="display:flex;"><span>    redisTemplate.opsForList().leftPop(availableKey)  <span style="color:#6272a4">// FIFO: 순서 보장
</span></span></span><span style="display:flex;"><span><span style="color:#6272a4"></span>} <span style="color:#ff79c6">else</span> {
</span></span><span style="display:flex;"><span>    redisTemplate.opsForList().rightPop(availableKey) <span style="color:#6272a4">// Standard: LIFO
</span></span></span><span style="display:flex;"><span><span style="color:#6272a4"></span>}
</span></span></code></pre></div><p>SQS의 Standard 큐는 순서를 보장하지 않지만, 대체로 먼저 넣은 게 먼저 나와요. 제 구현은 대놓고 <strong>나중에 넣은 것부터</strong> 꺼내고 있었습니다. 처리가 밀리면 오래된 메시지는 계속 뒤로 밀리다가 Hot 밖으로 나가고&hellip; 결국 2번 문제와 만나요.</p>
<h3 id="4-여러-대로-띄우면-깨진다">4. 여러 대로 띄우면 깨진다</h3>
<ul>
<li>Warm(RocksDB)과 Cold(파일)가 <strong>서버 로컬 디스크</strong>에 있어요. A 서버가 옮긴 메시지를 B 서버는 볼 수 없습니다.</li>
<li>계층 이동 스케줄러에 <strong>잠금이 없어요.</strong> 서버마다 같은 메시지를 동시에 옮깁니다.</li>
</ul>
<h3 id="5-덤-webflux인-줄-알았는데-tomcat">5. 덤: WebFlux인 줄 알았는데 Tomcat</h3>
<p>로그를 보니 <code>Tomcat started on port 8080</code>이 찍혀 있었어요. <code>spring-boot-starter-websocket</code>이 서블릿 스택을 같이 끌어와서, WebFlux가 아니라 Spring MVC로 뜨고 있었습니다.</p>
<blockquote>
<p>&ldquo;WebFlux 공부하려고 시작한 프로젝트였는데&hellip; 흐음&hellip;&rdquo;</p></blockquote>
<p>정리하면, 3편의 <strong>문제 인식(14일 보관을 Redis 메모리로는 못 버틴다)은 맞았어요.</strong> 해법이 큐가 아니라 캐시의 방식을 따랐던 게 문제였습니다.</p>
<h2 id="-다른-큐들은-메시지를-어디에-둘까">🔍 다른 큐들은 메시지를 어디에 둘까</h2>
<p>다시 설계하기 전에, 실제 큐 서비스들은 메시지를 어디에 저장하는지 찾아봤어요.</p>
<table>
  <thead>
      <tr>
          <th>서비스</th>
          <th>저장소</th>
          <th>특징</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Amazon SQS</td>
          <td>저장 엔진은 공개 안 됨. 앞단 + 셀 구조</td>
          <td>FIFO는 그룹 ID 해시로 파티션을 정하고, 파티션 수를 자동으로 늘리고 줄인다</td>
      </tr>
      <tr>
          <td>Meta FOQS</td>
          <td>샤딩한 MySQL, 메시지 하나가 행 하나</td>
          <td>하루 약 1조 건 (2021년 발표 기준)</td>
      </tr>
      <tr>
          <td>Apple QuiCK</td>
          <td>FoundationDB</td>
          <td>테넌트별 큐 수백억 개</td>
      </tr>
      <tr>
          <td>RabbitMQ quorum queue</td>
          <td>Raft 로그 (노드 공용 WAL + 큐별 파일)</td>
          <td>큐가 5천 개를 넘으면 구조를 다시 보라고 권장</td>
      </tr>
      <tr>
          <td>Kafka share group</td>
          <td>로그 + 상태 토픽</td>
          <td>메시지별 처리 완료를 지원. 4.2부터 운영용으로 쓸 수 있다</td>
      </tr>
      <tr>
          <td>Uber Cherami</td>
          <td>데이터 묶음마다 RocksDB</td>
          <td>2016년</td>
      </tr>
  </tbody>
</table>
<p>재밌었던 건 두 가지예요.</p>
<p><strong>하나.</strong> Meta와 Apple은 큐 전용 저장소를 새로 만들지 않았어요. 이미 운영하던 범용 저장소(MySQL, FoundationDB) 위에 큐를 얹었습니다.</p>
<p><strong>둘.</strong> 같은 &ldquo;큐&quot;인데 RabbitMQ와 SQS는 느낌이 완전히 다르죠. 저장소에서도 그 차이가 보였어요. RabbitMQ는 큐 수가 적고 큐마다 바쁜 부하에 맞춰 큐별 로그 파일을 씁니다. SQS 같은 서비스는 작은 큐가 수백만 개 있고, 메시지가 순서 없이 지워져요. 이런 부하에는 키-값 저장소가 맞습니다.</p>
<p>검증된 저장소 위에 얹는 게 가장 빠른 길이긴 한데&hellip; 저는 <a href="/projects/pgmux/">pgmux</a>(직접 만든 PostgreSQL 프록시)처럼 <strong>핵심을 직접 만들고, 원하는 대로 특화하고 싶었어요.</strong> 그래서 RocksDB를 계속 쓰되, 정말 큐로 쓸 만한지부터 확인하기로 했습니다.</p>
<h2 id="-rocksdb로-큐를-만들면-진짜-되나">🧪 RocksDB로 큐를 만들면 진짜 되나?</h2>
<p>이번엔 단순 읽기·쓰기가 아니라 <strong>큐 부하</strong>를 걸었어요. 키는 이렇게 잡았습니다.</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-fallback" data-lang="fallback"><span style="display:flex;"><span>m | 큐 | 순번              → 메시지 본문
</span></span><span style="display:flex;"><span>v | 큐 | visible_at | 순번 → 받을 수 있는 시각 색인 (빈 값)
</span></span></code></pre></div><p>받기는 <code>v</code>의 맨 앞에서 10건을 찾고, 색인을 가시성 타임아웃이 끝나는 시각(lease 만료 시각)으로 옮깁니다(지우고 다시 쓰기). 처리 완료는 둘 다 지워요. 그리고 처리가 밀린 큐를 비우는 속도를 쟀습니다.</p>
<table>
  <thead>
      <tr>
          <th>경우</th>
          <th>처리량 (받기 + 처리 완료)</th>
          <th>받기 p50</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>큐 1000개 × 1000건, 매번 큐 처음부터 찾기</td>
          <td>초당 12만 건</td>
          <td>66µs</td>
      </tr>
      <tr>
          <td>같은 조건, <strong>다음에 읽을 위치 기록</strong></td>
          <td>초당 29만 건</td>
          <td>21µs</td>
      </tr>
      <tr>
          <td>큐 1개에 20만 건이 밀림, 매번 처음부터 찾기</td>
          <td><strong>초당 4천 건</strong> (15초에서 끊음, 약 6만 건까지의 값)</td>
          <td><strong>2.4ms</strong></td>
      </tr>
      <tr>
          <td>같은 조건, 다음에 읽을 위치 기록</td>
          <td>초당 30만 건</td>
          <td>19µs</td>
      </tr>
  </tbody>
</table>
<blockquote>
<p>&ldquo;다음에 읽을 위치를 기록 안 하면 75배나 느려진다고?&rdquo;</p></blockquote>
<p>RocksDB에서 지운 키는 바로 사라지지 않고 <strong>삭제 표시</strong>(tombstone)로 남아요. 큐는 앞에서부터 계속 지우니까, 큐 앞부분에 삭제 표시가 산더미처럼 쌓입니다. 매번 큐 처음부터 찾으면 그걸 다 훑느라 느려지는 거예요. RocksDB 공식 위키의 <a href="https://github.com/facebook/rocksdb/wiki/Implement-Queue-Service-Using-RocksDB">Implement Queue Service Using RocksDB</a>에서도 경고하는 문제였습니다.</p>
<p>해결은 간단해요. 큐마다 <strong>다음에 읽을 위치</strong>를 기록해 두고 거기서부터 찾으면 됩니다.</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-java" data-lang="java"><span style="display:flex;"><span><span style="color:#6272a4">// 처음 찾은 키를 다음 시작 위치로 기록</span>
</span></span><span style="display:flex;"><span><span style="color:#8be9fd">byte</span><span style="color:#ff79c6">[]</span> lowKey <span style="color:#ff79c6">=</span> useMark <span style="color:#ff79c6">?</span> vKey(q, mark<span style="color:#ff79c6">[</span>q<span style="color:#ff79c6">]</span>, markSeq<span style="color:#ff79c6">[</span>q<span style="color:#ff79c6">]</span>) : vBound(q, 0);
</span></span><span style="display:flex;"><span>...
</span></span><span style="display:flex;"><span>it.<span style="color:#50fa7b">seek</span>(lowKey);
</span></span><span style="display:flex;"><span>...
</span></span><span style="display:flex;"><span>mark<span style="color:#ff79c6">[</span>q<span style="color:#ff79c6">]</span> <span style="color:#ff79c6">=</span> vis<span style="color:#ff79c6">[</span>0<span style="color:#ff79c6">]</span>;
</span></span><span style="display:flex;"><span>markSeq<span style="color:#ff79c6">[</span>q<span style="color:#ff79c6">]</span> <span style="color:#ff79c6">=</span> sq<span style="color:#ff79c6">[</span>0<span style="color:#ff79c6">]</span>;
</span></span></code></pre></div><p>솔직히 고백하면 측정 코드를 처음 짤 때 저도 똑같이 실수했어요. <strong>시각만</strong> 기록하고 순번은 안 기록했더니, 미리 쌓아 둔 메시지가 전부 같은 시각이라 효과가 하나도 없었습니다. 순번까지 기록하고 나서야 숫자가 제대로 나왔어요.</p>
<blockquote>
<p>&ldquo;삭제 표시는 진짜 조심해야겠다&hellip;&rdquo;</p></blockquote>
<p>결론: <strong>RocksDB로 큐는 된다. 단, 다음에 읽을 위치를 기록해 삭제 표시를 건너뛰지 않으면 무너진다.</strong></p>
<h2 id="-새-설계-rocksdb-엔진으로-먼저-자체-엔진은-나중에">🧭 새 설계: RocksDB 엔진으로 먼저, 자체 엔진은 나중에</h2>
<p>그럼 RocksDB로 끝인가 하면, 그건 또 아니에요. RocksDB는 범용 저장소라 큐에는 손해 보는 부분이 있습니다.</p>
<ul>
<li><strong>쓰기 증폭:</strong> 처리가 밀리면 컴팩션이 본문을 여러 번 다시 쓴다</li>
<li><strong>삭제 표시:</strong> 위에서 본 것처럼 쌓이면 느려진다</li>
<li><strong>컴팩션 멈춤:</strong> 쓰기가 오래 몰리면 p99가 튈 수 있다</li>
<li><strong>이중 쓰기:</strong> 나중에 Raft로 복제하면 Raft 로그와 RocksDB에 두 번 쓴다</li>
</ul>
<p>그래서 이렇게 가기로 했어요.</p>
<blockquote>
<p><strong>&ldquo;껍질(API, 큐 동작, 복제, 분할)을 RocksDB 엔진으로 먼저 완성하고, 나중에 엔진만 우리 것으로 갈아 끼운다.&rdquo;</strong></p></blockquote>
<p>핵심은 엔진 인터페이스를 <strong>큐 연산 단위</strong>(보내기, 받기, 가시성 타임아웃 변경, 지우기)로 두는 거예요. 키-값 단위로 두면 나중에 만들 엔진도 키-값 저장소가 돼야 해서 특화할 여지가 없거든요.</p>
<p>자체 엔진은 RocketMQ에서 힌트를 얻었어요. 파티션마다 커밋 로그를 하나 두고, 그 파티션에 든 모든 큐의 메시지를 들어온 순서대로 이어 씁니다. 큐마다는 (로그 위치, 크기, 받을 수 있는 시각)만 담은 작은 색인을 둡니다. 큐가 수백만 개여도 큐마다 파일을 따로 만들지 않아요. 본문은 한 번만 쓰고, 처리 완료는 삭제 표시가 아니라 상태 기록으로 남깁니다.</p>
<table>
  <thead>
      <tr>
          <th>단계</th>
          <th>내용</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>0</td>
          <td>측정 토대: 엔진 인터페이스·RocksDB 엔진 기본 연산·벤치마크를 Rust로 옮겨 µs 단위로 재기</td>
      </tr>
      <tr>
          <td>1</td>
          <td>껍질(API·큐 동작) + RocksDB 엔진 (한 대)</td>
      </tr>
      <tr>
          <td>2</td>
          <td>자체 엔진 v1. 같은 테스트와 벤치마크로 RocksDB와 비교</td>
      </tr>
      <tr>
          <td>3</td>
          <td>여러 노드: 파티션을 노드에 나눠 맡기고 Raft로 복제</td>
      </tr>
      <tr>
          <td>4</td>
          <td>커밋 로그를 Raft 로그로 같이 쓰기 (본문은 한 번만)</td>
      </tr>
      <tr>
          <td>5</td>
          <td>파티션 자동 분할, 테넌트 간 공정성</td>
      </tr>
  </tbody>
</table>
<p>겉 동작은 계속 <strong>AWS SQS 따라잡기</strong>예요. 이번엔 아예 SQS 호환 API(AWS JSON 프로토콜)로 만들어서, AWS SDK를 그대로 붙이고 같은 SQS 호환 서버인 ElasticMQ와 같은 조건으로 비교해 볼 생각입니다.</p>
<h2 id="-그리고-rust로-갑니다">🦀 그리고&hellip; Rust로 갑니다</h2>
<p>Kotlin + WebFlux로 시작한 프로젝트인데, 다시 짜는 김에 언어도 다시 골랐어요. JVM, Go, Rust를 놓고 비교했는데 결정적인 건 <strong>2단계 자체 엔진</strong>이었습니다.</p>
<ul>
<li>메시지 수억 건의 색인을 메모리에 들고 있어야 하는데, GC가 있는 언어에서는 이게 GC 부담이 돼요. Rust는 GC가 없고 메모리 배치를 직접 정할 수 있습니다.</li>
<li>4단계에서 커밋 로그를 Raft 로그로 같이 쓰려면 로그 저장을 앱에 맡기는 Raft 라이브러리가 필요해요. Rust에는 raft-rs(TiKV가 쓰는, etcd raft를 옮긴 것)가 있어서 이 계획이 막히지 않아요. Go의 etcd raft도 같은 구조라 Rust만의 장점은 아닙니다.</li>
<li>1단계 엔진도 지금 잰 것과 같은 RocksDB라서, 버전만 맞추면 기준선이 이어져요.</li>
</ul>
<p>코드는 AI랑 같이 짤 거라 언어를 익히는 비용은 크게 신경 쓰지 않기로 했어요. 대신 짠 코드가 맞는지는 테스트가 판정하게 합니다. 강제 종료 반복, 결정적 시뮬레이션, 두 엔진에 같은 정확성 테스트를 돌리는 식으로요. Rust 컴파일러가 메모리 실수를 막아 주는 것도 여기서는 장점이 됩니다.</p>
<blockquote>
<p>&ldquo;Kotlin, WebFlux 공부하려고 시작했는데 Rust 저장 엔진이 됐네&hellip;&rdquo;</p></blockquote>
<p>설계 문서는 저장소의 <a href="https://github.com/jyukki97/simple-queue-service/blob/main/docs/design/storage-engine.md">docs/design/storage-engine.md</a>에 정리해 뒀습니다.</p>
<h2 id="-다음에-할-것">🚀 다음에 할 것</h2>
<ul>
<li>0단계: Cargo workspace 뼈대, 엔진 인터페이스, RocksDB 엔진 기본 연산, 벤치마크를 Rust로 옮기기</li>
<li>RocksDB를 Rust 바인딩으로 다시 재서 이번 JNI 숫자와 비교하기 (RocksDB 버전을 맞춰서)</li>
<li>SQS 호환 API로 AWS SDK 붙여 보기</li>
<li>언젠가: 자체 엔진으로 <strong>RocksDB 이겨 보기</strong></li>
</ul>
]]></content:encoded></item></channel></rss>