“4편 마지막에 ‘성능 측정’이라고 적어 놨으니… 이제 재 볼까?”

지난편 끝에 “다음에 할 것” 첫 줄로 성능 측정을 적어 뒀는데요. 막상 재 보니 숫자 하나하나가 이상했어요. 그걸 따라가다 보니 측정 도구, 받기 경로, 결국 저장 구조 전체를 다시 보게 됐습니다.

이번 편은 내가 만든 큐가 사실은 캐시였다는 걸 깨닫고 다시 설계하기까지의 이야기입니다.

🤔 첫 번째 이상한 점: 지연이 0ms 아니면 1000ms

예전에 만들어 둔 benchmark-suite.sh 결과를 다시 열어 봤어요.

test_number,latency_ms
1,0
2,0
3,0
...

지연 측정 100번 중 98번이 0ms, 2번이 1000ms였습니다. 처리량도 Duration: 0s, 1.0s, 2.0s 세 가지뿐이었고요.

“중앙값 1ms 미만, P99 1000ms… 이거 그냥 초 단위로 잰 거잖아?”

스크립트는 date +%s.%N으로 시각을 찍는데, 결과가 0과 1000밖에 없다는 건 초 단위로만 찍혔다는 뜻이에요. 당시 맥의 date가 나노초를 못 찍었던 것 같습니다(지금 쓰는 맥에서는 찍혀요).

처리량 테스트도 문제였어요.

for i in $(seq 1 $batch_count); do
    curl -s -X POST $BASE_URL/api/v1/queues/throughput-test-queue/messages ... &
done
wait

curl 프로세스를 최대 1000개 한꺼번에 띄우고 있었어요. 이러면 서버 속도가 아니라 맥이 프로세스를 만드는 속도를 재게 됩니다.

교훈: 숫자를 믿기 전에 측정 도구부터 의심하자.

🔬 제대로 재 보기: Redis vs RocksDB

그래서 저장소만 떼어서 µs 단위로 다시 쟀습니다. 환경은 M4 Pro 맥, Java 21 + rocksdbjni(RocksDB의 JNI 바인딩)이고, 메시지 256B, 10만 건, 클라이언트 1개예요. RocksDB는 WAL에 쓰되 fsync는 하지 않았습니다.

Redis는 AOF를 켠 임시 컨테이너 안에서 redis-benchmark로, TCP 루프백으로 쟀어요. 클라이언트와 서버가 같은 컨테이너에 있어서 실제 배포보다 Redis에 유리한 조건입니다. 다만 맥의 Docker는 리눅스 VM 안에서 돌아서, RocksDB를 잰 환경과 완전히 같지는 않아요.

연산평균p99
RocksDB 쓰기 (같은 JVM, fsync 안 함)1.9µs4.4µs
RocksDB 읽기 (memtable)0.96µs2.1µs
RocksDB 읽기 (디스크 파일, 캐시 적중)1.2µs4.0µs
Redis SET (AOF 1초마다 fsync)42µs71µs
Redis GET39µs55µs

“…Hot이 Redis인데, Warm인 RocksDB가 20~40배 빠르다고?”

저장 엔진 자체는 둘 다 µs 단위로 빨라요. 차이는 요청마다 붙는 왕복입니다. Redis는 별도 프로세스라 요청마다 시스템 콜, 문맥 전환, 루프백 왕복이 붙어요. RocksDB는 앱 안에서 함수 호출로 끝나고요.

3편에서 “최근 메시지는 빠른 Redis에” 두려고 계층을 나눴는데, 측정해 보니 Hot 계층이 오히려 Warm보다 느린 길이었습니다.

😱 받기 경로를 따라가 봤더니

숫자가 이상하니 코드도 다시 봤어요. 메시지를 받을 때 어디서 꺼내는지 따라가 보니 더 큰 문제가 나왔습니다.

1. 오래된 메시지부터 내려보낸다

// Hot → Warm 으로 옮길 메시지 고르기
private fun getOldestMessages(queueKey: String, count: Long): Flux<String> {
    ...
    redisTemplate.opsForList().range(queueKey, 0, safeCount - 1)
}

계층 이동은 가장 오래된 메시지부터 Warm·Cold로 내려요. 캐시라면 맞는 방식이에요. 오래된 데이터일수록 덜 읽으니까요.

