[데이터 중심 애플리케이션 설계] 03장 디테일

[DDIA] 3장 디테일. 저장소와 검색 (Storage and Retrieval)
이 글은 3장 요약 글의 확장판(디테일 편) 이다.
요약에서는 큰 흐름(로그 구조 저장, B-Tree vs LSM, OLTP vs OLAP, 컬럼 지향)을 잡았다면, 여기서는 “저장 엔진이 실제로 부딪히는 디테일”을 파고든다.
- “append-only”는 왜 빠르고, 왜 결국 컴팩션이 필수인가?
- 해시 인덱스는 왜 RAM 병목이 되며, 재시작 때 무엇이 문제인가?
- 컴팩션은 어떻게 동작하고, 왜 밀리면 성능이 무너지는가?
- SSTable/LSM은 어떤 장치(Bloom filter, sparse index)로 읽기 비용을 줄이는가?
- B-Tree는 overwrite 기반인데, 왜 WAL 없이는 버틸 수 없는가?
- 증폭(amplification)은 왜 “비용” 그 자체인가?
- 컬럼 지향 저장은 왜 분석(OLAP)에서 강한가?
구현 디테일은 DB마다 다르지만(예: MySQL/InnoDB, PostgreSQL, RocksDB/LevelDB), 여기서는 DDIA가 강조하는 공통 원리를 중심으로 정리한다.
0. 저장 엔진을 이해할 때 “디테일”이 중요한 이유
저장 엔진은 단순히 “어떤 인덱스(B-Tree/LSM)를 쓴다”에서 끝나지 않는다.
같은 인덱스라도 다음 선택이 성능과 운영 난이도를 결정한다.
- I/O 패턴: 순차 쓰기 vs 랜덤 쓰기
- 레코드 포맷: 길이/체크섬/바이너리 인코딩 여부
- 복구 설계: WAL, 체크포인트, fsync 타이밍
- 백그라운드 작업: 컴팩션/머지/GC가 밀릴 때의 폭발 반경
- 증폭: 논리 I/O 1회가 실제 디스크 I/O 몇 회로 “증식”되는가?
요약하면, “인덱스가 무엇인가”보다 “그 인덱스를 어떤 규칙으로 운영하는가”가 더 실전적이다.
1. append-only 로그: 왜 빠른데, 왜 곧 한계가 오는가?
1.1 append-only가 빠른 이유(직관)
- 파일 끝에 계속 추가하면 저장 장치 입장에서 예측 가능한 쓰기 흐름이 된다.
- overwrite(제자리 수정)는 페이지/블록 단위의 랜덤 업데이트가 섞이기 쉬워 비용이 증가한다.
append-only는 “쓰기 비용을 지금이 아니라 나중에” 내는 전략에 가깝다.
1.2 append-only의 부작용: “읽기 모델”이 달라진다
append-only에서 업데이트는 “수정”이 아니라 “새 레코드 추가”다.
- 같은 key가 여러 번 등장한다(중복 누적).
- 최신 값을 빠르게 찾으려면 결국 “최신 위치를 가리키는 구조(인덱스)”가 필요하다.
- 오래된 중복을 치우지 않으면 파일은 계속 비대해진다 → 컴팩션이 필수.
2. 레코드 포맷 디테일: 로그는 “대충 쓰면” 복구가 지옥이 된다
단순히 key=value\n 같은 텍스트는 구현은 쉽지만, 실제 저장 엔진에서는 위험하다.
- 크래시로 레코드가 반쯤만 기록될 수 있다.
- 파일 일부 손상 시 어디까지 정상인지 판단이 어렵다.
- 파싱 비용이 크고(문자열 처리), 랜덤 접근도 비효율적이다.
그래서 흔히 아래 요소를 포함한 포맷을 쓴다.
- length-prefix: 레코드 길이를 앞에 둬서 다음 레코드로 점프 가능
- checksum: 레코드 단위 손상 감지
- binary encoding: key/value를 바이너리로 직렬화
개념 예시는 아래처럼 생각하면 된다.
| record_length | checksum | key_length | key_bytes | value_length | value_bytes |
이 포맷이면,
- 순차 스캔이 빠르고,
- 손상된 레코드를 감지해 “그 구간만 건너뛰는” 복구 전략도 가능해진다.
3. 해시 인덱스(로그 구조 + key→offset): “RAM이 곧 한계”가 된다
3.1 구조
- 데이터는 append-only 로그에 저장
- 인덱스는 해시맵:
key -> 최신 레코드 오프셋
쓰기:
- 로그 append
- 해시맵에서 key의 오프셋 갱신
읽기:
- 해시맵으로 오프셋 조회
- 해당 위치에서 레코드 읽기
3.2 현실적인 병목 1: 인덱스가 메모리를 먹는다
해시 인덱스의 강점(빠른 조회)은 곧 약점이 된다.
- key 수가 늘수록 해시맵이 RAM을 크게 점유한다.
- 결국 “값은 디스크에 있는데 인덱스가 메모리 병목”이 될 수 있다.
3.3 현실적인 병목 2: 재시작 시 인덱스 복구
인메모리 해시맵은 재시작하면 사라진다. 복구 방식은 보통 두 가지다.
(1) 로그 전체 스캔
- 파일을 앞에서부터 읽으며 각 key의 “마지막 등장 위치”로 해시맵을 재구성
- 데이터가 크면 재시작이 느려진다.
(2) 스냅샷/체크포인트 + 이후 로그 재생
- 주기적으로 해시맵(또는 메타데이터)을 디스크에 저장
- 재시작 시 스냅샷 로드 후, 스냅샷 이후의 로그만 반영
- 복구 시간을 크게 줄일 수 있다.
3.4 근본 한계: 범위 질의(range query)
해시 인덱스는 정렬 정보가 없어서 다음이 약하다.
BETWEEN,ORDER BY, prefix 검색- “키 순서대로 스캔”이 필요한 질의
이 지점에서 B-Tree나 LSM 같은 “정렬 기반” 인덱스 구조가 등장한다.
4. 컴팩션(Compaction): 진짜는 “병합(merge) + 최신값만 남기기”다
컴팩션은 로그 구조 저장에서 GC(가비지 컬렉션)에 해당한다.
- 중복 key(업데이트 누적)
- 삭제 표식(tombstone)
- 오래된 값
이들을 정리해 “저장 크기”와 “읽기 비용”을 통제한다.
4.1 세그먼트 + 머지의 직관적인 예
세그먼트(segment)로 로그를 나눈다고 하자.
시간이 흐르면 동일 key가 여러 번 기록된다.
seg1(오래됨): (a=1) (b=1) (c=1)
seg2 : (a=2) (d=1)
seg3(최신) : (b=2) (c=2)
컴팩션은 여러 세그먼트를 읽어 “최신 값만 남기는” 새 파일을 만든다.
merge 결과: (a=2) (b=2) (c=2) (d=1)
핵심은 이거다.
- 같은 key가 여러 번 나오면 가장 최신 레코드만 남긴다
- 나머지는 버린다 → 디스크 회수 + 읽기 단순화
4.2 컴팩션이 “밀릴 때” 무슨 일이 생기나?
컴팩션이 제때 수행되지 않으면 문제가 연쇄적으로 온다.
- 디스크: 중복/삭제 누적으로 사용량 증가 → 디스크 full 위험
- 읽기: 더 많은 파일/세그먼트를 확인 → 지연 증가(p99 악화)
- 쓰기: 백그라운드 작업이 밀려 쓰기까지 영향(스톨, throttling) 가능
로그 구조 저장에서 컴팩션은 “옵션”이 아니라 “생존 조건”에 가깝다.
5. B-Tree 계열(실전은 B+Tree): 페이지 엔진 + overwrite + WAL
B-Tree는 “전통적이고 범용적인” 인덱스지만, 디테일은 사실상 “페이지 기반 저장 엔진”의 이야기다.
5.1 페이지(page) 단위가 모든 것을 결정한다
- 디스크는 보통 페이지/블록 단위로 I/O가 일어난다.
- B-Tree는 노드를 페이지 크기에 맞춰 구성한다.
- 한 페이지에 많은 키를 넣어 트리 높이를 낮춘다 → 디스크 I/O 횟수 감소
5.2 range query가 강한 이유(리프 연결)
많은 구현이 B+Tree 형태다.
- 내부 노드: 키 범위 안내(포인터)
- 리프 노드: 실제 레코드(또는 레코드 포인터) 저장
- 리프 노드끼리 연결되어 있어 범위 스캔이 자연스럽다
즉,
- 시작 지점만 찾으면
- 그 다음은 리프를 순서대로 쭉 읽는다
그래서 BETWEEN, ORDER BY에 강하다.
5.3 overwrite의 숙명: WAL 없이는 복구가 깨진다
B-Tree는 페이지를 제자리 수정한다.
하지만 크래시가 나면 “페이지가 반만 갱신된 상태”가 남을 수 있다.
그래서 일반적으로:
- 변경 사항을 WAL에 먼저 기록
- 실제 페이지에 반영
- 체크포인트로 WAL 정리(재생 범위 축소)
결론: overwrite 기반 저장은 WAL/체크포인트 설계와 한 몸이다.
5.4 성능 흔들림 포인트: 페이지 split/merge
삽입이 계속되면 어떤 리프 페이지가 꽉 차고 split이 발생한다.
- 새 페이지 할당
- 부모 노드 갱신
- 경우에 따라 연쇄적 구조 변화
이 과정은 지연 스파이크의 원인이 될 수 있다(특히 동시성/락과 만나면 더 민감).
6. LSM-Tree 디테일: Memtable, SSTable, 그리고 “읽기 비용을 줄이는 장치들”
LSM은 철학이 명확하다.
- 쓰기는 append 중심으로 “싸게”
- 대신 읽기/정리를 위해 백그라운드 병합(compaction)을 수행
6.1 기본 흐름(운영 관점에서 가장 중요)
- (쓰기) WAL 기록 → Memtable 반영(정렬 구조)
- (flush) Memtable이 일정 크기면 SSTable로 디스크 flush(정렬된 불변 파일 생성)
- (정리) 여러 SSTable을 compaction으로 병합/정리
6.2 SSTable이 정렬되어서 생기는 혜택
정렬된 불변 파일이면:
- 머지가 싸다: 순차 읽기 + 병합 정렬
- 인덱스를 조밀하게 만들 필요가 없다: sparse index 가능
- 블록 압축이 잘 먹는다: 비슷한 값들이 모이기 쉬움
6.3 읽기 비용 완화 장치 1: Bloom Filter
LSM은 읽을 때 여러 SSTable을 확인할 수 있다.
Bloom filter는 “이 파일에 해당 key가 없음”을 빠르게 걸러준다.
- 없다고 판단되면 → 그 SSTable은 아예 읽지 않는다
- false positive(있다고 잘못 판단)는 가능하지만, false negative(없는데 있다고 판단)는 없도록 설계한다
6.4 읽기 비용 완화 장치 2: Sparse Index + 블록(block)
SSTable 내부는 흔히 “블록(block)” 단위다.
- 블록 단위로 압축/체크섬을 붙일 수 있고
- sparse index로 “대략 어느 블록”인지 찾고
- 해당 블록만 읽어 확인한다
정렬되어 있으니, 블록 안에서는 이진 탐색(혹은 작은 스캔)으로 key를 찾는 형태가 가능하다.
7. 컴팩션 정책: Size-tiered vs Leveled (LSM의 성격을 결정하는 스위치)
LSM에서 성능/운영 난이도를 좌우하는 게 컴팩션 정책이다.
7.1 Size-tiered compaction (STCS)
- 비슷한 크기의 SSTable 여러 개를 한 번에 합쳐 더 큰 파일로 만든다.
- 장점: 쓰기 처리량이 잘 나오기 쉬움(단순하고 강한 순차 I/O)
- 단점: 파일 간 키 범위가 겹치기 쉬워 읽기 시 여러 파일을 보게 됨 → 읽기 증폭↑
7.2 Leveled compaction (LCS)
- 레벨을 나누고, 각 레벨에서 키 범위가 가능하면 겹치지 않게 유지하려고 한다.
- 장점: 읽기 시 확인해야 하는 파일 수가 줄어 지연이 안정적 → 읽기 증폭↓
- 단점: 데이터가 레벨 이동 과정에서 더 자주 재작성될 수 있음 → 쓰기 증폭↑
STCS는 “쓰기 친화”, LCS는 “읽기 안정” 쪽으로 기울기 쉽다.
8. Tombstone(삭제 표식): 삭제는 왜 바로 사라지지 않나?
LSM에서 삭제는 “지우기”가 아니라 “삭제 레코드 추가”다.
- 삭제 요청 →
(key = TOMBSTONE)레코드를 append - 이후 컴팩션 과정에서:
- tombstone이 오래된 값을 “가리며”
- 일정 조건이 충족되면 실제로 제거된다
이 방식은 append-only 철학을 유지하면서도, “삭제 사실”을 시스템 전반(복제/동기화 등)으로 전달하는 데 유리한 측면이 있다.
9. 증폭(Amplification): 결국 비용(돈/지연/수명)이다
9.1 Read amplification
- LSM: 최신 값을 찾기 위해 여러 SSTable/레벨을 확인할 수 있음
→ Bloom filter, leveled compaction 등으로 완화 - B-Tree: 탐색 경로는 명확하지만, 캐시 미스가 나면 랜덤 I/O 비용이 커질 수 있음
9.2 Write amplification
- LSM: 컴팩션 때문에 같은 데이터가 여러 번 “다시 쓰일” 수 있음
- B-Tree: 페이지 split/정리/체크포인트 등으로 논리 쓰기 대비 실제 쓰기량이 늘 수 있음
증폭이 중요한 이유는 명확하다.
- SSD 수명(쓰기량)과 직결
- 디스크 I/O(=클라우드 비용/성능)와 직결
- p99 지연, 장애(디스크 full), 운영 난이도와 직결
10. OLAP/컬럼 지향 저장 디테일: “압축이 잘 되는 이유”를 한 번 더
컬럼 저장은 값이 “비슷한 것끼리” 모이기 쉬워 압축이 잘 된다.
(행 저장은 여러 타입이 섞여 압축 효율이 떨어지기 쉬움)
대표적인 기법들:
- Dictionary encoding: 문자열/범주형을 작은 정수 ID로 치환
- RLE(Run-Length Encoding): 같은 값 반복을 (값, 반복횟수)로 저장
- Bit-packing: 값 범위가 작으면 필요한 비트 수만큼만 저장
OLAP에서 자주 하는 일이 “몇 개 컬럼만 뽑아 대량 스캔/집계”이므로, 컬럼 저장은 I/O 자체를 줄이고(필요 컬럼만 읽기), 압축으로 읽어야 할 바이트 수까지 줄이는 구조다.
11. 실전 체크리스트(운영 관점)
- 내 서비스의 병목은 CPU인가 I/O인가? (대부분은 결국 I/O가 발목을 잡는다)
- point lookup vs range scan 비중은? (해시 vs 정렬 인덱스 선택의 핵심)
- p99 지연이 중요한가, throughput이 중요한가?
- 컴팩션이 밀렸을 때의 “폭발 반경”을 감당할 수 있는가?
- 스토리지 비용(용량/IOPS)과 SSD 쓰기량(증폭)을 모니터링하고 있는가?
- 분석 쿼리가 OLTP에 영향을 주는가? (OLAP 분리/컬럼 포맷 도입 시점)
12. 한 문단 결론
저장 엔진은 “인덱스 구조(B-Tree/LSM)”만 외워서는 이해가 끝나지 않는다.
진짜 성격은 레코드 포맷, WAL/복구, 컴팩션 정책, 증폭 관리, 그리고 I/O 패턴에서 결정된다.
DDIA 3장의 핵심은 결국 “DB는 쿼리를 처리하는 소프트웨어이면서 동시에, 디스크 위에서 살아남는 시스템”이라는 감각을 갖는 것이다.
Leave a comment