테마 선택

📚 지식 노트

AI·하드웨어·주변기기·게임·컴퓨터 시스템·이론 컴퓨터 과학·수학·개발과 운영·보안·응용 분야에 대해 알아 두면 좋은 내용을 정리한 노트다. 위에서 분야를 고르고 아래에서 노트를 고른다.

관계형: PostgreSQL·MySQL·SQLite

가장 널리 쓰는 오픈 소스 관계형 데이터베이스 세 가지가 같은 SQL을 서로 다른 내부 구조로 처리하는 방식이다.

목차 (7)
  1. 같은 SQL, 다른 몸통
  2. PostgreSQL: 힙에 쌓이는 튜플 판과 VACUUM
  3. PostgreSQL: WAL, 스트리밍 복제, 확장
  4. MySQL InnoDB: 테이블이 곧 기본 키 B+트리
  5. MySQL InnoDB: undo·redo·binlog와 복제
  6. SQLite: 파일 하나에 든 데이터베이스
  7. 고르는 법

같은 SQL, 다른 몸통

PostgreSQL, MySQL, SQLite는 모두 표와 SQL, 트랜잭션을 준다. 겉에서 보면 비슷하지만 안쪽은 꽤 다르다. 행을 어디에 어떤 순서로 두는지, 고친 행의 옛 판을 어디에 남기는지, 커밋을 무엇으로 지키는지, 연결 하나를 무엇으로 받는지가 엔진마다 갈린다. 이 노트는 그 차이를 따라간다. B+트리, WAL, MVCC 같은 공통 원리는 저장 엔진 노트와 동시성 노트에 있고, 여기서는 각 엔진이 그 원리를 어떻게 골라 조립했는지에 집중한다.

세 관계형 엔진의 실행 모델: PostgreSQL은 연결마다 백엔드 프로세스를 띄우고 공유 메모리로 버퍼를 나눈다. MySQL은 서버 프로세스 하나 안에서 연결마다 스레드를 쓴다. SQLite는 서버 없이 앱 프로세스 안의 라이브러리가 파일 하나를 직접 읽고 쓴다PostgreSQL연결마다 프로세스MySQL연결마다 스레드SQLite서버 없이 함수 호출연결backend연결backend연결backend공유 메모리shared buffers · WAL 버퍼테이블 파일 + WAL연결스레드연결스레드연결스레드InnoDB 버퍼 풀mysqld 프로세스 1개.ibd 파일 + redo 로그앱 프로세스앱 코드SQLite 라이브러리.db 파일 하나
연결을 받는 방식. PostgreSQL은 연결마다 운영체제 프로세스를, MySQL은 서버 프로세스 하나 안에서 스레드를 쓴다. SQLite는 서버가 없고 앱이 라이브러리 함수를 부른다.

이 차이는 운영에서 바로 드러난다. PostgreSQL은 연결이 들어올 때마다 백엔드 프로세스를 하나 띄우므로 연결 하나가 무겁다. 그래서 수천 개 연결을 직접 받기보다 PgBouncer 같은 연결 풀러를 앞에 두는 것이 보통이다. MySQL은 스레드라 연결이 가볍지만, 모든 연결이 한 주소 공간을 나눠 쓴다. SQLite는 네트워크도 서버도 없이 앱과 같은 프로세스에서 돌아, 연결이라는 말 자체가 "파일을 연다"에 가깝다.

PostgreSQL: 힙에 쌓이는 튜플 판과 VACUUM

PostgreSQL의 테이블은 힙(heap)이다. 8KB 페이지에 행(튜플)을 들어온 순서대로 쌓을 뿐 정렬하지 않는다. 인덱스는 모두 따로 있는 보조 구조이고, 잎에 힙의 위치(ctid, 페이지 번호와 칸 번호)를 적어 둔다.

동시성은 MVCC로 푼다. 독특한 점은 옛 판을 별도 공간이 아니라 힙 안에 그대로 둔다는 것이다. 튜플마다 자신을 만든 트랜잭션 번호(xmin)와 지운 트랜잭션 번호(xmax)가 붙어 있고, UPDATE는 "옛 튜플에 xmax를 적고 새 튜플을 하나 더 넣는" DELETE + INSERT다. 읽는 트랜잭션은 시작할 때 찍은 스냅샷과 xmin·xmax를 비교해 자기에게 보이는 판을 고른다. 그래서 읽기는 쓰기를 기다리지 않고, 쓰기도 읽기를 기다리지 않는다.

