LabHub

블로그

데이터베이스 내부 구조 완전 해부 — B-Tree, LSM, WAL, MVCC, Vacuum, Index, Query Planner, Replication 심층 가이드 (2025)

왜 DB 내부를 알아야 하는가 — 2025년의 관점

"나는 SELECT * FROM users WHERE email = ?만 쓰면 되는데 내부까지 알아야 해?"

예, 알아야 합니다. 이유:

  1. AI 기반 앱 폭증 — pgvector, Qdrant, LanceDB 같은 벡터 DB는 HNSW 같은 인덱스 구조의 특성이 직접 비용과 품질에 드러난다.
  2. 클라우드 비용 압박 — 쿼리 플랜 하나 잘못되면 RDS 인스턴스를 2배로 키워야 한다. 이해 없이는 튜닝이 불가능하다.
  3. DB가 '초당 수백만' 처리 가능한 시대 — PostgreSQL 17, MySQL 8.4, DuckDB 1.x가 내부 구조를 끊임없이 개선하는 이유를 알아야 올바르게 쓴다.
  4. LLM이 SQL은 생성하지만, 플랜은 못 만든다 — 사람의 영역이 내부 이해로 옮겨왔다.
  5. 분산 DB가 보편화 — 직전 글에서 본 CockroachDB·TiDB·YugabyteDB의 성능 특성은 하부 엔진의 선택(RocksDB vs Pebble)에서 결정된다.

Part 1 — 모든 DB가 공유하는 한 가지: WAL

Write-Ahead Log의 본질

"데이터 파일을 변경하기 전에, 변경 내역을 먼저 로그에 기록한다."

왜? 크래시 리커버리 때문이다. 데이터 페이지 변경 도중 전원이 나가면, 파일은 반쯤 쓰인 상태가 된다. WAL이 있으면 "어디까지 했고, 뭐가 남았는지" 재구성할 수 있다.

1. 트랜잭션 시작
2. BEGINWAL에 기록
3. UPDATE users SET name='A' WHERE id=1
WAL"변경 전/후 값" 기록
   → 데이터 페이지는 아직 디스크에 안  (메모리만)
4. COMMIT
WALCOMMIT 기록
   → fsync로 디스크에 강제 쓰기
5. (나중에) 데이터 페이지 플러시 (체크포인트)

ARIES 알고리즘 (1992, IBM)

현대 대부분의 DBMS가 ARIES(Algorithms for Recovery and Isolation Exploiting Semantics)를 따른다.

3단계 복구:

  1. Analysis — WAL을 읽어 "크래시 시점에 살아있던 트랜잭션" 식별.
  2. Redo — WAL의 모든 변경을 재적용 (커밋 안 된 것 포함).
  3. Undo — 커밋되지 않은 트랜잭션을 되돌림.

PostgreSQL, Oracle, SQL Server, MySQL InnoDB 모두 ARIES 계열. 세부 구현은 다르지만 본질은 같다.

WAL의 성능 이슈

Part 2 — 저장 엔진의 두 거대 계파: B-Tree vs LSM-Tree

B-Tree (Bayer & McCreight, 1971)

대부분의 전통 RDBMS(PostgreSQL, MySQL InnoDB, Oracle)의 기본. 실제로는 B+Tree — 리프 노드끼리 연결리스트로 이어져 순차 스캔 가능.

        [20 | 50 | 80]        (내부 노드)
       /    |    |    \
  [1,10][25,40][55,70][85,95]  (리프, 실제 데이터 or 포인터)

특성:

단점: 쓰기 증폭(Write Amplification) — 한 번의 논리 쓰기가 여러 페이지 디스크 쓰기를 유발.