그런데 큐는 오래된 메시지부터 읽습니다. 처리가 밀리면 밀릴수록, 다음에 읽어야 할 메시지부터 Hot 밖으로 나가요.

“아… 이거 큐가 아니라 캐시를 만들었네…”

2. 내려간 메시지는 받을 수가 없다

Hot 밖으로 나간 메시지는 어떻게 다시 받을까 싶어서 찾아봤는데… 다시 받는 길이 없었어요.

Standard 큐는 Hot에서 Warm으로 옮길 때 메시지를 받기 목록에서 지우고, 다시 넣지 않습니다. 받기는 그 목록에서만 꺼내고요. 그래서 Hot 한도(큐당 100건 또는 2시간)를 넘겨 Warm으로 내려간 메시지는 더 이상 받을 수 없어요. 큐에 100건 남짓만 밀려도 생기는 일입니다.

private fun getMessageFromColdStorage(messageId: String): Mono<Message> {
    // TODO: 실제 COLD 스토리지 구현체와 연동
    logger.warn("COLD 스토리지 조회 미구현: messageId=$messageId")
    return Mono.empty()
}

그 다음 단계도 막혀 있었어요. 4편에서 Cold 저장은 만들었는데, Cold에서 꺼내는 건 TODO로 남아 있었습니다. FIFO 큐는 Warm에서 Hot으로 되돌리는 코드가 있어서 Warm까지는 괜찮지만, Cold로 내려가면(Warm에 1000건 넘게 쌓이거나 24시간이 지나면) 역시 전달이 안 됩니다.

3. Standard 큐가 LIFO였다

val popOperation = if (queue.attributes.fifoQueue) {
    redisTemplate.opsForList().leftPop(availableKey)  // FIFO: 순서 보장
} else {
    redisTemplate.opsForList().rightPop(availableKey) // Standard: LIFO
}

SQS의 Standard 큐는 순서를 보장하지 않지만, 대체로 먼저 넣은 게 먼저 나와요. 제 구현은 대놓고 나중에 넣은 것부터 꺼내고 있었습니다. 처리가 밀리면 오래된 메시지는 계속 뒤로 밀리다가 Hot 밖으로 나가고… 결국 2번 문제와 만나요.

4. 여러 대로 띄우면 깨진다

  • Warm(RocksDB)과 Cold(파일)가 서버 로컬 디스크에 있어요. A 서버가 옮긴 메시지를 B 서버는 볼 수 없습니다.
  • 계층 이동 스케줄러에 잠금이 없어요. 서버마다 같은 메시지를 동시에 옮깁니다.

5. 덤: WebFlux인 줄 알았는데 Tomcat

로그를 보니 Tomcat started on port 8080이 찍혀 있었어요. spring-boot-starter-websocket이 서블릿 스택을 같이 끌어와서, WebFlux가 아니라 Spring MVC로 뜨고 있었습니다.

“WebFlux 공부하려고 시작한 프로젝트였는데… 흐음…”

정리하면, 3편의 문제 인식(14일 보관을 Redis 메모리로는 못 버틴다)은 맞았어요. 해법이 큐가 아니라 캐시의 방식을 따랐던 게 문제였습니다.

🔍 다른 큐들은 메시지를 어디에 둘까

다시 설계하기 전에, 실제 큐 서비스들은 메시지를 어디에 저장하는지 찾아봤어요.

서비스저장소특징
Amazon SQS저장 엔진은 공개 안 됨. 앞단 + 셀 구조FIFO는 그룹 ID 해시로 파티션을 정하고, 파티션 수를 자동으로 늘리고 줄인다
Meta FOQS샤딩한 MySQL, 메시지 하나가 행 하나하루 약 1조 건 (2021년 발표 기준)
Apple QuiCKFoundationDB테넌트별 큐 수백억 개
RabbitMQ quorum queueRaft 로그 (노드 공용 WAL + 큐별 파일)큐가 5천 개를 넘으면 구조를 다시 보라고 권장
Kafka share group로그 + 상태 토픽메시지별 처리 완료를 지원. 4.2부터 운영용으로 쓸 수 있다
Uber Cherami데이터 묶음마다 RocksDB2016년

재밌었던 건 두 가지예요.

