Parquet 없이 S3에서 조회하기
시리즈Parquet와 S32/2
- Parquet 해부
- Parquet 없이 S3에서 조회하기
들어가며
지난 글에서 Parquet 파일을 해부했다. footer가 파일 끝에 있다는 약속, row group마다 붙는 min/max 통계, 블룸 필터까지 열어 보고 나니 반대 방향의 궁금증이 남았다. Parquet를 쓰지 않을 방법은 없을까. 쌓아둔 JSONL을 그대로 두고도 조회를 효율적으로 할 수는 없을까.
이 글에서는 실험을 해볼 것이다. 재료는 알림 발송 이력 형태의 합성 데이터다. 월 250만 건을 일자별 JSONL 파일 30개로 나눠 실제 S3 버킷에 올렸고, 전부 합쳐 590MB다. 유저는 대충 20만 명이라고 가정하면 한 명당 한 달 평균 12건이 남는다. 발송 이력, 감사 로그, 이벤트 아카이브처럼 쓰기는 매일 일어나지만 조회는 드문 데이터를 흉내낸 것이다.
여기서 “유저 X의 지난달 발송 내역”을 꺼내 보자. 인덱스가 없으니 30개 파일을 전부 내려받아 훑는 수밖에 없다. 실측으로 15.7초가 걸렸는데, 같은 조회가 다른 날에는 115초였다. 전송량이 590MB라 시간이 곧 회선 속도이고, 회선은 통제할 수 없기 때문이다. 12건 3KB를 꺼내려고 250만 건을 읽는 셈이다.
DB에 넣으면 해결되지만, 한 달에 몇 번 안 보는 아카이브에 DB를 붙이는 건 과하다. 대신 S3는 GET에 Range 헤더를 지원한다. 파일의 임의 바이트 구간을 집어올 수 있다는 뜻이고, 그 순간 S3는 읽기 전용 원격 디스크가 된다. 디스크 위에서 DB가 하던 일, 그러니까 인덱스를 그 위에 직접 지을 수 있다는 뜻이기도 하다. 하나씩 지어보고 쟀다.
Range 이진탐색
일자별 파일은 발송 순번 id 순서로 쌓인다. 파일이 정렬돼 있다는 사실만으로, 인덱스 없이 한 건을 찾을 수 있을까?
방법은 로컬 배열의 이진탐색을 왕복 요청으로 옮기는 것이다. 중간 지점에서 4KB만 Range로 받아, 첫 개행 뒤의 완전한 레코드 하나를 파싱해 id를 비교한다. 목표보다 작으면 왼쪽 절반을 버리고 크면 오른쪽 절반을 버린다. 남은 구간이 64KB 아래로 좁혀지면 그 구간만 통째로 받아 스캔한다.
18MB 일자 파일에서 프로브 열 번이 절반씩 버리며 목표를 좁혀가는 과정이다.
실측 0.81초, 전송 0.17MB로 풀스캔의 1/100이다. 데이터가 5배로 늘어도 로그 스케일이라 0.6초가 0.8초가 될 뿐이다. 클라이언트 코드는 30줄이면 된다. 정렬 자체가 인덱스인 것이다.
다만 이 트릭의 본질을 봐야 한다. “이 파일은 id로 정렬돼 있다”는 지식이 파일이 아니라 클라이언트 코드에 있다. 파일 안에 메타데이터가 있는 게 아니라, 외부 지식으로 메타데이터를 흉내내는 것이다. 이 구분이 글 끝까지 따라온다.
키값을 사용한 이진탐색
유저 조회에는 방금의 트릭이 통하지 않는다. user_id는 시간축에 산재하므로 id 정렬 파일에서 아무 위치도 특정할 수 없다. 정렬 밖의 키도 찾을 수 있을까?
DB라면 세컨더리 인덱스를 만들 자리다. 같은 것을 S3 위에 만든다. append 할 때 바이트 카운터로 [user_id, 일, offset, length] 한 줄을 같이 기록해 두고, 일 마감 때 user_id로 정렬해 사이드카 파일 하나로 묶는다. 쓰기 쪽 비용은 O(1) 기록과 배치 정렬 한 번이 전부다.
이 인덱스 파일 자체가 user_id로 정렬된 파일이므로, 본문에 쓰던 Range 이진탐색을 인덱스에 다시 적용한다. 키값 user_id로 이진탐색해 유저의 12줄이 모인 연속 구간을 찾으면, 각 줄의 (일, offset, length)로 본문을 핀포인트 GET 한다.
산재한 포인터가 정렬로 연속 구간이 되고, 그 구간을 딛고 본문을 집어오는 다섯 단계다.
실측 0.77초, 요청 24회, 전송 175KB로 풀스캔의 1/3,400이다. 마감된 로그는 불변이라 복잡한 구조가 필요 없다. 정렬 배열이 곧 완성된 인덱스다.
물론 공짜는 아니다. 이 인덱스는 결과가 수십 건인 등가 조회 전용이라 집계에는 무력하고, 조회 축이 늘 때마다 인덱스를 하나씩 더 지어야 하며, 한 건이라도 누락되면 그 건은 영영 못 찾으므로 완전성 검증이 앱 책임이 된다. 이 계산서는 결론에서 다시 편다.
인덱스 갱신을 더 효율적으로 하는 방법
여기까지의 트릭은 전부 “정렬된 불변 파일”이 전제다. 그런데 데이터는 매일 들어온다. 정렬 배열 중간에 새 키를 끼워 넣으면 뒤 전체를 밀어내는 O(N)이다. 매일 이 비용을 낼 수는 없다. 갱신이 계속되는데도 정렬을 유지할 수 있을까? DB들이 수십 년 전에 부딪힌 문제이고, 답은 두 갈래로 갈렸다.
B-tree
B-tree는 정렬을 삽입 시점에 제자리에서 유지한다. 디스크 페이지 하나를 노드로 삼아 키를 수백 개씩 담으면 수억 건도 깊이 3~4단인 넓고 얕은 트리가 된다. 조회는 루트에서 키를 비교해 자식 하나를 골라 내려가는 한 경로다. 범위 조회는 리프들이 연결 리스트라 시작점만 찾으면 옆으로 순차 스캔이다.
루트의 키 비교가 자식 하나를 고르고, 리프 연결 리스트가 범위를 담당하는 모습이다.
읽기에 최적화한 대가는 쓰기가 진다. 꽂힐 위치를 키 값이 정하므로 삽입은 리프 페이지의 제자리 수정이고, 페이지가 꽉 차면 분할이 부모로 번진다. 제자리 수정이 분할로, 분할이 랜덤 I/O로 번지는 쓰기 쪽 이야기는 이렇다.
LSM tree
LSM tree는 정렬 삽입을 포기한다. 쓰기는 메모리의 memtable로만 받고, 가득 차면 통째로 정렬해 불변 파일 SSTable로 순차로 흘려 쓴다. 갱신과 삭제도 옛 파일을 고치지 않고 새 값과 tombstone을 위에 얹을 뿐이다. 흐트러진 층들은 백그라운드의 compaction이 merge 해서 도로 정렬 하나로 만든다. 정렬을 삽입으로 지키는 대신 재작성으로 다시 얻는 것이다.
memtable 쓰기에서 flush, 층 쌓임, compaction까지 한 사이클을 따라간다.
이번엔 쓰기가 최적이 된 대가를 읽기가 진다. 키는 “가장 최근에 쓴 곳”에 있으므로 조회는 memtable부터 층을 위에서 아래로 훑어야 하고, 파일마다 붙은 블룸 필터가 헛걸음을 막아준다. 읽기가 층을 훑다 발견 즉시 멈추는 경로다.
두 트리 실측
둘 다 실제로 지어서 쟀다. B-tree는 4KB 페이지를 단일 파일에 이어 붙여 root 1장, internal 3장, leaf 785장으로 만들었다. 페이지 읽기 한 번이 Range GET 한 번이다. LSM은 일별 SSTable 30런에 런별 블룸 필터와 fence pointer를 담은 manifest를 붙였다. 조회 대상은 같은 유저 1명의 한 달치 12건이고, 여섯 경로 모두 3회 중앙값으로 재면서 결과가 서로 일치하는지 검증했다.
조회
| 조회 경로 | 시간 | S3 요청 | 전송 |
|---|---|---|---|
| 키값 이진탐색 (위의 기준선) | 0.84s | 23회 | 70KB |
| B-tree, root부터 하강 (cold) | 0.36s | 16회 | 15KB |
| B-tree, 상위 레벨 메모리 상주 (warm) | 0.20s | 14회 | 7KB |
| LSM, 30런 전부 이진탐색 (컴팩션·블룸 없음) | 1.01s | 342회 | 136KB |
| LSM, 블룸+fence 상주 | 0.17s | 21회 | 40KB |
| LSM, 컴팩션 후 단일 런 + fence | 0.16s | 13회 | 6KB |
숫자가 말하는 것은 두 가지다. 첫째, 트리의 값어치는 왕복 수다. 이진탐색의 순차 23왕복이 B-tree에서는 깊이만큼인 3왕복으로 줄어 시간이 절반 아래가 된다. 상위 레벨을 메모리에 두면(실제 DB 버퍼풀이 늘 하는 일이다) 리프 한 왕복만 남는다. 둘째, LSM의 읽기 증폭은 요청 수로 과금된다. 컴팩션 없이 30런을 다 확인하면 342요청인데, S3는 크기가 아니라 횟수로 과금하므로 같은 답에 15배를 내는 셈이다. 블룸 필터가 유저 없는 21일을 요청 없이 걸러냈고, 컴팩션까지 끝나면 13요청이 된다.
id 키로도 두 트리를 따로 지어 단건 조회를 쟀다. B-tree는 상위 상주 시 0.12초 2요청, LSM은 0.13초 2요청이다. 특히 LSM은 id가 시간에 따라 단조 증가하는 키라 런별 min/max만으로 SSTable 하나가 바로 골라져 블룸 필터조차 필요 없었다. 시계열 키가 LSM과 잘 맞는 이유가 실측에 그대로 드러난다.
쓰기
하루치 83,333건을 반영하는 비용이다. S3 오브젝트는 통째로만 다시 쓸 수 있으므로 B-tree는 페이지를 오브젝트 하나씩으로 두고 페이지마다 읽고 다시 쓰는 read-modify-write를 했다.
| 하루치 반영 | 시간 | S3 요청 |
|---|---|---|
| LSM flush, SSTable 1개 순차 쓰기 (1.3MB) | 0.4s | 1회 |
| LSM 컴팩션, 30런→단일 런 (월 1회, 40MB 재작성) | 병합 1.3s + 업로드 5.6s | 1회 |
| B-tree 제자리 갱신, dirty 페이지 RMW | 12.1s | 1,578회 |
하루에 활동한 유저가 68,145명이었고, 이들이 leaf 785페이지 전부를 건드렸다. 키가 전 범위에 흩어지면 “제자리 수정”은 이름만 남는다. 사실상 전체 재작성에 랜덤 왕복 비용까지 얹는 것이다. 반면 LSM의 하루치는 순차 쓰기 한 번, PUT 1회다. 오브젝트 스토리지 위에서는 구조적으로 LSM이 이긴다.
일자별로 append 하고, 마감 때 정렬하고, 필요하면 월 단위로 병합하는 설계는 주기가 하루인 LSM 그 자체다. flush가 일 마감이고 compaction이 월 병합이고 manifest가 블룸과 fence다. 로그 아카이브 설계자들이 LSM을 몰라도 LSM에 도달하는 이유가 있다.
Parquet와 비교
같은 유저 조회를 Parquet와 DuckDB로 하면 0.89초다. 키값 이진탐색보다 약간 느리고 트리보다 느리다. 그런데 왕복 구조가 근본적으로 다르다. 우리가 지은 인덱스들은 전부 “어디를 읽을지”를 찾는 검색 왕복이 필요했다. Parquet는 메타데이터 footer의 위치가 파일 끝으로 약속돼 있어 검색이 없다. 30개 파일의 footer를 병렬 한 왕복에 읽고, footer의 min/max 통계로 필요한 row group만 고르고, 그 청크만 병렬 두 왕복째에 읽으면 끝이다.
footer가 파일 끝이라는 약속 하나로 검색 없이 병렬 2왕복에 끝나는 흐름이다.
같은 조회를 네 경로로 나란히 놓으면 순차 깊이가 3, 13, 5, 2로 갈린다.
시간이 비슷해도 왕복 깊이는 구조가 결정한다. 집계로 가면 차이가 더 벌어진다. 하루치 status 집계는 Parquet 0.54초 대 JSONL 4.0초이고, 한 달치 집계는 6.6초 대 115초다. Range 이진탐색도 키값 이진탐색도 B-tree도 LSM도 여기서는 아무것도 못 한다. 전부 등가 조회용 구조라 “매칭 건의 위치”만 알 뿐, 수백만 건을 요약하는 일은 결과 건수만큼 왕복하다 무너진다. Parquet는 열 지향이라 필요한 컬럼 청크만 받는다.
정리
들어가며의 질문으로 돌아가자. JSONL을 그대로 두고도 조회는 된다. 정렬된 파일은 Range 이진탐색으로 찾고, 비정렬 키는 정렬된 사이드카 인덱스로 찾고, 갱신은 B-tree나 LSM으로 받아낸다. 속도도 밀리지 않는다. 단건 0.12초는 Parquet의 0.3~1.7초보다 빠르다.
그런데도 결론은 Parquet다. 차이는 속도가 아니라 인덱스를 앱이 이해하느냐, 엔진이 이해하느냐에 있다. 직접 지은 인덱스는 전부 앱 소유물이다. 조회 축이 하나 늘면 인덱스와 백필과 정합성 검증과 조회 코드가 같이 는다. join이 필요해지면 인덱스로는 부족하고 실행기까지 앱이 짜야 한다. 소수 건을 이어 붙이는 index nested loop까지는 버티지만, 대규모 join은 행 수만큼 왕복하거나 hash join을 재발명하는 길뿐이다. Parquet는 이 전부가 마감 배치의 COPY ... ORDER BY user_id ... PARTITION_BY dt 한 줄 뒤 엔진 몫이다. 날짜 파티션이 1차 인덱스, 파티션 내부 정렬이 2차 인덱스, row group 크기가 인덱스 granularity 역할을 하므로 별도 인덱스 파일 없이 포맷 안에 구워진다.
그래서 S3에서 조회할 거면 Parquet를 쓰면 된다. ingest와 원본 보관은 append가 단순하고 사람이 읽을 수 있는 JSONL로 하고, 조회 사본은 일자별 Parquet로 굽는다. 직접 지은 인덱스와 트리는 특정 경로에 엄격한 지연 SLA가 생겼을 때 꺼내는 예비 카드로 남긴다. 정렬이 인덱스라는 프리미티브는 S3 위에서도 성립하지만, 그것을 앱이 떠안을 이유는 없다.


