JSON은 잊으세요. 이 4가지 데이터 형식으로 API 속도가 5배 빨라졌습니다

|MSA & Architecture|21분 읽기

JSON이 멀쩡해 보이는데 왜 느릴까

운영 중인 서비스의 응답 시간을 들여다보다가 이상한 걸 발견한 적이 있습니다. DB 쿼리는 빠른데 응답 전체는 느렸어요. 로직도 단순했고요. 범인을 추적해보니 직렬화 구간이었습니다. JSON으로 객체를 문자열로 바꾸고, 받는 쪽에서 다시 객체로 푸는 그 과정 말이죠.

평소엔 아무도 신경 쓰지 않는 구간입니다. JSON은 편하니까요. 브라우저 콘솔에서 바로 읽히고, 팀원이 메모장으로 열어도 이해합니다. 디버깅할 때 그냥 눈으로 보면 됩니다. 이 편안함이 워낙 강력해서 대부분은 여기서 멈춥니다.

그런데 편안함에는 비용이 붙습니다.

서버가 응답을 직렬화할 때마다 메모리에 구조화되어 있던 데이터를 문자 단위로 평면 문자열로 변환합니다. 클라이언트는 그 문자열을 받아 다시 파싱하고 구조를 재구성하죠. 한두 번이면 티가 안 납니다. 그런데 운영 환경에서는 이 작업이 초당 수천 번씩 일어납니다. 그래도 "문제없이 돌아간다"고 판단하는 경우가 많습니다.

괜찮지 않습니다.

제 서비스에서 실제로 잰 수치를 보겠습니다. 사용자 레코드 10,000개를 JSON으로 담은 페이로드는 2.3MB였고, 직렬화에 약 180ms가 걸렸습니다. 동일한 데이터를 바이너리 형식으로 바꾸면 어떻게 될까요? 용량은 620KB, 소요 시간은 약 34ms로 줄었습니다.

같은 데이터인데 5배 빨라진 겁니다. 데이터 내용은 한 글자도 안 바꿨습니다. 그릇만 바꿨을 뿐인데요. 무엇을 어떻게 바꿨는지 하나씩 풀어보겠습니다.

1. Protobuf — 필드 이름 대신 번호를 보낸다

구글이 내부에서 수년간 쓰다가 오픈소스로 공개한 형식입니다. 아이디어는 의외로 단순합니다. 스키마를 .proto 파일에 한 번 정의해두면, 라이브러리가 인코딩과 디코딩을 정해진 바이너리 형식으로 처리합니다.

// user.proto
syntax = "proto3";

message User {
  int32 id = 1;
  string name = 2;
  string email = 3;
}

Node.js에서 인코딩/디코딩은 이렇게 합니다.

const protobuf = require('protobufjs');

async function run() {
  const root = await protobuf.load('user.proto');
  const User = root.lookupType('User');
  const msg = User.create({ id: 1, name: 'Arjun', email: 'a@dev.io' });
  const buf = User.encode(msg).finish(); // 바이너리 버퍼 생성
  const decoded = User.decode(buf);      // 객체로 변환
}

핵심은 전송되는 게 필드 번호(1, 2, 3)라는 점입니다. 필드 이름이 아니에요. JSON에서 "name"이라는 문자열은 매 응답마다 4바이트를 차지합니다. Protobuf에서는 같은 정보가 필드 태그 1바이트로 끝납니다.

별것 아닌 것 같죠. 그런데 하루에 백만 건의 요청이 오간다고 상상해보세요. 필드 하나당 3바이트씩, 필드가 10개면 요청당 30바이트. 백만 건이면 매일 수십 MB가 그냥 네트워크에서 증발합니다. 이건 마이크로 최적화가 아니라 실질적인 인프라 비용입니다. 트래픽 요금, 대역폭, CPU 사이클 전부에 직접 닿거든요.

잘 맞는 곳: 내부 서비스 간 통신, gRPC API, 처리량이 높은 데이터 파이프라인. 스키마를 양쪽이 공유할 수 있는 환경이면 거의 항상 답이 됩니다.

2. MessagePack — 헬스장 다녀온 JSON

MessagePack은 살 빼고 온 JSON이라고 생각하면 편합니다.