PostgreSQL MVCC 단계 그림: 힙 페이지의 튜플이 UPDATE로 두 판이 되고, 트랜잭션마다 스냅샷에 맞는 판을 보며, 죽은 판은 VACUUM이 치운다힙 페이지 0ctidxminxmax데이터(0,1)100kim · 잔액 100—200빈자리 (FSM에 기록)(0,2)200—kim · 잔액 50(0,1)은 이제 누구에게도 안 보이는 죽은 튜플옛 튜플이 새 판을 가리킨다(t_ctid)트랜잭션 150200 커밋 전 스냅샷트랜잭션 210200 커밋 뒤 스냅샷VACUUM죽은 튜플 회수가시성 지도(VM), 페이지 0:0힙을 직접 확인해야 한다1all-visible: 인덱스만 보고 답해도 된다xmin: 이 판을 만든 트랜잭션 · xmax: 이 판을 지운(바꾼) 트랜잭션
1 / 5

힙 페이지에 행이 하나 있다. 트랜잭션 100이 넣었으므로 xmin = 100이고, 아직 아무도 지우지 않아 xmax는 비어 있다.

UPDATE 한 번이 힙에 남기는 것. 행의 옛 판과 새 판이 같은 페이지에 함께 있고, 트랜잭션은 xmin·xmax와 자기 스냅샷을 비교해 볼 판을 고른다.

대가는 쓰레기다. 아무 스냅샷도 볼 수 없게 된 옛 판(죽은 튜플)이 힙과 인덱스에 남아 자리를 차지한다. 이것을 치우는 일이 VACUUM이고, 평소에는 autovacuum 데몬이 바뀐 행이 많은 테이블을 골라 알아서 돌린다. VACUUM이 하는 일은 셋이다.

  • 자리 회수: 죽은 튜플과 그것을 가리키는 인덱스 항목을 지우고, 빈자리를 여유 공간 지도(FSM)에 적어 다음 INSERT가 쓰게 한다. 파일 크기는 보통 줄지 않는다(VACUUM FULL은 테이블을 새로 써서 줄이지만 그동안 테이블을 잠근다).
  • 가시성 지도 갱신: 페이지의 모든 튜플이 모든 트랜잭션에 보이면 가시성 지도(visibility map)에 표시한다. 인덱스 전용 스캔은 이 표시가 있는 페이지의 힙을 건너뛰고, 다음 VACUUM도 그 페이지를 건너뛴다.
  • 얼리기(freeze): 트랜잭션 번호는 32비트라 약 40억에서 한 바퀴 돈다. 충분히 오래된 튜플을 "모두에게 보이는 과거"로 표시해 두지 않으면 번호가 돌아왔을 때 과거 행이 미래 행처럼 보인다. 이 작업을 오래 미루면 결국 데이터베이스가 쓰기를 멈추고 VACUUM을 요구한다.

같은 페이지 안에 새 판을 넣을 자리가 있고 인덱스 걸린 열이 바뀌지 않았다면, 인덱스를 고치지 않고 페이지 안에서 옛 판이 새 판을 가리키게만 하는 HOT 업데이트가 일어난다. UPDATE가 잦은 테이블에 fillfactor로 페이지 여유를 남겨 두는 이유다.

PostgreSQL: WAL, 스트리밍 복제, 확장

커밋의 내구성은 WAL(write-ahead log)이 맡는다. 바뀐 페이지를 디스크에 바로 쓰지 않고, 변경 기록만 순서대로 WAL에 붙여 커밋 때 그 WAL을 fsync한다. 페이지 자체는 shared buffers에 더티 상태로 남았다가 checkpointer가 모아서 쓴다. 꺼졌다 켜지면 마지막 체크포인트부터 WAL을 다시 재생해 커밋된 상태로 돌아온다.