하나. Meta와 Apple은 큐 전용 저장소를 새로 만들지 않았어요. 이미 운영하던 범용 저장소(MySQL, FoundationDB) 위에 큐를 얹었습니다.

둘. 같은 “큐"인데 RabbitMQ와 SQS는 느낌이 완전히 다르죠. 저장소에서도 그 차이가 보였어요. RabbitMQ는 큐 수가 적고 큐마다 바쁜 부하에 맞춰 큐별 로그 파일을 씁니다. SQS 같은 서비스는 작은 큐가 수백만 개 있고, 메시지가 순서 없이 지워져요. 이런 부하에는 키-값 저장소가 맞습니다.

검증된 저장소 위에 얹는 게 가장 빠른 길이긴 한데… 저는 pgmux(직접 만든 PostgreSQL 프록시)처럼 핵심을 직접 만들고, 원하는 대로 특화하고 싶었어요. 그래서 RocksDB를 계속 쓰되, 정말 큐로 쓸 만한지부터 확인하기로 했습니다.

🧪 RocksDB로 큐를 만들면 진짜 되나?

이번엔 단순 읽기·쓰기가 아니라 큐 부하를 걸었어요. 키는 이렇게 잡았습니다.

m | 큐 | 순번              → 메시지 본문
v | 큐 | visible_at | 순번 → 받을 수 있는 시각 색인 (빈 값)

받기는 v의 맨 앞에서 10건을 찾고, 색인을 가시성 타임아웃이 끝나는 시각(lease 만료 시각)으로 옮깁니다(지우고 다시 쓰기). 처리 완료는 둘 다 지워요. 그리고 처리가 밀린 큐를 비우는 속도를 쟀습니다.

경우처리량 (받기 + 처리 완료)받기 p50
큐 1000개 × 1000건, 매번 큐 처음부터 찾기초당 12만 건66µs
같은 조건, 다음에 읽을 위치 기록초당 29만 건21µs
큐 1개에 20만 건이 밀림, 매번 처음부터 찾기초당 4천 건 (15초에서 끊음, 약 6만 건까지의 값)2.4ms
같은 조건, 다음에 읽을 위치 기록초당 30만 건19µs

“다음에 읽을 위치를 기록 안 하면 75배나 느려진다고?”

RocksDB에서 지운 키는 바로 사라지지 않고 삭제 표시(tombstone)로 남아요. 큐는 앞에서부터 계속 지우니까, 큐 앞부분에 삭제 표시가 산더미처럼 쌓입니다. 매번 큐 처음부터 찾으면 그걸 다 훑느라 느려지는 거예요. RocksDB 공식 위키의 Implement Queue Service Using RocksDB에서도 경고하는 문제였습니다.

해결은 간단해요. 큐마다 다음에 읽을 위치를 기록해 두고 거기서부터 찾으면 됩니다.

// 처음 찾은 키를 다음 시작 위치로 기록
byte[] lowKey = useMark ? vKey(q, mark[q], markSeq[q]) : vBound(q, 0);
...
it.seek(lowKey);
...
mark[q] = vis[0];
markSeq[q] = sq[0];

솔직히 고백하면 측정 코드를 처음 짤 때 저도 똑같이 실수했어요. 시각만 기록하고 순번은 안 기록했더니, 미리 쌓아 둔 메시지가 전부 같은 시각이라 효과가 하나도 없었습니다. 순번까지 기록하고 나서야 숫자가 제대로 나왔어요.

“삭제 표시는 진짜 조심해야겠다…”

결론: RocksDB로 큐는 된다. 단, 다음에 읽을 위치를 기록해 삭제 표시를 건너뛰지 않으면 무너진다.

🧭 새 설계: RocksDB 엔진으로 먼저, 자체 엔진은 나중에

그럼 RocksDB로 끝인가 하면, 그건 또 아니에요. RocksDB는 범용 저장소라 큐에는 손해 보는 부분이 있습니다.

  • 쓰기 증폭: 처리가 밀리면 컴팩션이 본문을 여러 번 다시 쓴다
  • 삭제 표시: 위에서 본 것처럼 쌓이면 느려진다
  • 컴팩션 멈춤: 쓰기가 오래 몰리면 p99가 튈 수 있다
  • 이중 쓰기: 나중에 Raft로 복제하면 Raft 로그와 RocksDB에 두 번 쓴다

