DB 내부 ⑫ (회고): 나는 왜 DB를 밑바닥부터 만들었나, 만들어서 증명한 것들
목차
0. 30초 요약: 이 프로젝트가 증명한 숫자들
긴 글이니 결론부터. db-hobby는 C로 밑바닥부터 만든 미니 관계형 DB(GitHub)이고, 아래 표의 모든 수치는 제가 이 프로젝트에서 직접 측정한 것입니다(12코어 macOS, -O2, 측정 코드도 repo에 있음).
| 주장 | 직접 측정한 근거 |
|---|---|
| 인덱스는 O(log n)이다 | 풀 스캔 대비 1천 행 11배 → 10만 행 416배 (격차가 N과 함께 벌어짐 = 로그 곡선과 일치) |
| 내구성(fsync)은 비싸다 | 같은 5천 행 적재: 행당 커밋 3,372 rows/s vs 50행 묶음 79,039 rows/s, 23배 |
| 클러스터드는 지역성이 무기다 | InnoDB식이 PK 범위 스캔 3.8배 빠름, 대신 보조 인덱스 점 조회 2배 느림(이중 조회) |
| 병렬화의 천장은 CPU가 아니다 | 12코어 병렬 집계 최고 2.16배(선형 아님). 천장의 정체는 버퍼 풀 latch. 고치자 콜드 스캔 4워커 1.08배 → 2.39배(8워커 0.62배 → 1.36배) |
| 전부 검증됐다 | 테스트 694개 / 42스위트, ThreadSanitizer·ASan/UBSan 클린, 크래시 주입, 결정적 시뮬레이션 |
| 진짜 프로토콜이다 | 실제 psql 14.19가 접속해 MVCC(“reader가 writer를 안 막는다”)를 두 터미널로 시연 |
이 글은 그 여정의 회고입니다. 왜 만들었고, 어떻게 검증했고, 왜 그 선택들을 했는지. 기능 나열은 안 합니다(그건 시리즈 1~11편이 이미 합니다).
1. 왜 만들었나: “설명할 수 없으면 모르는 것이다”
출발점은 지식의 불편함이었습니다. 저는 이런 문장들을 말할 수 있었습니다:
- “인덱스는 B+Tree라 빨라요.”
- “커밋하면 fsync돼서 안전해요.”
- “MVCC라 읽기가 안 막혀요.”
그런데 꼬리 질문 하나면 무너졌습니다. 왜 이진 트리가 아니라 B+Tree인가? fsync가 정확히 무엇을 보장하는가, 데이터 파일인가 로그인가? 읽기가 안 막히면 dirty read는 무엇이 막는가? 답을 못 하는 지식은 외운 것이지 이해한 게 아닙니다. 그리고 외운 지식은 실무의 변형된 상황(EXPLAIN이 인덱스를 안 탈 때, VACUUM을 해도 디스크가 안 줄 때)에서 힘을 못 씁니다.
동시에 OS를 공부하면서 다른 자각이 왔습니다. 페이지, 캐시, 동시성, fsync… DB라는 게 결국 OS가 주는 원시 재료 위에 데이터 계층을 다시 쌓은 종합 시스템이었습니다. 그렇다면 한 겹씩 직접 쌓아 보면, 그 문장들이 전부 “외운 것”에서 “유도할 수 있는 것”으로 바뀌겠다고 봤습니다. 그게 이 프로젝트의 문제 정의였습니다.
사실 이 프로젝트의 첫 삽은 Rust였습니다. 포크 가능한 임베디드 KV 저장소(영속 불변 트리로 데이터셋을 O(1)에 분기하는 실험)를 며칠 만지다, “이걸 제대로 하려면 DB 내부부터 알아야겠다”는 생각에 C 미니 RDBMS로 방향을 틀었습니다. 그 전향이 곧 이 시리즈의 출발점입니다.
왜 C였나. 목표가 “진짜 구조의 재현”인데, DB 내부는 메모리·포인터·바이트 레이아웃이 곧 학습 내용입니다. 언어가 그걸 추상화로 가리면 정작 배우려는 게 안 보입니다(중간에 Java 재작성도 검토했지만, 버퍼 풀을 GC 위에 올리는 순간 “버퍼 풀을 왜 만드나”라는 질문 자체가 증발해서 접었습니다). 왜 발명이 아니라 재현이었나. 새 걸 만드는 게 아니라 PostgreSQL·InnoDB가 왜 그렇게 생겼는지를 이해하는 게 목적이라, 매 계층마다 “실제 DB는 여기서 어떻게 하나”를 대조하는 걸 규칙으로 삼았습니다.
2. 어떻게 일했나: 주장은 테스트로, 검증은 분리해서
이 프로젝트에서 가장 의식적으로 지킨 건 기능이 아니라 검증 문화였습니다. 네 겹입니다.
① 모든 계층은 테스트가 주장한다. “동작한다”가 아니라 테스트 694개가 말하게 했습니다. 특히 복구 계층은 크래시를 실제로 주입했습니다. 커밋 마커 fsync 직후에 죽이면 redo로 살아나는지(내구성), 마커 전에 죽이면 깨끗이 버려지는지(원자성). “전원이 꺼져도 안 깨진다”는 크래시를 일으켜 보기 전엔 주장일 뿐이기 때문입니다.
② 동시성은 계측한다. 동시성 코드에서 “테스트 통과”는 약한 증거입니다. 레이스는 대부분의 실행에서 안 터지기 때문입니다. 그래서 버퍼 풀 축출 폭풍, latch crabbing, 병렬 스캔 전부를 ThreadSanitizer로 계측해 “관측된 실행에 data race가 없었다”를 얻었습니다. 분산(Raft)은 한 발 더 나아갑니다. 합의 버그는 특정 메시지 순서에서만 터지는데 진짜 소켓 위에선 그 스케줄을 재현할 수 없어서, 논리 시계·메시지 큐·분단 행렬을 테스트가 소유하는 결정적 시뮬레이션 네트워크를 만들어 적대적 시나리오를 똑같이 반복 재생했습니다.
③ 구현과 검증을 분리한다. 사람이든 AI든, 구현한 쪽은 자기 코드에 관대합니다. 그래서 합의·복제처럼 “그럴듯하게 맞는” 상태가 제일 위험한 영역엔 독립적인 적대적 리뷰를 붙였습니다. 구현을 모르는 검증자가 “이 주장들을 깨 보라”는 임무만 받고 공격하는 방식입니다(AI 에이전트를 쓸 때도 리뷰어는 코드를 고치지 못하게 역할을 갈랐습니다. 고치는 순간 또 하나의 구현자가 되고 검증자가 사라지기 때문입니다). 이 리뷰가 잡은 게 전부 “테스트는 초록인데 원리상 틀린” 종류였습니다:
| 적대적 리뷰가 잡은 실버그 | 왜 테스트가 못 잡았나 |
|---|---|
| 복구가 스냅샷을 로드만 하고 상태기계에 설치 안 함 → 재시작 노드가 조용히 발산 | 스냅샷 이후를 검증하는 시나리오가 없었음 |
| 멤버십 변경 4건 (truncate가 구성을 안 되돌림 등) | 특정 분단·타이밍에서만 발현 |
| SMR apply가 엔진 실패를 무시 → 한 노드만 발산 | 실패 주입 없이는 관찰 불가 |
| 읽기 배리어 ack에 epoch 부재 → 재정렬 시 오확인 가능 | 하버스의 스케줄 습관이 우연히 가려줌 |
마지막 줄이 특히 아픈 교훈이었습니다. 안전이 코드가 아니라 테스트 하버스의 우연에 걸려 있던 상태를, 독립 검증자가 원리로 짚어냈습니다.
④ 만들었으면 잰다. 성능 주장엔 반드시 측정을 붙였고, 측정 조건(코어 수·워밍/콜드·최소시간)을 명시했습니다. 이 원칙이 다음 절의 서사를 만들었습니다.
3. 딥다이브 ①: 측정이 병목을 찾고, A/B가 인과를 확증하다
이 프로젝트에서 가장 아끼는 서사입니다. 5단계로 그대로 적습니다.
- 정상 상태. MVCC로 논리적 동시성은 얻었지만, 실행은 전역 latch 하나로 직렬화돼 있었습니다. 병렬 스캔·병렬 집계를 구현해 read-only 쿼리를 워커 여럿이 나눠 돌게 만들었습니다(직렬과 결과 바이트 동일 + TSan 클린까지 확인).
- 문제. 자로 재니 이상했습니다. 12코어인데 최고 2.16배. 심지어 콜드 캐시(테이블 > 버퍼 풀)에선 8워커가 0.62배, 직렬보다 느렸습니다.
- 분석. 프로파일이 아니라 구조에서 답을 찾았습니다. 버퍼 풀은 latch 하나가 프레임 테이블을 지키는데, 캐시 miss 시의 디스크 읽기(pager_read)까지 그 latch 안에서 일어나고 있었습니다. 워커 여덟이 latch 앞에 줄 서서 I/O를 한 번에 하나씩입니다. 코어가 12개여도 I/O는 1차선입니다. 사실 이 한계는 코드 주석에 제가 “진짜 DB는 read-in-progress 상태로 I/O를 latch 밖으로 뺀다”라고 적어 둔, 알고도 미뤄 둔 빚이었습니다. 측정이 그 빚의 우선순위를 끌어올린 겁니다.
- 학습·수정. miss 시 프레임을
io_pending+pin으로 예약하고 latch를 놓은 뒤 읽도록 바꿨습니다. 같은 페이지를 동시에 miss한 스레드는 조건변수로 기다렸다 hit로 잡아 중복 로드를 막습니다(PostgreSQL 계열의 read-in-progress 패턴). - 결과, 그리고 인과의 확증. 여기가 한끝입니다. “고쳤더니 빨라졌다”로 끝내지 않고, 스위치 하나로 I/O를 latch 안/밖으로 토글해 같은 프로세스·같은 워밍 상태에서 A/B를 쟀습니다. 변수가 정확히 하나뿐인 실험: latch 안 = 4워커 1.08배·8워커 0.62배, latch 밖 = 4워커 2.39배·8워커 1.36배. I/O 직렬화가 천장이었다는 인과가 확증됐고, 정확성은 콜드 7만 행 동시 로딩을 수학적 정답과 대조 + 축출 폭풍 TSan 클린으로 못박았습니다.
이 사이클(측정 → 구조 분석 → 수정 → 단일 변수 A/B 재측정)이 제가 이 프로젝트에서 배운 성능 작업의 표준형입니다.
4. 딥다이브 ②: 성능 작업이 발굴한 “조용히 틀린 답”
집계를 병렬화하려고 그 경로를 읽다가, 병렬화와 무관한 기존 버그를 발견했습니다. 직렬 집계가 행을 고정 버퍼(4,096행)까지만 모은 뒤 계산하고 있었습니다. 6,000행 테이블에 SELECT COUNT(*)를 하면 에러 없이 4096이 나왔습니다. 가장 무서운 종류의 버그입니다: 틀린 답을 멀쩡한 얼굴로 돌려주는.
두 가지를 배웠습니다. 첫째, 성능 작업은 correctness 감사를 겸한다. “빠르게 만들려고 읽는 코드”가 곧 “믿고 지나치던 코드”이기 때문입니다. 둘째, 검증 기준을 무엇으로 잡느냐가 검증의 전부다. 이 버그를 고친 병렬 집계는 직렬과 비교하면 안 됐습니다(직렬이 틀렸기 때문입니다). 그래서 수학적 정답(oracle) 과 대조했습니다: SUM(1..n) = n(n+1)/2. 기준이 오염됐을 가능성까지 의심하는 것, 적대적 리뷰에서 배운 태도가 여기서도 일했습니다.
비슷한 결의 사건이 하나 더 있습니다. LSM을 PK 인덱스로 배선하려다(CREATE TABLE ... USING lsm) “인덱스는 단순 key→value가 아니다” 라는 벽에 부딪혔습니다. MVCC가 한 키에 여러 행 버전을 매달아서, unique한 LSM으론 담을 수 없었기 때문입니다. 교과서가 미리 알려주지 않는 이런 상호작용(MVCC × 인덱스 구조)은 만들어 봐야만 만납니다. 해법은 dedup 단위를 (key, val) 짝으로 바꾼 멀티값 모드였고, 이건 RocksDB 계열이 비유니크 인덱스를 담는 방식과 같은 계열이었습니다. 부딪히고 나서 찾아보니 실제 DB가 이미 그 길에 있었다는 것 자체가, 이 프로젝트가 의도한 학습 방식이었습니다.
5. 선택의 기록: “왜”에 답할 수 없는 결정은 하지 않는다
기술 선택마다 “왜 이것이고, 왜 대안이 아니었나”를 남기는 걸 규칙으로 했습니다. 대표 다섯 개:
| 선택 | 왜 | 왜 대안이 아니었나 |
|---|---|---|
| 저장은 힙(PG식) | INSERT 단순, 인덱스를 “키→RID”로 깔끔히 분리해 학습 | 클러스터드(InnoDB식)는 나중에 벤치 모듈로 대조(3.8배/2배 실측). 엔진 배선은 실행기 재작성 대비 학습 효익이 낮아 안 함 |
| 복구는 no-steal부터 | redo-only의 단순함으로 원리를 먼저 | 그 대가(트랜잭션 크기가 풀에 못 박힘)에 실제로 부딪힌 뒤 steal+undo로. “정책이 복구를 결정한다”를 몸으로 배우는 순서 |
| pageLSN·CLR 생략 | 페이지 전체 물리 로깅이라 redo가 멱등 → 불필요함을 논증하고, 크래시 주입으로 반례 없음을 확인한 뒤 닫음 | 화물숭배 금지. 기법은 필요해지는 문제가 나타날 때 들여온다 |
| 선형화 읽기는 ReadIndex | 과반 확인만으로 성립, 결정적 시뮬레이션과 궁합 | leader lease는 시계 가정이 필요. 테스트 인프라와 안 맞음 (etcd 기본값도 ReadIndex) |
| B+Tree 삭제는 lazy | PostgreSQL nbtree도 재분배를 안 한다. 교과서(병합·재분배) 이식 전에 실제 DB를 먼저 확인 | ”교과서적으로 완전한” 것보다 “실제 시스템이 왜 그 절충을 했는지”가 이 프로젝트의 질문 |
6. 무엇을 안 했나: 정직한 경계
한 것만큼 안 한 것도 적어야 이 회고가 정직해집니다. 의도적으로 남긴 경계들입니다:
- inter-query 병렬 없음: 병렬화는 한 쿼리 안(intra-query)까지. 서로 다른 트랜잭션의 동시 실행은 여전히 엔진 latch가 직렬화합니다. 카탈로그·테이블 WAL·트랜잭션 상태 전부의 스레드 안전화가 필요한, 이 프로젝트 최대의 남은 산.
- 행 단위 락 없음: 쓰기 충돌은 테이블 granularity의 first-updater-wins. 원리는 PostgreSQL(REPEATABLE READ 기준으로, first-updater-wins abort는 PG의 RR 동작이고, READ COMMITTED는 abort 대신 최신 버전 재평가로 진행합니다)과 같고 입자만 거칩니다.
- physiological 로깅·히스토그램·정렬 병합 조인·extended query protocol 없음: 각각 “어떤 문제가 그걸 필요하게 만드는지”를 해당 편에 적고 닫았습니다.
- 프로덕션이 아님: 이건 학습 프로젝트입니다. 목적은 PostgreSQL을 대체하는 게 아니라 PostgreSQL을 읽을 수 있게 되는 것.
7. 그래서 무엇이 달라졌나
이 프로젝트 전과 후의 차이는, 실무 상황들이 “암기 항목”에서 “유도 가능한 것” 으로 바뀐 것입니다.
- “인덱스를 걸었는데 왜 안 타요?” → 옵티마이저가 통계로 “페치할 행 > 페이지 수”를 계산해 일부러 안 탄 것.
random_page_cost의 직관을 비용 모델로 직접 구현해 봤기 때문입니다. - “VACUUM 했는데 디스크가 안 줄어요” → truncate는 파일 꼬리의 전부-빈 페이지만. 그 코드를 직접 썼기 때문입니다.
- “커밋이 느려요” → fsync 23배를 직접 쟀고, fsync 완화 다이얼(
innodb_flush_log_at_trx_commit·synchronous_commit, 내구성을 걸고 fsync를 분리·지연)이 정확히 무엇을 트레이드하는지 알기 때문입니다. 동시 커밋을 한 번의 fsync로 묶는 group commit(PG의commit_delay)은 내구성을 깎지 않는 별개 메커니즘이라는 것까지 포함해서. - “replica가 낡은 값을 줘요” → 복제 lag의 구조(커밋 후 비동기 전송, 9편의 미니 DB 기준이고, 실제 PostgreSQL은 WAL을 생성 즉시 스트리밍하며 lag은 전송·재생 지연에서 옵니다)와, 그걸 정말 없애려면 합의(ReadIndex)까지 가야 한다는 것을 만들어 봤기 때문입니다.
그리고 부수적인 소득 하나. 이 시리즈 자체가 그 증거물입니다. 처음엔 41편의 시간순 빌드로그로 썼다가, 독자가 주제를 배우러 온다는 걸 인정하고 주제별 완결 11편 + 이 회고로 전면 재편했습니다. 글의 구조도 코드처럼 리팩토링 대상이라는 것, 그것도 이 프로젝트에서 배운 것 중 하나입니다.
8. 시리즈 지도
| 편 | 주제 |
|---|---|
| ① | 저장의 뼈대: 페이지·슬롯·행 포맷·힙·버퍼 풀 |
| ② | B+Tree 인덱스: O(log n), 인덱스는 왜 단순 key→value가 아닌가 |
| ③ | WAL과 크래시 복구: redo-only에서 steal + no-force까지 |
| ④ | 격리: 2PL에서 MVCC 스냅샷까지 |
| ⑤ | 진짜 psql이 붙는 서버: wire protocol 해부 |
| ⑥ | 비용 기반 옵티마이저: 통계·선택도·Selinger DP |
| ⑦ | 저장 엔진의 세 철학: 힙 vs 클러스터드 vs LSM |
| ⑧ | 병렬 실행: latch를 걷어내고, 재고, 병목을 고치기 |
| ⑨ | 복제: 복구의 redo를 스트림으로 |
| ⑩ | Raft: 합의에서 HA DB까지 |
| ⑪ | SQL 실행기: 텍스트에서 행까지 |
코드와 테스트 전부: github.com/dj258255/db-hobby
댓글
댓글 수정/삭제는 GitHub Discussions에서 가능합니다.