PostgreSQL WAL과 스트리밍 복제: backend가 변경을 WAL 버퍼에 적고 커밋 때 pg_wal 파일을 fsync한다. 바뀐 페이지는 shared buffers에 남았다가 checkpointer가 테이블 파일에 쓴다. 주 서버의 WAL은 walsender가 standby의 walreceiver로 흘려보내고, standby의 startup 프로세스가 재생해 읽기 전용 쿼리에 답한다주 서버(primary)standbybackendWAL 버퍼pg_wal 파일shared bufferscheckpointer테이블 파일walreceiverstandby pg_walstartup: 재생테이블 파일읽기 전용 쿼리커밋 때 fsync나중에WALwalsender데이터 파일이 아니라 WAL(바뀐 내용의 기록)을 보낸다동기 복제면 standby가 WAL을 받았다고 답해야 커밋이 끝난다
WAL이 복제의 재료가 된다. 주 서버가 남긴 WAL을 standby가 받아 그대로 재생하므로, 두 서버는 바이트 단위로 같은 데이터 파일을 갖게 된다.

이 WAL을 네트워크로 흘려보내면 그대로 복제가 된다. 스트리밍 복제는 standby가 주 서버의 WAL을 받아 재생하는 물리 복제이고, standby는 재생하는 동안에도 읽기 전용 쿼리를 받는다(hot standby). 기본은 비동기라 주 서버가 갑자기 죽으면 아직 보내지 못한 마지막 커밋이 사라질 수 있고, synchronous_standby_names를 설정하면 standby가 받았다고 답할 때까지 커밋을 기다린다. 테이블 단위로 골라 다른 버전·다른 스키마로 보내는 논리 복제도 있다. 주 서버가 죽었을 때 standby를 올리는 일은 PostgreSQL 자체가 아니라 Patroni 같은 바깥 도구가 맡는다.

PostgreSQL을 많이 고르는 이유 하나는 확장(extension)이다. 새 자료형, 연산자, 인덱스 방식, 백그라운드 작업까지 C로 짜 넣고 CREATE EXTENSION 한 줄로 켠다. 지리 정보의 PostGIS, 벡터 검색의 pgvector, 시계열의 TimescaleDB가 모두 확장이다. 데이터베이스를 하나 더 늘리는 대신 PostgreSQL 안에서 해결하는 길이 열린다.

MySQL InnoDB: 테이블이 곧 기본 키 B+트리

MySQL은 저장 엔진을 갈아 끼우는 구조이고, 지금 기본이자 사실상 유일한 선택은 InnoDB다. InnoDB의 테이블은 힙이 아니라 클러스터드 인덱스다. 기본 키 B+트리의 잎 페이지에 행 전체가 기본 키 순서로 들어 있다. 기본 키를 정하지 않으면 NOT NULL인 첫 UNIQUE 인덱스를, 그것도 없으면 숨은 행 번호를 기본 키로 쓴다.

InnoDB 클러스터드 인덱스: 왼쪽 보조 인덱스(email)의 잎에는 기본 키 값이 들어 있다. email로 찾으면 먼저 보조 인덱스에서 기본 키 7을 얻고, 오른쪽 기본 키 B+트리를 다시 내려가 잎에 통째로 들어 있는 행을 읽는다보조 인덱스 (email)클러스터드 인덱스 (PK id) = 테이블kim@ | lee@choi@ → 12kim@ → 3lee@ → 7park@ → 55 | 73 · kim · 서울5 · park · 부산7 · lee · 대구12 · choi · 광주① email로 찾아 PK 7을 얻는다② PK 7로 다시 내려간다잎 페이지에 행 전체가 PK 순서로 들어 있다(행 = 잎)PostgreSQL의 인덱스는 PK 대신 힙 위치(ctid)를 바로 가리킨다
보조 인덱스로 찾으면 B+트리를 두 번 내려간다. 보조 인덱스 잎에는 행 위치가 아니라 기본 키 값이 들어 있기 때문이다.

이 구조에서 실무 규칙 몇 가지가 나온다. 기본 키 범위 조회는 잎을 옆으로 훑기만 하면 되어 아주 빠르다. 보조 인덱스는 잎마다 기본 키를 품으므로 기본 키가 길면 모든 인덱스가 같이 커진다. 무작위 UUID(v4)를 기본 키로 쓰면 새 행이 트리 아무 곳에나 끼어들어 페이지 분할이 잦아지므로, 늘어나는 정수나 시간 순서를 담은 UUIDv7이 낫다. 필요한 열이 모두 보조 인덱스 안에 있으면(커버링 인덱스) 두 번째 탐색을 건너뛴다.

