DevYGwan
Arrow vs JSON - 대용량 시계열 조회 API 성능 개선기 본문
IoT 시계열 데이터를 과거부터 지금까지 조회하는 화면에서 어느 순간부터 데이터가 많아지니 조회 기간이 길어질수록(수백만 ~ 천만 행) 로딩이 급격히 느려졌고, 1년 데이터 조회 기준으로 JSON API는 요청부터 차트 렌더링까지 짧게는 47초, 길게는 1분 이상 걸리는 경우 발생했습니다. 이러다보니 한번에 조회하는 데이터가 많아질 수록 다음과 같은 추가적인 문제가 발생했습니다.
- 사용자가 해당 화면에서 너무 오랜 시간을 기다려야 되는 문제 발생
- 조회 기간이 길어질수록 AWS ALB의 기본 idle timeout(60초)에 근접하게 돼, 1분 이상 조회해야 할 경우 타임아웃으로 요청이 실패하는 케이스가 존재
- 많은 데이터를 한번에 메모리에 저장해 서버 내에서 OOM 에러가 발생
라는 단점이 존재했습니다. 물론 AWS의 ALB timeout 시간을 늘리고 데이터가 많을 경우 스트리밍 방식으로 바꾸면 위와 같은 문제는 해결될 수 있지만, 근본적으로 데이터 조회 속도를 늘릴 필요가 있었습니다. 그러다 같은 데이터를 Apache Arrow IPC 스트리밍으로 바꾸자 4.5초가 됐고, 첫 데이터는 268ms 만에 도착하기 도착하기 시작했습니다.
그래서 데이터 조회 성능을 향상시키기 위해 Arrow 데이터형을 사용한 이유와 왜 구조적으로 이러한 차이가 날 수 밖에 없는지 이 글을 통해 공유하려고 합니다.
※ 왜 JSON API는 대용량에서 무너지는가?
문제는 JSON이라는 포맷 하나가 아니라, JSON API의 관행적 처리 구조에 있습니다.
DB → 전체 rows를 List/배열로 수집 (힙 적재) → JSON 직렬화 → 응답 바디
Spring 기준으로는 모든 행을 List<DTO>/List<Map>으로 로드한 뒤 Jackson(ObjectMapper)이 전체 리스트를 직렬화하는 전형적인 패턴이다. 이 구조에는 세 가지 근본 특성이 있습니다.
※ 물질화(materialization) — 힙 점유가 O(N)
- 결과 크기에 비례해 힙을 점유한다.
- N행이면 N개의 객체 그래프(Map/DTO/String/박싱된 숫자)가 동시에 힙에 적재됩니다. 직렬화 중에는 원본 객체 + 직렬화 버퍼가 공존하므로 피크는 더 큽니다.
- 점유 시간이 요청 수명 전체다.
- 응답을 다 만들 때까지 리스트는 살아있는 참조라 GC가 회수할 수 없다 — retained heap이다.
- 동시성으로 곱해진다.
- 동시 요청 C개가 각각 대용량 결과를 붙잡으면 힙 압박 ≈ (요청당 크기) × C. "크기 × 동시성"이 힙 상한을 넘는 순간 OOM이 발생합니다. 요청 하나는 괜찮은데 겹치면 죽는 전형적인 패턴.
JSON도 응답을 다 만들어 쥐고 있지 말고 스트리밍 방식으로 행/청크 단위로 흘려보내면(NDJSON·청크드 응답 등) 서버가 한 번에 붙잡는 양이 O(N) → O(chunk)로 줄어, 이 절의 물질화 문제는 상당 부분 완화됩니다. 다만 스트리밍은 "언제·얼마씩 보내느냐"를 바꿀 뿐"각 값을 어떤 형태로 담느냐"는 바꾸지 못합니다. 그래서 뒤에서 다룰 크기(1.2)와 변환 CPU(1.3)는 스트리밍만으로는 그대로 남고, 포맷 자체를 바꿔야 사라집니다.
※ 텍스트 포맷의 가중 요인
- 숫자를 텍스트로 표현한다.
- JSON에는 바이너리 숫자 타입이 없어 모든 숫자가 10진 ASCII입니다.(RFC 8259). 1000000000은 바이너리 4바이트 vs ASCII 10바이트.
- 행마다 필드명이 반복된다.
- 배열의 모든 객체가 같은 키를 다시 쓴다. 컬럼형 포맷은 스키마 블록 1개에 필드명을 한 번만 담는다.
- 구조 문자 오버헤드.
- 따옴표·[ ] { } : ,·이스케이프가 요소마다 붙는다.
※ 직렬화·역직렬화 — 변환 CPU가 양쪽에서 O(N)
- 값마다 두 번 변환한다.
- 서버가 숫자를 텍스트로 바꾸고(직렬화), 클라이언트가 다시 숫자로 되돌립니다.(역직렬화).
- 행 × 필드만큼 반복되니 양쪽 다 O(N).
- 파싱은 그냥 변환이 아니다.
- 따옴표·구분자 판별 + 객체 생성까지 거쳐, 클라이언트에도 1.1의 물질화 힙이 똑같이 생긴다.
- UI가 멈춘다.
- 브라우저 JSON.parse는 블로킹이라, 대용량이면 파싱 끝날 때까지 화면이 얼고 그 뒤에야 렌더된다.
※ Apache Arrow는 무엇이 다른가
Apache Arrow는여러 시스템이 데이터를 복사·변환 없이 주고받기 위한 공통 인메모리 데이터 표현 규격입니다. 파일 포맷도 DB도 아니고, 메모리에 데이터를 어떻게 배치할지를 정해둔 표준(spec)입니다. 따라서 아래 두 단어가 핵심입니다.
- 인메모리(in-memory).
- CSV·JSON·Parquet은 저장/전송용 표현이라 쓰려면 메모리에서 객체로 다시 풀어야 하지만, Arrow는 메모리에 올라간 상태 그 자체를 표준화해, "읽어서 객체로 변환"하는 단계를 생략합니다.
- 컬럼 지향(columnar).
- 행 단위가 아니라 컬럼 단위로 값을 연속 저장해, {시간, 온도, 습도}를 행마다 반복하지 않고 시간은 시간끼리·온도는 온도끼리 한 덩어리로 모아서 관리합니다.
따라서, 원래라면 도구마다 자기 내부 포맷을 써서 데이터를 넘길때마다 "포맷 변환" 작업이 필요했습니다. 데이터가 많아지면 이에 대한 비용이 많이 발생하게 돼 문제가 돼 Arrow의 발상은, "모두가 같은 메모리 포맷을 쓰면 그 변환이 사라진다." 라는 것으로 시작됐습니다. 따라서 한쪽이 만든 버퍼를 다른 쪽이 재해석 없이 그대로 열 수 있어 데이터 변환 작업이 없어지고 더 효율적으로 데이터 전송이 가능합니다.
JSON과의 핵심 차이는 세 층위로 나뉩니다.
※ 언어 무관 공통 포맷 — 물질화(1.1)를 완화한다.
- 서버·클라이언트가 같은 바이트 레이아웃을 공유한다.
- 자바 서버가 만든 버퍼를 JS 클라이언트가 재해석 없이 그대로 열 수 있습니다.(양쪽 라이브러리가 동일 규격을 구현).
- 객체 N개 대신 컬럼 버퍼 몇 개.
- 힙에 DTO/Map/박싱 숫자를 N개 쌓는 대신, 컬럼당 연속 버퍼 하나를 잡기 때문에, 물질화 피크와 GC 압박이 크게 줄어든다.
RecordBatch = 길이가 같은 컬럼 배열들의 묶음, 전송·처리의 단위.
- IPC 스트림 포맷은 스키마 → RecordBatch 0..n 순차 구조라 일부가 준비되는 즉시 흘려보낼 수 있다.
- 서버는 한 번에 배치 1개만 들고 있으면 되므로, 힙 점유가 결과 크기 O(N)이 아니라 배치 크기 O(batch) 상수가 된다.
※ 컬럼 지향 메모리 레이아웃 — 크기 문제(1.2)를 없앤다.
- 같은 컬럼 값을 연속으로 담는다.
- 행 단위가 아니라 컬럼 단위로 저장해, 필드명을 행마다 반복하지 않고 스키마 블록 한 번으로 끝낼 수 있습니다.
- 숫자는 이진 고정폭.
- 10진 ASCII가 아니라 원래의 이진 표현(예: int64 8바이트, float64 8바이트)을 그대로 담고, 따옴표·구분자 같은 구조 문자 오버헤드도 없습니다.
- 같은 컬럼 값이 나란히 붙어 있어 압축률도 높다(사전 인코딩·RLE 등).
※ 직렬화 없는 전송 (zero-copy IPC) — 변환 CPU(1.3)를 없앤다.
- 메모리 표현 = 전송 표현.
- Arrow는 인메모리 포맷과 IPC 스트림 포맷이 사실상 동일합니다. 그래서 "객체 그래프 → 텍스트"로 바꾸는 직렬화, "텍스트 → 객체"로 되돌리는 역직렬화 단계 자체가 없습니다.
- 컬럼은 타입 + 길이 + 연속 버퍼로 구성된다.
- 고정폭(int64, float64)은 validity 비트맵 + data 버퍼 2개, 가변폭(utf8)은 validity + offsets + data 3개. 값은 네이티브 이진 타입이라 텍스트↔숫자 변환 비용이 없습니다.
- 역직렬화가 파싱이 아니라 "뷰 열기"다.
- 수신 측은 바이트를 토큰화·객체 재구성하지 않고, offset 산술로 버퍼를 그대로 가리켜(pointer) 컬럼 벡터로 읽어, JSON.parse처럼 메인 스레드를 붙잡고 객체 그래프를 짓는 단계가 없습니다.
※ DB가 Arrow를 네이티브로 뱉을 수 있다.
마지막으로, ClickHouse는 FORMAT ArrowStream으로 조회 결과를 Arrow IPC 스트림으로 직접 출력합니다. 그러면 API 서버는:
- ResultSet을 행 객체로 역직렬화하지 않고,
- DTO를 만들지 않고,
- 직렬화하지 않고,
- ClickHouse가 준 Arrow 바이트를 InputStream → StreamingResponseBody로 소켓에 그대로 흘릴 수 있습니다.
따라서 서버 힙에 행 객체가 아예 생기지 않는다. 물질화 문제의 근본 해법이 됩니다. 이를 Clickhouse는 자동으로 지원하기 때문에 쉽게 DB -> 서버 -> 클라이언트 전 구간을 스트리밍 하기 쉽습니다.
※ 측정 결과
모든 테스트는 요청 시작부터 차트 렌더링 완료까지를 기준으로 비교했습니다.
※ 전체 소요 시간 (API 요청 → 렌더링 완료)
| 조회 기간 | 원본 Row 수 | Arrow (ms) | JSON (ms) | 성능 차이 |
| 1개월 | 81,675 | 111.7 | 337.8 | 약 3배 |
| 2개월 | 163,350 | 157.9 | 880.5 | 약 5.6배 |
| 6개월 | 980,100 | 539.2 | 3,605.1 | 약 6.7배 |
| 1년 | 4,492,125 | 1,847.5 | 17,139.6 | 약 9.3배 |
| 2년 | 10,454,400 | 4,516.9 | 47,713.4 | 약 10.5배 |
전송 데이터가 커질수록 격차가 벌어집니다. 이는 JSON과 Arrow의 구조적 이유(파싱 차수, 파이프라이닝, 고정비 희석) 때문입니다.
※ 처리량 (rows/sec)
| 조회 기간 | Arrow | JSON |
| 1개월 | 1,028,652 | 242,503 |
| 2개월 | 1,376,158 | 185,900 |
| 6개월 | 2,557,672 | 271,933 |
| 1년 | 3,462,939 | 262,097 |
| 2년 | 3,500,435 | 215,686 |
Arrow는 규모가 커질수록 처리 효율이 올라가고(고정비 희석 + 배치 처리), JSON은 20만 rows/sec 초반대에서 병목에 갇히는 것을 볼 수 있습니다.
※ 규모 증가에 따른 열화 곡선
| 방식 | 1개월 → 2년 | 증가 배수 |
| JSON | 337ms → 47,713ms | 약 140배 |
| Arrow | 111ms → 4,516ms | 약 40배 |
데이터는 128배(81,675 → 10,454,400행) 늘었을 때, Arrow는 약 40배 증가로 데이터 증가율(128배)보다 오히려 완만했던 반면, JSON은 약 140배로 데이터 증가율을 웃돌며 가파르게 성능이 안좋아 진 것을 확인할 수 있습니다. 이는, Arrow가 규모가 커질수록 고정비가 희석돼 효율이 올라가는 것과 대조적으로, JSON은 파싱·물질화 비용이 행 수를 따라 그대로 쌓여 병목에 갇혀 이러한 현상이 보이는 것을 확인할 수 있었습니다.
※ 언제 Arrow를 쓰고, 언제 쓰지 말아야 하나
여태까지 Arrow 데이터포맷에 대해서 정리를 했습니다. 이렇게만 보면 항상 Arrow 데이터형을 사용하는게 좋을 것 같은데 실제로 Arrow 데이터 포맷이 항상 정답은 아닙니다.
도입이 유리한 신호
- 조회 결과가 크고 가변적(수만~수백만 행), 상한이 불명확
- 결과가 표 형태(균질 컬럼)의 시계열/이벤트이고, 소비 측이 차트·분석·집계
- 동시 요청이 많음(대시보드 위젯 다수, 폴링), 힙 OOM/GC 압박이 이미 관측됨
- DB가 Arrow를 네이티브로 뱉음(ClickHouse ArrowStream, DuckDB, Snowflake JDBC 등) → 서버는 프록시만 하면 됨
쓰지 말아야 할 때
- 작은 결과셋: 스키마·메타·패딩의 고정비 때문에 몇 KB 응답에선 JSON이 더 작고 단순하다.
- 깊게 중첩/이질적인 데이터: 컬럼 지향은 평평한 표에 최적
- 디버깅·범용성 우선: JSON는 텍스트라 응답을 사람이 읽을 수 있지만, Arrow는 이진 포맷이라 같은 화면에서 깨져서 사람이 읽을 수 없다.
- 스키마가 자주 변함: Arrow의 엄격한 스키마는 비호환 소비자를 깨뜨린다.
위와 같이 무조건 Arrow 데이터 형을 사용하는 것은 오히려 성능이 저하될 수 있습니다. 상황에 따라 맞는 데이터 포맷을 사용하는 것이 중요한 것 같습니다.
※ 정리
간단하게 JSON vs Arrow의 처리 방식을 도식화하자면,