여전히 키-값 구조입니다. 지원하는 데이터 타입도 거의 같고요. 다른 점은 단 하나, 텍스트 대신 전부 바이너리로 인코딩한다는 것입니다. 스키마가 필요 없습니다. 기존 JSON 객체를 거의 마이그레이션 작업 없이 그대로 직렬화할 수 있어요.

솔직히 말하면 저는 Protobuf 스키마를 작성할 인내심이 없어서 이걸 먼저 시도했습니다. 웹소켓 페이로드를 JSON에서 MessagePack으로 바꾸는 데 오후 한나절이면 충분했어요. 비즈니스 로직은 한 줄도 안 건드렸는데 페이로드 크기가 38% 줄었습니다.

const msgpack = require('@msgpack/msgpack');

const data = { id: 1, name: 'Arjun', score: 98.5 };
const encoded = msgpack.encode(data); // Uint8Array, 약 20바이트
const decoded = msgpack.decode(encoded); // 다시 일반 객체로

같은 데이터를 JSON.stringify(data)로 만들면 {"id":1,"name":"Arjun","score":98.5}, ASCII로 37바이트입니다. MessagePack은 20바이트 안쪽이고요.

어떻게 이게 가능할까요. MessagePack은 작은 정수, 불리언, null 같은 흔한 값을 전체 문자열로 적지 않고 단일 바이트로 표현합니다. true를 4바이트가 아니라 1바이트로 끝내는 식이죠. 누적되면 차이가 큽니다.

다만 대가가 있습니다. 가독성이 사라집니다. 전환하기 전에 반드시 로깅과 디버깅 파이프라인이 바이너리 데이터를 다룰 수 있는지 확인해야 합니다. 이걸 안 하고 넘어가면 나중에 장애 상황에서 후회하게 되거든요. 이 얘기는 뒤에서 다시 하겠습니다.

잘 맞는 곳: WebSocket 페이로드, Redis 캐싱처럼 네트워크 양쪽 끝을 모두 내가 통제할 수 있는 환경. 외부에 노출되지 않는 구간이라면 가장 진입 장벽이 낮은 선택입니다.

형식이 자리잡는 위치 — 경계의 문제

여기서 제 머릿속을 정리해준 사고방식을 공유하겠습니다. 데이터 형식은 그 데이터를 읽는 게 사람인지 기계인지에 따라 달라져야 합니다. 이게 전부입니다.

클라이언트 앱
     |
     | (REST/HTTP - JSON. 사람이 직접 디버깅하는 구간)
     v
API 게이트웨이
     |
     | (MessagePack 또는 Protobuf - 시스템 전용)
     v
서비스 A ----> 서비스 B ----> 서비스 C
     |                          |
     | (Avro - 이벤트 스트리밍)   |
     v                          v
Kafka / Redpanda            캐시 (MessagePack)

경계가 중요합니다. 외부 API는 가독성이 생명이고, 내부 호출은 속도가 생명입니다. 그런데 대부분의 팀은 모든 구간에 JSON 하나만 깔아놓고 "왜 느리지"를 반복합니다.

원인과 결과를 분리해보면 명확합니다. 느린 건 결과고, 원인은 사람이 읽을 일이 없는 내부 구간까지 사람이 읽기 좋은 형식을 쓰고 있다는 것입니다. 그 답은 사실 Content-Type 헤더 한 줄에 들어 있습니다. 어디서 무슨 형식을 쓸지 결정하는 그 지점 말이죠.

저는 이걸 시스템의 "피부"와 "내장"으로 봅니다. 피부는 외부와 닿는 부분이라 사람이 만지고 들여다봅니다. JSON이 맞아요. 내장은 안에서 빠르게 돌아가야 하고 아무도 직접 보지 않습니다. 여기에 텍스트 형식을 쓰는 건 내장 기관에 라벨을 붙여놓는 것과 비슷합니다.

3. Apache Avro — 스키마가 변해도 깨지지 않는다

Avro는 Kafka로 파이프라인을 짜본 사람이라면 결국 마주치게 되는 형식입니다. 아무도 처음에 알려주지 않지만 Kafka가 조용히 기대하고 있는 형식이거든요.

Protobuf처럼 스키마 기반인데, 결정적 차이가 있습니다. 스키마를 데이터와 함께 저장합니다. 그래서 시간이 지나며 스키마가 변하는 이벤트 스트리밍에 특히 강합니다.