MySQL InnoDB: undo·redo·binlog와 복제

InnoDB도 MVCC를 쓰지만 옛 판을 두는 곳이 PostgreSQL과 반대다. 행은 제자리에서 고치고, 고치기 전 값을 undo 로그에 남긴다. 오래된 스냅샷으로 읽는 트랜잭션은 행에서 undo 사슬을 거꾸로 따라가 옛 판을 다시 만든다. 테이블에 쓰레기가 쌓이지 않는 대신, 아주 긴 트랜잭션이 열려 있으면 undo를 지우지 못해(purge 지연) undo 공간이 불어난다.

InnoDB 쓰기 단계 그림: 버퍼 풀에서 페이지를 찾고, undo 로그에 옛 값을 남기고, 페이지를 고치며 redo 로그에 적고, 커밋 때 redo와 binlog를 디스크에 내리고, 복제본은 binlog를 재생하며 데이터 파일은 나중에 쓰인다버퍼 풀(메모리)undo 로그redo 로그binlog복제본.ibd 데이터 파일id 7 · 잔액 100id 7 · 잔액 50더티나중에 씀id 7 옛 값: 잔액 100id 7 잔액 → 50 (메모리)prepare → commit (fsync)이벤트 기록 (fsync)2단계 커밋같은 변경 재생doublewrite 거쳐
1 / 5

UPDATE accounts SET 잔액 = 50 WHERE id = 7. 먼저 PK 7이 든 잎 페이지를 버퍼 풀에서 찾는다. 없으면 디스크에서 읽어 올린다.

InnoDB의 쓰기 경로. 데이터 파일은 커밋 때 쓰지 않는다. 커밋을 지키는 것은 순서대로 이어 쓰는 redo 로그이고, 복제는 별도의 binlog로 한다.

로그는 두 종류다. redo 로그는 InnoDB의 WAL로, 충돌 뒤 페이지를 되살리는 데 쓴다. binlog는 그 위 MySQL 서버 층의 기록으로, 복제와 특정 시점 복구에 쓴다. 둘이 어긋나면 "주 서버에는 있는데 복제본에는 없는" 행이 생기므로, 커밋 때 내부 2단계 커밋으로 순서를 맞춘다. 데이터 페이지는 16KB인데 디스크는 보통 4KB 단위로 쓰므로, 쓰는 도중 꺼지면 페이지가 반만 바뀔 수 있다. 그래서 페이지를 먼저 doublewrite 버퍼에 쓰고 나서 제자리에 쓴다.

복제는 복제본이 binlog를 받아(relay log) 재생하는 방식이다. 기본 binlog 형식은 바뀐 행 자체를 적는 ROW이고, GTID로 트랜잭션마다 전역 번호를 붙여 복제본을 바꿔 달기 쉽게 한다. 기본은 비동기이며, 반동기(semi-synchronous) 복제나 여러 서버가 합의로 커밋을 정하는 Group Replication을 고를 수 있다. 격리 수준 기본값도 다르다. InnoDB는 REPEATABLE READ이고 범위 조건에 갭 락을 걸어 팬텀을 막는 반면, PostgreSQL은 READ COMMITTED가 기본이다.

SQLite: 파일 하나에 든 데이터베이스

SQLite는 서버가 아니라 C 라이브러리다. 데이터베이스 전체가 파일 하나이고, 그 안에서 테이블과 인덱스가 각각 B-트리다. 파일은 고정 크기 페이지(기본 4096바이트)로 나뉘고, 테이블 B-트리는 행 번호(rowid)를 키로 잎에 행을 담는다. 파일을 복사하면 데이터베이스가 통째로 옮겨지고, 파일 형식은 하위 호환을 약속한다.