이렇습니다. 이를 마지막으로 정리하자면, 두 방식 모두 DB → 서버 → 클라이언트 → 렌더를 거치지만, 가운데 두 단계(서버·클라이언트)에서 하는 일의 양이 다릅니다.
- JSON은 양쪽에서 무거운 작업 진행
- 서버: DB 결과를 객체(DTO)로 매핑하고, 그 객체를 JSON 텍스트로 직렬화(숫자→글자 인코딩 포함).
- 클라이언트: 받은 텍스트를 역직렬화(파싱)하고, 그 결과로 객체 N개를 힙에 새로 생성.
- 즉 객체 ↔ 텍스트 왕복이 서버·클라이언트 양쪽에서 일어나고, 행 N개면 그 비용이 N에 비례
- Arrow는 그 단계들이 통째로 빠진다.
- 서버: 값을 텍스트로 바꾸지 않고 컬럼 버퍼에 이진 그대로 채워 전송(직렬화 사실상 없음).
- 클라이언트: 받은 버퍼를 파싱·객체 재구성 없이 그대로 가리킴(zero-copy 뷰). 객체 N개를 만드는 단계 없음
- 스키마를 읽고 버퍼 경계를 계산하는 최소 준비는 있지만, 이건 N에 비례하지 않는 고정비라 사실상 공짜에 가깝습니다.