8바이트 수정에 10MB 디스크가 타오르는 이유: 행 지향 저장소 구조

[DB 딥다이브] 단 8바이트 수정에 10MB 디스크가 타오르는 이유 (쓰기 증폭)

SQL 공부를 시작하면 거의 처음에 배우는 공식이 있어요.

“RDB는 OLTP, BigQuery는 OLAP.”

저도 그냥 외웠어요. 시험 답 적듯이. 근데 어느 날 공부하다가 갑자기 이게 너무 궁금해졌어요. 똑같이 SELECT * FROM table WHERE id = 1 치는 건데, 속에서 뭐가 다르게 돌아가는 거지?

그래서 한번 알아봤습니다. 그리고 BigQuery에서 급여 컬럼 하나를 UPDATE 했을 때 디스크에서 실제로 벌어지는 일을 알고 나서야, 왜 데이터 엔지니어들이 BigQuery UPDATE를 그렇게 극도로 꺼리는지 비로소 이해가 됐어요.

오늘은 그 이야기를 해볼게요.


저장 방식부터가 완전히 다르다: Row-store vs Column-store

핵심부터 말하면 이래요.

행 지향(Row-store)은 한 행의 모든 컬럼을 붙여서 저장하고, 열 지향(Column-store)은 같은 컬럼끼리 모아서 저장해요.

말로만 하면 감이 안 오니까 그림으로 생각해볼게요. 직원 테이블이 있다고 가정해요.

id name salary department
1 준석 3000 DE팀
2 지훈 3500 DA팀
3 수아 4000 DE팀

Row-store(PostgreSQL, MySQL 같은 RDBMS) 는 디스크에 이렇게 들어가요.

[1, 준석, 3000, DE팀] [2, 지훈, 3500, DA팀] [3, 수아, 4000, DE팀]

한 행이 통째로 붙어있어요. 준석의 salary를 업데이트하려면? 준석 행 전체를 찾아서, 그 자리에서 수정하면 돼요. 직관적이고 빠르죠.

Column-store(BigQuery, Redshift 같은 분석DB) 는 이렇게 들어가요.

[id: 1, 2, 3]
[name: 준석, 지훈, 수아]
[salary: 3000, 3500, 4000]
[department: DE팀, DA팀, DE팀]

같은 컬럼끼리 모여 있어요. SELECT AVG(salary) FROM employees 같은 쿼리를 날리면 salary 컬럼 하나만 읽으면 끝이에요. id도, name도, department도 건드릴 필요가 없어요. 수백만 행이 있어도 salary 컬럼만 쭉 스캔하면 되니까 OLAP 쿼리에서 압도적으로 빠른 거예요.

한 줄 정리: Row-store는 “한 사람의 정보를 한 곳에”, Column-store는 “같은 종류의 정보를 한 곳에” 저장해요.


RDBMS는 어떻게 빠르게 찾나: B-Tree 인덱스의 물리적 메커니즘

그럼 PostgreSQL 같은 RDBMS는 수천만 행 중에서 id = 1인 행을 어떻게 빠르게 찾을까요?

여기서 B-Tree 인덱스가 등장해요.

B-Tree를 쉽게 설명하면 “도서관의 책 색인”이에요. 책 색인은 찾고 싶은 단어 → 페이지 번호를 알려주잖아요. B-Tree 인덱스도 마찬가지예요. id = 1 → “디스크 페이지 37번, 오프셋 128″처럼 실제 위치를 가리키고 있어요.

그리고 이 B-Tree는 SMP(Symmetric Multi-Processing) 구조와 잘 맞아요. SMP는 여러 CPU가 하나의 공유 메모리를 같이 쓰는 구조예요. RDBMS는 이 구조를 활용해서 트랜잭션 단위로 빠르게 처리해요. id = 1 인 행 하나를 업데이트하는 건 정말 빠르게 처리할 수 있어요.

근데 여기서 중요한 게 있어요. B-Tree 인덱스는 쓰기할 때 숨겨진 비용이 있어요.

salary 컬럼에 인덱스가 걸려있다고 해볼게요. 준석의 salary를 3000에서 3100으로 바꾸면 어떻게 될까요?

  1. 실제 행 데이터 수정 (디스크 I/O 1번)
  2. salary 인덱스에서 3000 항목 삭제 (B-Tree 재조정 발생)
  3. salary 인덱스에 3100 항목 삽입 (B-Tree 재조정 발생)
  4. 변경 내역을 WAL(Write-Ahead Log)에 기록

인덱스가 5개 걸려있으면 이걸 5번 해야 해요. 이게 바로 B-Tree 인덱스가 쓰기를 느리게 만드는 이유예요. 인덱스가 많을수록 쓰기 비용은 선형적으로 늘어나요.

한 줄 정리: B-Tree 인덱스는 읽기를 빠르게 해주는 대신, 인덱스 개수만큼 쓰기 비용이 곱해져요.


BigQuery는 어떻게 분석 쿼리를 그렇게 빠르게 처리하나: Zone Map Pruning과 MPP

BigQuery는 RDBMS와 전혀 다른 방식으로 동작해요.