SQLite의 롤백 저널과 WAL 모드 비교: 롤백 저널은 고칠 페이지 2의 원본을 -journal 파일에 복사한 뒤 DB 파일을 직접 고치고, 저널을 지우는 순간 커밋된다. WAL 모드는 DB 파일을 그대로 두고 새 페이지 2와 커밋 표시를 -wal 파일 끝에 이어 쓰며, 체크포인트 때 DB 파일로 옮긴다롤백 저널 (기본값)app.db12′34-journal2고치기 전 원본① 원본 페이지를 저널에 복사② DB 파일을 제자리에서 고침③ 저널을 지우는 순간 = 커밋쓰는 동안 읽기도 막힌다꺼지면 저널로 원본을 되돌린다WAL 모드app.db1234-wal2′커밋…체크포인트① 바뀐 페이지를 -wal 끝에 이어 씀② 커밋 표시를 붙이면 = 커밋③ 나중에 체크포인트가 DB로 옮김읽는 쪽은 DB + -wal을 함께 본다읽기와 쓰기 하나가 동시에 돈다
SQLite의 두 커밋 방식. 롤백 저널은 원본을 따로 떠 두고 제자리를 고치며, WAL 모드는 원본을 두고 바뀐 페이지를 따로 이어 쓴다.

원자적 커밋은 두 방식 중 하나로 지킨다. 기본인 롤백 저널은 고칠 페이지의 원본을 -journal 파일에 먼저 떠 두고 DB 파일을 고친 뒤, 저널을 지우는 것으로 커밋한다. 도중에 꺼지면 다음에 열 때 저널의 원본으로 되돌린다. WAL 모드는 거꾸로 DB 파일을 두고 바뀐 페이지를 -wal 파일 끝에 붙이며, 읽는 쪽은 DB 파일과 -wal을 함께 본다. 그래서 쓰는 사람 하나와 읽는 사람 여럿이 서로 막지 않는다. 쓰기는 어느 모드든 한 번에 하나뿐이다.

SQLite는 가장 널리 배포된 데이터베이스 엔진이다. 스마트폰 운영체제, 웹 브라우저, 데스크톱 앱의 설정과 기록이 대부분 SQLite 파일이다. 공식 문서의 쓰기 알맞은 곳 설명대로 SQLite는 클라이언트·서버 데이터베이스와 겨루는 것이 아니라 fopen()과 겨룬다. 앱 안의 저장소, 파일 형식, 도구 사이의 데이터 교환, 쓰기가 많지 않은 웹사이트에 잘 맞고, 여러 기계가 네트워크로 동시에 쓰는 일에는 맞지 않는다.

고르는 법

관계형 엔진 고르기: 데이터가 앱 하나, 기계 하나 안에만 있으면 되나? 예면 SQLite. MySQL 운영 경험과 도구가 이미 갖춰져 있나? 예면 MySQL (InnoDB). 모두 아니면 PostgreSQL에서 출발한다데이터가 앱 하나, 기계 하나 안에만 있으면 되나?예SQLite아니오MySQL 운영 경험과 도구가 이미 갖춰져 있나?예MySQL (InnoDB)아니오PostgreSQL에서 출발한다
세 엔진을 고르는 순서. 표는 아래에서 항목별로 비교한다.
기준PostgreSQLMySQL (InnoDB)SQLite
형태서버, 연결마다 프로세스서버, 연결마다 스레드앱에 들어가는 라이브러리
행 저장힙(정렬 없음) + 보조 인덱스기본 키 B+트리에 행rowid B-트리에 행
옛 판힙 안의 튜플 판, VACUUM으로 회수undo 로그, purge로 회수판 없음(쓰기 하나, WAL로 읽기와 공존)
복제WAL 스트리밍(물리), 논리 복제binlog 재생, Group Replication내장 없음
잘 맞는 곳복잡한 쿼리, 다양한 자료형과 확장, 정합성이 중요한 서비스단순하고 많은 OLTP, 오래 쌓인 운영 경험과 도구모바일·데스크톱 앱, 임베디드, 단일 서버의 작은 서비스
조심할 점연결 수(풀러 필요), autovacuum 관리긴 트랜잭션의 undo 증가, 기본 키 설계동시 쓰기 하나, 네트워크 파일 시스템

새 서비스라면 대개 PostgreSQL에서 출발하고, 이미 MySQL 운영 경험과 도구가 갖춰진 조직이라면 MySQL도 충분히 좋은 선택이다. 데이터가 한 기계 안에만 있으면 되는 앱이라면 서버를 띄우기 전에 SQLite부터 따져 본다. 어느 쪽이든 한 대로 감당하지 못할 만큼 커지면 복제·샤딩 노트의 문제로 넘어간다.