개발
home
🗜️

msgpack + snappy, 정말 이득일까? — Node BFF Redis 캐시 직렬화 개선기

Created
2026/07/04
Tags
node
Typescript
성능 최적화
json
2026-07-04

개요

회사의 BFF 서버(Node + Fastify)는 단일 이벤트 루프 위에서 동작합니다. 이 서버의 Redis 캐시 헬퍼가 캐시를 읽고 쓸 때마다 메인 스레드에서 동기 직렬화·압축 작업을 하고 있었고, 트래픽이 몰리면 이벤트 루프 블로킹으로 이어질 수 있는 구조였습니다.
처음에는 "동기 압축해제(snappy.uncompressSync)를 async로 바꾸자"는 작은 리팩토링으로 시작했는데, 실측 벤치마크를 해보니 진짜 병목은 압축이 아니라 직렬화(msgpack) 였습니다. 결국 msgpack을 걷어내고 JSON 1패스로 단순화해서 encode 경로의 동기 CPU를 60% 줄인 과정을 정리합니다.

본문

사전 지식: msgpack과 snappy

본문에 들어가기 전에 두 라이브러리를 간단히 정리합니다.
msgpack (MessagePack) — JSON과 같은 데이터 모델(객체/배열/문자열/숫자)을 바이너리로 인코딩하는 직렬화 포맷입니다. "It's like JSON. but fast and small."이라는 슬로건대로 타입 태그와 길이 정보를 바이너리로 붙여 텍스트 JSON보다 작은 출력을 만듭니다. 다만 스키마리스(schemaless)라 필드명은 여전히 문자열로 매번 저장되고, Node 구현(@msgpack/msgpack)은 순수 JS라 encode/decode가 이벤트 루프에서 도는 동기 CPU 작업입니다.
snappy — Google이 만든 압축 라이브러리입니다. gzip 계열보다 압축률은 낮지만 압축/해제 속도가 수백 MB/s로 매우 빨라 캐시·RPC·스토리지 내부처럼 지연에 민감한 곳에서 널리 쓰입니다(LevelDB, Kafka, Cassandra 등). Node의 snappy v7은 Rust 기반 네이티브 바인딩(napi-rs)이라, async API(compress/uncompress)를 쓰면 실제 작업이 libuv 스레드풀에서 실행되어 이벤트 루프를 막지 않습니다.
정리하면 이 캐시 헬퍼의 파이프라인은 "msgpack으로 작게 만들고, snappy로 한 번 더 줄인다"는 의도로 구성되어 있었습니다. 이 조합이 실제로 그만큼의 효과를 내는지가 이 글의 주제입니다.

문제: 캐시를 읽고 쓸 때마다 이벤트 루프가 멈춘다