먼저 MPP(Massively Parallel Processing) 구조예요. SMP가 하나의 공유 메모리에 여러 CPU가 붙는 구조라면, MPP는 아예 수백 수천 대의 독립된 서버가 쿼리를 나눠서 동시에 처리하는 구조예요. SELECT AVG(salary) FROM employees 쿼리가 날아오면 BigQuery는 테이블을 수백 개 조각으로 쪼개서 각각의 서버에 분배하고, 결과를 합쳐요. 수십억 행도 그냥 씹어먹을 수 있는 이유예요.

그리고 Column-store이기 때문에 압축 효율이 엄청나요. salary 컬럼처럼 비슷한 숫자들이 모여있으면 압축률이 10~20배까지 올라가요. 같은 값이 반복되거나 값의 범위가 좁으면 압축이 훨씬 잘 되거든요.

여기에 Zone Map Pruning이 더해져요.

Zone Map은 각 데이터 블록의 메타데이터예요. “이 블록에는 salary 값이 최소 2000, 최대 5000이야”라는 정보를 미리 저장해두는 거예요. WHERE salary > 8000 이라는 조건이 들어오면 BigQuery는 Zone Map을 보고 “이 블록은 최대 5000이니까 볼 필요 없어”라고 판단해서 아예 스캔 자체를 건너뛰어요. 이게 바로 Pruning(가지치기) 이에요.

B-Tree처럼 정확한 위치를 찾는 방식이 아니라, 필요 없는 블록을 제거하는 방식으로 효율을 높이는 거예요.

한 줄 정리: BigQuery는 MPP로 병렬 처리하고, Zone Map Pruning으로 불필요한 스캔을 건너뛰어서 분석 쿼리를 빠르게 처리해요.


이제 본론: 8바이트 UPDATE가 10MB 디스크를 태우는 이유

여기까지 이해했으면 이제 핵심을 설명할 수 있어요.

BigQuery에서 준석의 salary를 3000 → 3100으로 바꾼다고 해볼게요. 정말 딱 8바이트짜리 수정이에요 (int64 기준).

근데 BigQuery는 Column-store예요. salary 컬럼 데이터가 어떻게 저장되어 있냐면, 수십만 행의 salary 값이 하나의 컬럼 블록에 압축되어 통째로 저장되어 있어요.

여기서 문제가 생겨요.

Column-store는 컬럼 단위 블록을 통째로 읽고, 수정하고, 다시 통째로 써요.

왜냐하면 컬럼 데이터는 압축되어 있거든요. 압축된 데이터 중간의 한 값을 바꾸려면 압축을 풀고, 값을 수정하고, 다시 압축해서 새 블록으로 써야 해요. 중간만 쏙 바꾸는 게 불가능해요.

그러면 실제로 무슨 일이 일어나냐면:

  1. salary 컬럼 블록 전체 읽기 — 수십만 행이 들어있는 블록이니까 수 MB
  2. 압축 해제 — CPU 자원 소모
  3. 값 하나 수정 — 실제로 바꾸려던 8바이트
  4. 다시 압축 — 새로운 블록 생성
  5. 새 블록 전체 쓰기 — 다시 수 MB

준석 혼자 salary 바꾸려고 했는데, 같은 블록에 들어있는 수십만 명의 salary 데이터가 통째로 읽히고 다시 써지는 거예요. 이게 바로 쓰기 증폭(Write Amplification) 이에요.

실제로 바꾼 데이터는 8바이트인데, 디스크에서 읽고 쓴 데이터는 10MB가 넘는 거예요. 10MB / 8byte = 약 130만 배의 I/O 증폭이 일어나는 셈이에요.

BigQuery가 UPDATE를 공식적으로 지원하긴 하지만, 내부적으로는 사실상 해당 파티션 전체를 다시 쓰는 작업이 일어나요. 데이터 엔지니어들이 BigQuery에서 UPDATE를 꺼리는 게 단순히 문화나 습관이 아니라, 물리적인 이유가 있었던 거예요.

한 줄 정리: Column-store에서 UPDATE는 8바이트를 고치기 위해 블록 전체를 다시 읽고 쓰는 작업이라, 실제 I/O는 수백만 배 증폭돼요.


그럼 RDBMS에서 같은 업데이트를 하면 어떻게 될까

비교를 위해 PostgreSQL에서 같은 작업을 해볼게요.

UPDATE employees SET salary = 3100 WHERE id = 1
  1. B-Tree 인덱스로 id = 1의 디스크 위치 조회 — 아주 빠름
  2. 해당 행이 들어있는 페이지(보통 8KB) 읽기
  3. 그 페이지 안에서 salary 값만 수정
  4. 수정된 페이지 쓰기
  5. WAL 로그 기록

읽고 쓰는 데이터가 8KB 페이지 하나예요. 압축 해제하고 블록 전체 다시 쓰고 할 필요가 없어요. Row-store는 한 행이 한 곳에 붙어있으니까, 그 행이 들어있는 페이지만 건드리면 끝이에요.

이게 RDBMS가 OLTP에 강한 물리적 이유예요. 단건 업데이트, 단건 조회에서 Row-store + B-Tree는 최적의 구조예요.

반대로 RDBMS에서 SELECT AVG(salary) FROM employees 를 수억 건에 날리면? salary 컬럼만 필요한데, 모든 행을 풀스캔하면서 id, name, department까지 전부 읽어야 해요. 불필요한 데이터를 엄청나게 읽는 거예요. Column-store였으면 salary만 읽으면 될 걸요.

댓글 달기

이메일 주소는 공개되지 않습니다. 필수 필드는 *로 표시됩니다

위로 스크롤