생각해보세요. 지난주에 필드 3개를 추가했습니다. 그런데 6개월 전에 기록된 이벤트를 읽는 컨슈머는 여전히 동작해야 합니다. 스키마가 바뀌었다고 과거 데이터가 못 읽히면 그건 데이터 레이크가 아니라 데이터 무덤이죠. Avro는 이 스키마 진화(schema evolution)를 매끄럽게 처리합니다.

const avro = require('avsc');

const UserEvent = avro.Type.forSchema({
  type: 'record',
  name: 'UserEvent',
  fields: [
    { name: 'id', type: 'int' },
    { name: 'event', type: 'string' },
    { name: 'ts', type: 'long' }
  ]
});

const buf = UserEvent.toBuffer({ id: 42, event: 'login', ts: Date.now() });
const obj = UserEvent.fromBuffer(buf);

수치로 말하면 이렇습니다. 내부 이벤트 버스를 Avro로 옮긴 후, Kafka 컨슈머 지연이 최대 부하 시 40초에서 4초 미만으로 떨어졌습니다. 하드웨어도 그대로, 토픽 파티션도 그대로였어요. 바꾼 건 와이어 포맷 하나뿐입니다.

이 수치를 처음 봤을 때 솔직히 의심했습니다. 10배라는 게 보통 측정 오류거든요. 그런데 며칠 더 관찰해도 같았습니다. 컨슈머가 따라잡지 못하던 병목이 직렬화/역직렬화 비용이었던 거죠.

Confluent든 AWS Glue든, 스키마 레지스트리와 통합해두면 대규모 파이프라인을 운영하는 팀이 바로 쓸 수 있게 정리됩니다.

잘 맞는 곳: Kafka 이벤트 스트리밍, 데이터 레이크, 그리고 시간에 따른 스키마 변화가 중요한 고려 사항이 되는 모든 곳.

4. FlatBuffers — 상자를 풀지 않고 그대로 읽는다

이 목록에서 가장 저평가됐으면서 동시에 가장 오해받는 형식입니다.

역시 구글이 만들었는데, 접근 방식이 완전히 다릅니다. 다른 형식들은 객체를 바이트로 인코딩했다가 반대편에서 디코딩합니다. FlatBuffers는 그 디코딩 단계를 아예 없앱니다. 코드가 바이트 버퍼를 직접 읽을 수 있는 구조로 처음부터 메모리를 구성하거든요.

비유하자면 이렇습니다. 다른 형식은 택배 상자를 받아서 포장을 뜯고 내용물을 꺼내야 합니다. FlatBuffers는 상자를 안 풉니다. 상자 자체를 그대로 읽습니다. 역직렬화라는 작업이 존재하지 않는 거죠.

// .fbs 스키마에서 JS 코드를 생성한 후:
const flatbuffers = require('flatbuffers');
const { Monster } = require('./monster_generated');

const builder = new flatbuffers.Builder(128);
const name = builder.createString('Orc');
Monster.startMonster(builder);
Monster.addHp(builder, 300);
Monster.addName(builder, name);
const orc = Monster.endMonster(builder);
builder.finish(orc);
const buf = builder.asUint8Array();

const monster = Monster.getRootAsMonster(new flatbuffers.ByteBuffer(buf));
console.log(monster.name()); // 'Orc' - 버퍼에서 직접 읽기, 제로 카피
console.log(monster.hp());   // 300

복사가 없습니다. 읽을 때 메모리 할당도 없습니다. 지연 시간에 극도로 민감한 시스템, 그러니까 실시간 피드, 금융 거래 데이터, 게임 백엔드 같은 곳에서는 이게 완전히 다른 차원의 성능을 만듭니다. 뒤에 나올 벤치마크에서 FlatBuffers 역직렬화 시간을 보면 오타로 의심하실 텐데, 오타가 아닙니다.

대신 장벽도 분명합니다. 스키마(.fbs)를 작성하고 코드 생성 단계를 거쳐야 합니다. 데이터를 빌드하는 코드도 다른 형식보다 장황하고요. 그래서 "빠르니까 다 이걸로"가 아니라, 정말 마지막 수 밀리초가 돈이 되는 곳에 골라서 씁니다.

잘 맞는 곳: 실시간 시스템, 금융 데이터 피드, 게임 서버, 임베디드 시스템.

벤치마크 — 실제 수치로 보기