LSM-Tree (O'Neil et al., 1996)

Log-Structured Merge-Tree. Google Bigtable(2006)이 대중화. RocksDB, Cassandra, HBase, LevelDB, ScyllaDB, InfluxDB가 모두 LSM.

구조:

MemTable (메모리, 정렬된 구조: skip list or red-black tree)
    (꽉 차면)
L0 파일 (SSTable, 정렬된 불변 파일)
    (컴팩션)
L1 파일들
L2 파일들
...

쓰기 경로:

  1. WAL에 기록
  2. MemTable에 삽입 (메모리)
  3. MemTable 가득 → L0로 플러시
  4. 백그라운드에서 컴팩션(여러 L0 → L1 통합, 중복 제거)

읽기 경로:

  1. MemTable 확인
  2. L0 파일들 확인 (최신 순)
  3. L1, L2, ... 확인
  4. Bloom Filter로 "이 SSTable에 해당 키 없음"을 빠르게 판정.

장점:

단점:

비교 — 언제 B-Tree, 언제 LSM?

상황추천
OLTP (읽기 중심, 범위 쿼리)B-Tree
쓰기 집중 (로그, IoT, 시계열)LSM
SSD 수명 고려LSM (쓰기 증폭 낮음)
복잡한 트랜잭션B-Tree (MVCC 구현 쉬움)
키-밸류만LSM (RocksDB)

흥미로운 2020년대 트렌드: 두 계파의 융합.

Part 3 — MVCC — 읽는 사람이 쓰는 사람을 막지 않는 마법

MVCC의 핵심 아이디어

"하나의 행에 여러 버전을 유지해, 각 트랜잭션이 자기 시점의 스냅샷을 본다."

전통적 잠금 기반 DB는 읽기가 쓰기를 블로킹한다. MVCC는 그걸 없앤다.

PostgreSQL의 MVCC 구현

모든 행에 숨겨진 컬럼 4개가 있다:

-- 내부적으로 모든 행은:
SELECT xmin, xmax, cmin, cmax, ctid, *
FROM users;

가시성 규칙:

행이 보이려면:
  xmin이 커밋된 트랜잭션이고,
  xmin <= 현재_트랜잭션_스냅샷,
  그리고 (xmax == 0 or xmax가 롤백됨 or xmax > 현재_스냅샷)

UPDATE는 실제로는 DELETE + INSERT. 기존 행의 xmax를 설정하고, 새 행을 삽입한다. 그래서 PostgreSQL의 UPDATE는 느리다고 알려져 있다.

Oracle/MySQL InnoDB 방식 — Undo Log

PostgreSQL처럼 행 안에 버전을 두는 대신, 별도의 Undo 영역에 과거 버전을 기록.

데이터 페이지: 최신 값만
Undo Log: (과거 값 체인)

장점: Vacuum이 필요 없다 (Undo는 자동 정리). 단점: Undo 체인이 길어지면 읽기 비용 증가. "ORA-01555 snapshot too old" 유명 에러.

Vacuum의 진실 — "느려지는 이유"

PostgreSQL MVCC의 대가는 Dead Tuple이다. 업데이트/삭제된 행이 디스크에 남는다. VACUUM이 이를 정리한다.

Autovacuum이 못 따라가면:

해결책:

Serializable Snapshot Isolation (SSI)

PostgreSQL 9.1+. 진정한 serializable을 MVCC 기반으로 구현. 전통적 S2PL보다 동시성 훨씬 높음. CockroachDB도 유사 알고리즘.

Part 4 — 인덱스의 동물원

B-Tree Index

PostgreSQL 기본. 대부분의 경우 최적.

CREATE INDEX idx_users_email ON users(email);

유용한 경우: 동등(=), 범위(<, >, BETWEEN), ORDER BY, LIKE 'prefix%'.

Hash Index

동등 비교만. 2020년대엔 B-Tree가 거의 모든 경우 더 좋아 잘 안 쓴다.

BRIN (Block Range INdex)

큰 테이블의 "거의 정렬된" 컬럼에 이상적. 테이블 전체를 블록 단위로 요약만 한다.

CREATE INDEX idx_logs_timestamp ON logs USING BRIN(timestamp);

GIN (Generalized Inverted iNdex)

배열, JSONB, 전문 검색에 쓰는 역색인.

CREATE INDEX idx_users_tags ON users USING GIN(tags);
CREATE INDEX idx_events_data ON events USING GIN(data jsonb_path_ops);
CREATE INDEX idx_docs_text ON docs USING GIN(to_tsvector('english', body));

GiST (Generalized Search Tree)

기하학, 범위 타입, 전문 검색에 쓰는 확장 가능 트리.

CREATE INDEX idx_locations ON stores USING GIST(location);  -- PostGIS
CREATE INDEX idx_booking ON bookings USING GIST(duration);  -- tsrange

SP-GiST

공간 분할(Space-Partitioned) GiST. IP 주소, 전화번호처럼 불균형 데이터에 강함.

HNSW — 벡터 검색의 지배자

Hierarchical Navigable Small World. pgvector 0.5+에서 도입, 2024년 벡터 검색의 사실상 표준.

CREATE EXTENSION vector;
CREATE TABLE documents(id BIGSERIAL, embedding vector(1536));

CREATE INDEX ON documents USING hnsw (embedding vector_cosine_ops)
WITH (m = 16, ef_construction = 64);

원리:

경쟁자: IVFFlat — 더 간단하지만 HNSW가 정밀도·속도 모두 앞선다.

2024 벤치마크: pgvector 0.7 + HNSW는 Pinecone과 근접 성능, 비용은 1/10.

DiskANN

Microsoft Research의 SSD 친화 그래프 인덱스. 수십억 벡터를 SSD에서 검색 가능. pgvector 0.8+에서 실험적 지원.

Part 5 — Query Planner가 하는 일

4단계

  1. Parsing — SQL을 파싱 트리로.
  2. Rewriting — 뷰 확장, 룰 적용.
  3. Planning — 여러 실행 계획을 생성·비교.
  4. Execution — 선택된 계획 실행.

Cost-Based Optimization (CBO)

"몇 가지 계획을 만들어 각 비용을 추정, 최저 비용 선택."

비용 = seq_page_cost * 페이지_수 + random_page_cost * 랜덤_페이지_수 + cpu_tuple_cost * 행수 + ...

기본값:

통계 (pg_statistic)

플래너는 테이블 통계 없이는 무력하다. ANALYZE가 수집하는 정보:

통계가 오래되면 플래너가 바보가 된다. autovacuum이 ANALYZE도 하므로, autovacuum 죽이면 안 된다.

Join 전략

  1. Nested Loop — 작은 테이블 + 인덱스 있는 큰 테이블.
  2. Hash Join — 한쪽이 메모리에 들어올 때.
  3. Merge Join — 양쪽이 정렬돼 있을 때.
EXPLAIN ANALYZE SELECT * FROM orders o
JOIN users u ON o.user_id = u.id
WHERE o.created_at > '2025-01-01';

이 결과를 읽을 줄 아는 것이 DB 엔지니어의 기본.

Parameter Sniffing / Plan Cache 문제

준비된 문장(prepared statement)은 첫 실행 시 플랜을 캐시한다. 이후 다른 파라미터로 실행하면 부적절한 플랜이 재사용되어 느려질 수 있다. PostgreSQL은 plan_cache_mode 설정으로 제어.

PostgreSQL JIT

PG 11+. 복잡한 WHERE 절·집계를 LLVM으로 JIT 컴파일. OLAP 쿼리에 2-10배 속도. 단, 짧은 OLTP 쿼리엔 오히려 오버헤드 — jit_above_cost 임계 설정이 중요.

Part 6 — Replication의 구조

1. Streaming Replication (물리 복제)

PostgreSQL의 기본. WAL을 네트워크로 그대로 전송, 복제본이 재생.

PrimaryWAL StreamReplica

2. Logical Replication (논리 복제)

WAL을 "논리적 변경(INSERT user 5, UPDATE row ...)"으로 해석해 전송.

CREATE PUBLICATION my_pub FOR ALL TABLES;
CREATE SUBSCRIPTION my_sub CONNECTION '...' PUBLICATION my_pub;

3. Trigger-Based Replication

레거시. pg_logical 이전 시대의 Slony 등. 지금은 거의 안 씀.

Async vs Sync 트레이드오프

복제 지연 모니터링

SELECT
  application_name,
  pg_wal_lsn_diff(pg_current_wal_lsn(), sent_lsn) AS sent_lag,
  pg_wal_lsn_diff(sent_lsn, flush_lsn) AS flush_lag,
  pg_wal_lsn_diff(flush_lsn, replay_lsn) AS replay_lag
FROM pg_stat_replication;

Part 7 — 파티셔닝과 샤딩

파티셔닝 (단일 DB 내)

PostgreSQL 10+의 네이티브 파티셔닝:

CREATE TABLE events (
  id BIGSERIAL,
  created_at TIMESTAMPTZ NOT NULL,
  data JSONB
) PARTITION BY RANGE (created_at);

CREATE TABLE events_2026_04 PARTITION OF events
  FOR VALUES FROM ('2026-04-01') TO ('2026-05-01');

이점: 파티션 프루닝(필요 없는 파티션 스킵), 부분 인덱스, 오래된 파티션 빠른 DROP.

샤딩 (여러 DB로)

Citus(PostgreSQL 확장, 이제 Microsoft 소유)가 대표. 샤드 키 기반으로 여러 워커 노드에 분산.

수동 샤딩의 함정:

Part 8 — 실무에서 DB가 느려지는 7가지 이유

  1. Dead Tuple 축적SELECT relname, n_dead_tup FROM pg_stat_user_tables ORDER BY n_dead_tup DESC;
  2. 인덱스 블로트pg_stat_user_indexes.idx_blks_hit이 낮으면 의심.
  3. 통계 오래됨 — ANALYZE 실행, EXPLAIN의 행수 예측 비교.
  4. 플랜 캐시 오염 — 같은 SQL이 파라미터에 따라 다른 플랜이 필요한데 캐시된 플랜 사용.
  5. 연결 폭주 — PostgreSQL은 프로세스 기반, 연결당 메모리 큼. PgBouncer 필수.
  6. 체크포인트 I/O 폭주checkpoint_timeout, max_wal_size 튜닝.
  7. 긴 트랜잭션이 Vacuum을 막음SELECT * FROM pg_stat_activity WHERE state = 'idle in transaction';

Part 9 — 2025년 DB 생태계 지형

관계형 DBMS

분석 DB

분산 SQL

벡터 DB

임베디드

Part 10 — 실무 체크리스트 (12항목)

  1. EXPLAIN (ANALYZE, BUFFERS)를 읽을 줄 알아야 한다 — 쿼리 튜닝의 시작.
  2. 연결 풀(PgBouncer, RDS Proxy)을 반드시 도입 — PG는 연결당 비용이 크다.
  3. autovacuum을 끄지 마라 — 대신 파라미터 튜닝.
  4. 인덱스는 '꼭 필요할 때만' — 쓰기 비용이 선형 증가.
  5. UPDATE가 많은 테이블은 HOT 업데이트를 활용 — 인덱스 컬럼 외 변경.
  6. random_page_cost를 SSD에 맞게 낮춰라 — 기본값 4.0은 HDD 기준.
  7. 긴 트랜잭션을 피하라 — Vacuum을 방해한다.
  8. 복제 지연을 SLI로 추적하라 — 읽기 복제본 쓰면 필수.
  9. 큰 삭제는 배치로 — 한 번에 수백만 행 DELETE = WAL 폭발.
  10. 백업 복구를 정기 훈련 — 존재만 아니라 작동 확인.
  11. **스키마 변경은 pg_repack/pt-online-schema-change**로 무중단.
  12. 벡터는 HNSW + 적절한 m/ef_construction — 차원과 데이터 크기에 맞게.

Part 11 — 10대 안티패턴

  1. SELECT * — 불필요한 I/O, 뷰 망침.
  2. OFFSET 100000 — 깊은 페이지네이션은 Cursor 기반으로.
  3. N+1 쿼리 — ORM의 고전적 함정.
  4. LIKE '%pattern%' — 인덱스 못 쓴다. pg_trgm GIN 인덱스를 쓰라.
  5. UUID v4 기본키 — B-Tree 페이지 분할 폭주. v7(2024)은 시간 정렬.
  6. 모든 컬럼에 인덱스 — 쓰기 비용 폭증.
  7. 트랜잭션 안에 외부 API 호출 — 커넥션 점유, 데드락 유발.
  8. autovacuum 비활성화 — 대부분 증상을 더 악화시킨다.
  9. 잦은 소규모 UPDATE로 테이블 블로트 — 배치화/partitioning/append-only 고려.
  10. EXPLAIN 없이 "인덱스 탔을 거야"라 믿기 — 진짜 원인은 통계일 때가 많다.

마치며 — DB는 '시스템 엔지니어링의 결정체'

데이터베이스만큼 컴퓨터 과학의 모든 기초가 응집된 소프트웨어는 드물다. 자료구조(B-Tree/LSM), 시스템(파일 시스템/메모리), 분산(앞 글 전체), 최적화(CBO), 동시성(MVCC), 언어(SQL)…

한 명의 엔지니어가 DB를 이해하면, 사실상 백엔드의 모든 영역을 이해할 기반을 갖춘다. 그래서 시니어 엔지니어가 DB에 시간을 쓰는 것이다. 쿼리 하나를 튜닝하는 일이 아키텍처 전체를 재평가하는 기회가 된다.

2025년, 당신이 쓰는 앱 뒤엔 반드시 DB가 있다. 그 DB의 내부를 이해하면, 당신은 "DB 오너"가 된다. 이해 못 하면, 당신은 "DB 희생자"가 된다.

다음 글 예고 — "메시지 큐와 이벤트 스트리밍의 모든 것" — Kafka, RabbitMQ, NATS, Pulsar, SQS, Redis Streams 완전 비교

DB는 '상태'를 다루고, 메시지 큐/스트림은 '흐름'을 다룬다. 현대 아키텍처의 절반은 이 '흐름'에 있다.

"어떻게 대규모 이벤트를 안정적으로 흘려보낼 것인가?"의 교과서. — 다음 글에서.

댓글

아직 댓글이 없습니다.

로그인하면 댓글을 쓸 수 있습니다