그래서 이렇게 가기로 했어요.

“껍질(API, 큐 동작, 복제, 분할)을 RocksDB 엔진으로 먼저 완성하고, 나중에 엔진만 우리 것으로 갈아 끼운다.”

핵심은 엔진 인터페이스를 큐 연산 단위(보내기, 받기, 가시성 타임아웃 변경, 지우기)로 두는 거예요. 키-값 단위로 두면 나중에 만들 엔진도 키-값 저장소가 돼야 해서 특화할 여지가 없거든요.

자체 엔진은 RocketMQ에서 힌트를 얻었어요. 파티션마다 커밋 로그를 하나 두고, 그 파티션에 든 모든 큐의 메시지를 들어온 순서대로 이어 씁니다. 큐마다는 (로그 위치, 크기, 받을 수 있는 시각)만 담은 작은 색인을 둡니다. 큐가 수백만 개여도 큐마다 파일을 따로 만들지 않아요. 본문은 한 번만 쓰고, 처리 완료는 삭제 표시가 아니라 상태 기록으로 남깁니다.

단계내용
0측정 토대: 엔진 인터페이스·RocksDB 엔진 기본 연산·벤치마크를 Rust로 옮겨 µs 단위로 재기
1껍질(API·큐 동작) + RocksDB 엔진 (한 대)
2자체 엔진 v1. 같은 테스트와 벤치마크로 RocksDB와 비교
3여러 노드: 파티션을 노드에 나눠 맡기고 Raft로 복제
4커밋 로그를 Raft 로그로 같이 쓰기 (본문은 한 번만)
5파티션 자동 분할, 테넌트 간 공정성

겉 동작은 계속 AWS SQS 따라잡기예요. 이번엔 아예 SQS 호환 API(AWS JSON 프로토콜)로 만들어서, AWS SDK를 그대로 붙이고 같은 SQS 호환 서버인 ElasticMQ와 같은 조건으로 비교해 볼 생각입니다.

🦀 그리고… Rust로 갑니다

Kotlin + WebFlux로 시작한 프로젝트인데, 다시 짜는 김에 언어도 다시 골랐어요. JVM, Go, Rust를 놓고 비교했는데 결정적인 건 2단계 자체 엔진이었습니다.

  • 메시지 수억 건의 색인을 메모리에 들고 있어야 하는데, GC가 있는 언어에서는 이게 GC 부담이 돼요. Rust는 GC가 없고 메모리 배치를 직접 정할 수 있습니다.
  • 4단계에서 커밋 로그를 Raft 로그로 같이 쓰려면 로그 저장을 앱에 맡기는 Raft 라이브러리가 필요해요. Rust에는 raft-rs(TiKV가 쓰는, etcd raft를 옮긴 것)가 있어서 이 계획이 막히지 않아요. Go의 etcd raft도 같은 구조라 Rust만의 장점은 아닙니다.
  • 1단계 엔진도 지금 잰 것과 같은 RocksDB라서, 버전만 맞추면 기준선이 이어져요.

코드는 AI랑 같이 짤 거라 언어를 익히는 비용은 크게 신경 쓰지 않기로 했어요. 대신 짠 코드가 맞는지는 테스트가 판정하게 합니다. 강제 종료 반복, 결정적 시뮬레이션, 두 엔진에 같은 정확성 테스트를 돌리는 식으로요. Rust 컴파일러가 메모리 실수를 막아 주는 것도 여기서는 장점이 됩니다.

“Kotlin, WebFlux 공부하려고 시작했는데 Rust 저장 엔진이 됐네…”

설계 문서는 저장소의 docs/design/storage-engine.md에 정리해 뒀습니다.

🚀 다음에 할 것

  • 0단계: Cargo workspace 뼈대, 엔진 인터페이스, RocksDB 엔진 기본 연산, 벤치마크를 Rust로 옮기기
  • RocksDB를 Rust 바인딩으로 다시 재서 이번 JNI 숫자와 비교하기 (RocksDB 버전을 맞춰서)
  • SQS 호환 API로 AWS SDK 붙여 보기
  • 언젠가: 자체 엔진으로 RocksDB 이겨 보기