기존 Redis 캐시 헬퍼의 직렬화 경로는 다음과 같았습니다.
private async _encode(data: unknown): Promise<Buffer> { // BSON 지원이 안되므로 해당 로직 추가 const msgpackData = JSON.parse(JSON.stringify(data)) const encodedData = msgpack.encode(msgpackData) return snappy.compress(Buffer.from(encodedData)) } private async _decode<T>(buffer: Buffer | null): Promise<T | null> { if (!buffer) return null const uncompressedData = snappy.uncompressSync(buffer) as Buffer return msgpack.decode(uncompressedData) as T }
TypeScript
복사
문제가 두 가지 보입니다.
쓰기(_encode)가 3패스: JSON.parse(JSON.stringify(data))로 BSON(ObjectId, Date)을 plain object로 변환 → msgpack.encodesnappy.compress. 같은 데이터를 세 번 순회합니다.
읽기(_decode)가 동기: snappy.uncompressSync는 캐시 히트마다 메인 스레드를 블로킹합니다. 쓰기 쪽은 이미 async compress를 쓰고 있어서 비대칭이기도 했습니다.
Node 단일 이벤트 루프에서 동기 CPU 작업은 그 시간만큼 서버 전체의 요청 처리가 멈추는 것과 같습니다. 캐시 히트가 많은 BFF 특성상 이 비용은 호출량에 비례해 누적됩니다.

진짜 병목 찾기 — 실측 벤치마크

처음 계획은 uncompressSync → async uncompress(snappy v7은 napi-rs 기반이라 libuv 스레드풀로 offload됨) 한 줄 수정이었습니다. 그런데 "snappy가 정말 병목인가?"를 확인하려고 prod 실데이터(상품 200건, 평균 6.3KB, Node v24)로 벤치마크를 돌려보니 예상과 다른 그림이 나왔습니다.
항목당 동기 CPU (= 이벤트 루프 블로킹 시간)
단계
msgpack(기존)
JSON(변경)
개선
encode
41.8µs
16.6µs
−60%
decode
18.6µs
16.9µs
−9%
압축 후 크기 (항목당 bytes)
raw
snappy 후
압축 비율
msgpack(기존)
5,367
2,159
0.402
JSON(변경)
6,332
2,280
0.360
요약하면:
msgpack은 raw 기준으로는 JSON보다 15% 작지만, snappy를 통과하고 나면 차이가 +5.3%(2,159 → 2,280B)로 줄어듭니다. snappy가 JSON의 반복되는 필드명을 이미 잘 압축해주기 때문입니다.
반면 직렬화 CPU는 msgpack 경로가 JSON 대비 2.5배(41.8µs vs 16.6µs)를 씁니다. 순수 JS인 msgpack encode에 3패스 변환 비용까지 얹혀 있었기 때문입니다.
snappy async의 wall time은 항목당 ~1.3µs 수준. snappy는 애초에 병목이 아니었습니다. 과거에 snappy를 async로 전환하고도 지표 개선이 안 보였던 이유가 실측으로 설명됐습니다.
"바이너리 직렬화 + 압축" 조합에서 두 단계는 역할이 겹칩니다. msgpack이 줄여주는 것(필드명 반복, 타입 오버헤드)은 snappy도 줄여줍니다. 뒤에 압축이 붙는다면 앞 단계 직렬화는 작은 출력보다 적은 CPU를 기준으로 골라야 합니다.

해결: 3패스 → 1패스, sync → async

벤치마크 결과를 바탕으로 msgpack을 제거하고 JSON 직렬화 1패스로 전환했습니다. 변경 후 코드입니다.
private async _encode(data: unknown): Promise<Buffer> { // JSON.stringify 가 ObjectId(toJSON)·Date(toISOString)를 직렬화하므로 별도 BSON 변환 불필요. // snappy 압축이 JSON 의 반복 필드명을 제거하여 msgpack 대비 크기 차이는 미미하면서 CPU 는 절감. return snappy.compress(Buffer.from(JSON.stringify(data))) } private async _decode<T>(buffer: Buffer | null): Promise<T | null> { if (!buffer) return null try { const uncompressed = (await snappy.uncompress(buffer)) as Buffer try { return JSON.parse(uncompressed.toString('utf-8')) as T } catch { // 구 포맷 호환: 배포 전 캐싱된 msgpack 데이터. TTL 만료 후 후속 PR 에서 제거 예정. return msgpack.decode(uncompressed) as T } } catch { return null } }
TypeScript
복사
핵심 포인트는 세 가지입니다.
JSON.parse(JSON.stringify(data)) 사전 변환 제거 — JSON.stringify는 ObjectId의 toJSON()(hex string)과 Date의 toISOString()을 알아서 호출하므로, msgpack에 넣기 위해 하던 BSON → plain 변환 자체가 필요 없었습니다.
msgpack.encode/decode 제거 — 순수 JS라 동기로 CPU를 쓰던 구간이 통째로 사라집니다.
uncompressSyncawait snappy.uncompress — 압축해제가 libuv 스레드풀로 내려가 이벤트 루프에서 분리됩니다.
JSON.parse(JSON.stringify())를 지울 때는 해당 헬퍼를 쓰는 모든 caller가 캐싱하는 타입을 확인해야 합니다. ObjectId·Date는 JSON.stringify가 처리해주지만, toJSON이 없는 특이 타입(Map, BigInt 등)을 캐싱하는 곳이 있다면 동작이 달라집니다. 이번 케이스는 40+ caller를 확인해 ObjectId/Date 외 특이 타입이 없음을 검증하고 진행했습니다.

캐시 flush 없는 무중단 마이그레이션

직렬화 포맷이 바뀌면 배포 전에 쌓인 캐시를 어떻게 할지가 문제입니다. flush는 간단하지만 배포 직후 cold-start로 원본 저장소에 부하가 몰립니다.
여기서는 _decodeJSON 우선 + 구 msgpack fallback 2단 구조로 만들어 해결했습니다.
배포 전 캐싱된 msgpack 데이터 → JSON.parse 실패 → fallback으로 정상 디코딩
신규 write는 모두 JSON 포맷 → 기존 키는 TTL 만료로 자연 교체
가장 긴 TTL이 지나면 후속 PR에서 fallback과 msgpack import를 제거
덕분에 캐시 flush 없이, cold-start 없이 포맷을 전환할 수 있었습니다.

정리

최적화의 시작은 계측입니다. "압축해제가 동기라 느리겠지"라는 가설로 시작했지만, 실측해보니 snappy는 항목당 ~1.3µs로 병목이 아니었고 진짜 비용은 msgpack 직렬화에 있었습니다.
압축 단계가 뒤에 있다면 바이너리 직렬화의 크기 이득은 생각보다 작습니다(이번 케이스 +5.3%). 그 대가로 내는 동기 CPU(−60%)가 훨씬 컸습니다.
포맷 전환은 read 경로의 fallback 한 겹으로 무중단이 됩니다. flush 없이 TTL로 자연 교체하는 패턴은 재사용할 만합니다.
결과적으로 encode 경로의 이벤트 루프 블로킹 60% 감소, 코드는 3패스에서 1패스로 단순해졌고, 의존성(msgpack)도 하나 줄었습니다.