목차
주문 하나의 번호가 궁금해서 4바이트짜리 컬럼 하나를 조회했는데, 디스크는 8KB를 읽었다. 2000배다. 버그가 아니라 덜 읽을 방법이 아예 없다.
여기까지가 트랜잭션부터 MVCC까지, “같이 건드리면 무엇이 깨지나”였다. 여기서부터는 조회 성능이다. 그런데 그 전에 바닥을 하나 깔아야 한다. 뒤에 나올 것 대부분이 이 위에 선다.
최소 단위가 있다
앞에서 말한 그 조회는 이렇게 생겼다.
SELECT 주문번호 FROM orders WHERE id = 7;받고 싶은 건 4바이트다. 그런데 디스크는 바이트 하나를 못 읽는다. “여기서부터 4바이트만 주세요”라는 요청 자체가 성립하지 않는다. 정해진 덩어리 단위로만 오간다.
그 단위가 여러 층에 걸쳐 있다.
| 층 | 단위 |
|---|---|
| 디스크 | 섹터. 전통적으로 512바이트, 요즘은 4KB |
| 운영체제 | 보통 4KB |
| DB | 4~16KB. 여기가 우리 얘기다 |
DB는 아래 층 단위를 그대로 쓰지 않고 자기 단위를 따로 갖는다. 그리고 그 단위로 읽고, 쓰고, 캐시하고, 잠근다. DB가 세상을 보는 눈금이다.
이름이 둘이다
여기서 헷갈리는 게 하나 있다. 같은 것을 제품마다 다르게 부른다.
| 뭐라고 부르나 | 기본 크기 | |
|---|---|---|
| Oracle | 블록 | 8KB |
| PostgreSQL | 페이지 | 8KB |
| MySQL (InnoDB) | 페이지 | 16KB |
| SQL Server | 페이지 | 8KB |
같은 것이다. 다른 개념이 아니라 그냥 이름이 갈린 것뿐이다.
이 시리즈에서는 블록이라고 부른다. 하나로 정하지 않으면 문장마다 “블록/페이지”를 밟게 되고, 그건 읽는 사람 몫이 된다. 다만 페이지 분할만 예외다. 그건 굳어진 이름이라 억지로 바꾸면 검색이 안 된다.
블록 안에는 뭐가 있나