Node.js 20 환경에서, 레코드당 8개 필드를 가진 5,000개 레코드 페이로드로 측정했습니다.

형식 크기 직렬화 역직렬화
JSON 1.8 MB 142 ms 98 ms
MessagePack 1.1 MB 61 ms 44 ms
Protobuf 680 KB 38 ms 29 ms
Avro 590 KB 35 ms 31 ms
FlatBuffers 720 KB 28 ms ~2 ms*

*FlatBuffers의 역직렬화 시간이 거의 0인 이유는 역직렬화 과정 자체가 없기 때문입니다. 코드가 메모리의 원시 버퍼에서 직접 읽습니다.

표를 구조적으로 읽어보면 흥미롭습니다. 크기는 Avro가 가장 작고(590KB), 직렬화는 FlatBuffers가 가장 빠르고(28ms), 역직렬화는 FlatBuffers가 압도적입니다(~2ms). 즉 "무조건 1등인 형식"은 없습니다. 무엇을 줄이고 싶은지에 따라 답이 달라져요.

크기를 줄여 네트워크 비용을 아끼고 싶다면 Avro나 Protobuf. 읽기 지연을 0에 수렴시키고 싶다면 FlatBuffers. 코드 변경을 최소화하고 싶다면 MessagePack. 이게 형식 선택의 진짜 기준입니다. 벤치마크 1등을 고르는 게 아니라, 내 병목이 어디인지 먼저 보는 거죠.

그래서 JSON을 완전히 버려야 하나

아니요. 그건 틀린 결론입니다. 저도 주말 내내 "전부 바이너리로 가야 한다"고 스스로를 설득하려다 실패했거든요.

JSON은 공개 API, 설정 파일, 그리고 사람이 데이터를 읽어야 하는 모든 곳에서 여전히 옳습니다. 디버깅 편의성은 그 자체로 엄청난 자산이고, 도구 생태계의 지원도 압도적입니다. 이걸 무시하고 전부 바이너리로 밀어붙이면 개발 속도와 운영 안정성을 갈아 넣게 됩니다.

앞에서 미뤄둔 얘기를 여기서 하겠습니다. 운영 환경에서 처음으로 장애가 터졌는데, 로그를 열었더니 읽을 수 없는 바이너리 덩어리가 나오는 경험. 한 번 겪어보면 제 말뜻을 정확히 이해하게 됩니다. 새벽 3시에 알 수 없는 바이트 배열을 노려보는 그 기분은, 38% 절약한 페이로드 크기로는 보상이 안 됩니다.

그래서 결정 기준을 단순하게 정리하면 이렇습니다.

  1. 사람이 직접 읽고 디버깅하는 구간이라면 JSON. 외부 API, 설정 파일, 웹훅 페이로드.
  2. 내부 서비스 간 통신이고 스키마를 공유할 수 있다면 Protobuf. gRPC와 함께라면 더 자연스럽습니다.
  3. 코드 변경 없이 빠르게 줄이고 싶고 양쪽 끝을 다 통제한다면 MessagePack. Redis, 웹소켓.
  4. 이벤트 스트리밍이고 스키마가 시간에 따라 변한다면 Avro. Kafka 생태계의 기본값에 가깝습니다.
  5. 마지막 1ms가 돈이 되는 실시간 시스템이라면 FlatBuffers. 금융 피드, 게임 서버.

결국 중요한 건 의도성

이 모든 걸 한 줄로 줄이면, 진짜 변화는 형식을 바꾸는 게 아니라 의도를 갖고 형식을 선택하는 법을 배우는 것입니다.

JSON이 느린 게 아닙니다. 아무 생각 없이 모든 경계에 JSON을 깔아두는 습관이 느린 겁니다. 사람이 시스템에 닿는 경계에는 JSON을, 기계끼리 대량으로 떠드는 내부에는 바이너리를. 이 구분 하나만 의식해도 시스템의 성격이 달라집니다.

저는 컨설팅을 다니면서 "성능이 안 나온다"는 시스템을 꽤 봤는데, 의외로 많은 경우 데이터 형식의 경계가 흐릿했습니다. 코드를 고치기 전에 Content-Type부터 들여다보는 게 빠를 때가 있더라고요. 도구를 바꾸는 것보다 어디에 무엇을 쓸지 결정하는 안목이 먼저입니다.

#Protobuf#MessagePack#Avro#FlatBuffers#직렬화