헤더가 앞에 있다. 이 블록이 뭔지, 어디까지 찼는지 같은 정보다. 그리고 행들이 들어간다. 뒤에 빈 공간이 남는다.
여기서 두 가지가 나온다.
8KB를 다 쓰지 못한다. 헤더가 먹고, 빈 공간이 남는다. 실제 데이터는 그보다 적다.
행 하나는 한 블록 안에 들어간다. 이게 기본 규칙이다. 행이 블록 경계에 걸쳐 반씩 나뉘지 않는다.
그래서 생기는 일 - 같은 블록이면 공짜다
한 행을 읽어도 블록 하나를 통째로 읽는다. 그러면 그 블록에 같이 있던 나머지 행들도 이미 메모리에 와 있다. 공짜로 딸려온 것이다.
이게 이 글의 전부다. 그리고 뒤에 나올 거의 모든 것의 바닥이다.
- 다음 글의 B-tree가 왜 그렇게 뚱뚱한지 - 어차피 블록 하나를 통째로 읽을 거면 그 블록을 값으로 꽉 채우는 게 이득이다
- 인덱스가 왜 빠른지 - 필요한 값들이 한 블록에 모여 있어서다
- 무작위 키가 왜 나쁜지 - 매번 다른 블록을 열어야 해서다
전부 같은 문장의 다른 얼굴이다. 같이 읽힐 것들을 같은 블록에 모아라.
반대쪽도 있다 - 한 바이트를 써도 블록 하나다
읽기만 그런 게 아니다.
8바이트를 바꾸려면 그 블록 8KB를 통째로 다시 써야 한다. 덜 쓸 방법이 없다. 읽을 때와 같은 이유다.
낭비처럼 보이는데, 조회에서는 이게 이득이었다. 쓰기에서는 그냥 손해다. 같은 성질이 방향에 따라 이득도 되고 손해도 된다.
B-tree와 LSM-tree 글에서 이 손해를 정면으로 다룬다. 그리고 그걸 못 견딘 사람들이 만든 게 LSM-tree다.
꽉 차면 - 쪼개거나, 안 쪼개거나
블록에 자리가 없는데 새로 넣어야 한다. 어떻게 되나? 그 구조가 순서를 지켜야 하느냐에 갈린다.
테이블은 순서가 없다. 어느 행이 어느 블록에 있든 상관없다. 그러니 꽉 찬 블록을 만났으면 그냥 자리 있는 다른 블록에 넣는다. 쪼갤 이유가 없다.
인덱스는 정렬돼 있어야 한다. 그게 인덱스가 빠른 이유의 전부다. 1005는 반드시 1004와 1006 사이에 있어야 하는데 그 블록이 꽉 찼다면, 아무 데나 던져둘 수가 없다. 블록을 둘로 쪼개고 절반씩 나눠 담은 뒤 그 사이에 끼운다. 이게 페이지 분할이다.
한 번의 삽입이 블록 여러 개를 건드리는 일이 된다. 그래서 값이 어디에 꽂히느냐가 성능을 가른다. 이건 키 설계 편에서 다시 만난다.
“테이블은 안 쪼갠다”에 예외가 있다. 테이블 자체를 기본키 순으로 정렬해 저장하는 DB가 있다(MySQL InnoDB가 그렇다). 그러면 테이블이 곧 인덱스라 테이블도 쪼개진다. 규칙은 “테이블이냐 인덱스냐”가 아니라 “순서를 지켜야 하느냐” 다.
행이 블록보다 크면
TEXT 컬럼에 긴 글을 넣었다. 행 하나가 8KB를 넘는다. “행은 한 블록에 들어간다”는 규칙이 깨진다.
DB마다 방법이 다르지만 결국 둘 중 하나다. 쪼개서 여러 블록에 나눠 담거나, 큰 값만 밖으로 빼고 블록에는 가리키는 표시만 남긴다.
어느 쪽이든 그 행을 읽으려면 블록을 두 번 이상 읽는다. 한 번이면 될 걸 두 번 한다.
크기는 왜 하필 그건가
8KB냐 16KB냐는 거래다.
크면 한 번에 많이 가져온다. 훑는 조회에 유리하다. 대신 한 행만 필요할 때 버리는 게 많고, 메모리에 올릴 때도 그만큼 자리를 먹는다.
작으면 낭비가 적다. 대신 블록 개수가 늘어 관리할 게 많아지고, 큰 행이 안 들어간다.
거의 항상 기본값을 그대로 쓴다. 바꾸려면 대개 DB를 다시 만들어야 하고, 바꿔서 얻는 것보다 잃는 게 크다. 이 절은 “크기를 튜닝하라”는 게 아니라 왜 이 숫자가 나왔는지 알아두라는 것이다. 느린 조회의 원인이 블록 크기인 경우는 사실상 없다.
일부러 비워둔다
블록을 100% 꽉 채우면 문제가 생긴다. 나중에 그 행이 커지면 갈 데가 없다.
주소 컬럼에 서울이라고 넣었다가 서울특별시 강남구 …로 바꾸면 행이 커진다. 블록이 꽉 차 있으면 그 행을 다른 블록으로 통째로 옮긴다. 그리고 원래 자리에는 “저기로 갔다”는 표시만 남는다. 이제 그 행을 읽으려면 두 블록을 읽는다.
그래서 DB는 블록을 채울 때 일부러 여유를 남긴다. 나중에 커질 자리를 미리 비워두는 것이다. 공간을 버리고 나중의 이사를 산다.
정리
- 디스크는 바이트를 모른다. 최소 단위가 있고, DB는 자기 단위를 따로 갖는다. 그게 블록이다.
- Oracle은 블록, 나머지는 대개 페이지라고 부른다. 같은 것이다. 이 시리즈는 블록으로 부른다.
- 한 행을 읽어도 블록 하나, 한 바이트를 써도 블록 하나다. 조회에선 이게 이득이고 쓰기에선 손해다.
- 그래서 뒤에 나올 게 전부 한 문장으로 모인다. 같이 읽힐 것들을 같은 블록에 모아라.
- 블록이 꽉 차면 쪼개고, 행이 블록보다 크면 나눠 담고, 행이 커질 자리는 미리 비워둔다.
- 크기는 안 건드린다. 왜 그 숫자인지만 알면 된다.
다음 글은 인덱스다. B-tree가 왜 그렇게 생겼는지, 왜 이진트리가 아닌지는 사실 이 글 하나로 설명된다.