Introduction
세상에는 두 종류의 기업이 있습니다.
- 데이터베이스 샤딩을 경험한 기업과
- 앞으로 경험하게 될 기업
스타트업이 성공하면 어느 순간 마주하게 되는 그 순간, Notion도 피해갈 수 없었습니다. 2020년 중반, 노션이 사용하던 PostgreSQL 데이터베이스가 신음하기 시작했습니다. 5년간 1만 배의 성장을 견뎌온 데이터베이스였지만, 이제는 매일 밤 CPU 스파이크로 엔지니어들을 깨우고 있었죠.
더 큰 문제는 따로 있었습니다. 데이터베이스의 VACUUM 프로세스가 중단되기 시작했고, TXID 순환이라는 시한폭탄이 카운트다운에 들어갔습니다. 이대로 가다간 전체 서비스가 멈출 수 있는 상황. 이들은 결단을 내려야 했죠.
과연 노션의 도전은 성공할 수 있었을까요? 수개월간의 준비 끝에 진행된 숨 막히는 5분의 기록과, 그 과정에서 배운 값진 교훈들을 공유하고자 합니다. 노션 블로그에서 작성한 Herding elephants: Lessons learned from sharding Postgres at Notion을 번역해 가져왔습니다.
이 글에서는 단순히 기술적인 설명을 나열하는 대신, 실제 현장에서 마주한 도전과 고민, 그리고 그 해결 과정을 현실감 있게 다루고자 합니다. 특히 다음과 같은 내용들을 자세히 다룰 예정인데요:
- 샤딩 시점을 결정할 때의 딜레마
- 480개 샤드라는 특별한 숫자를 선택한 이유
- 5분 다운타임으로 모든 것을 바꾼 그날의 이야기
- "이랬다면 더 좋았을 것"이라는 세 가지 뼈아픈 교훈
마이그레이션이 성공적으로 끝난 후, 사용자들은 즉시 변화를 체감했습니다. 하지만 그 이면에는 수개월간의 치열한 준비와 고민이 있었죠. 이제 그 모든 이야기를 들려드리려 합니다.
Notion의 PostgreSQL 샤딩 여정과 교훈
올해 초, Notion은 5분간의 예정된 점검 시간을 가졌습니다. 공지사항에는 단순히 "안정성과 성능 개선"이라고만 언급했지만, 그 이면에는 수개월에 걸친 집중적이고 긴급한 팀워크가 숨어 있었습니다. 바로 Notion의 단일 PostgreSQL 데이터베이스를 수평 분할된 데이터베이스 클러스터로 전환하는 대작업이었죠.
전환 작업은 성공적이었고 팀은 환호했지만, 혹시 모를 마이그레이션 후유증에 대비해 조용히 지켜보기로 했습니다. 다행히도 사용자들은 곧 개선된 점을 알아차리기 시작했습니다.
하지만 단순한 점검 시간만으로는 이 이야기의 전부를 설명할 수 없습니다. 우리 팀은 앞으로 수년간 Notion을 더 빠르고 안정적으로 운영하기 위해 이 마이그레이션을 수개월에 걸쳐 세심하게 설계했습니다.
지금부터 우리가 어떻게 샤딩을 구현했고, 그 과정에서 무엇을 배웠는지 이야기해보겠습니다.
샤딩 시기 결정하기
애플리케이션 성능 개선을 위한 우리의 지속적인 노력 가운데, 샤딩은 중요한 이정표였습니다. 지난 몇 년간 더 많은 사람들이 일상의 모든 면에서 Notion을 활용하는 모습을 보며 감사하고 겸손한 마음을 가졌죠. 당연하게도, 사람들이 새로운 회사 위키, 프로젝트 관리 도구, 포켓몬 도감 등을 만든다는 것은 곧 수십억 개의 새로운 블록, 파일, 스페이스를 의미했습니다. 2020년 중반쯤에는 제품 사용량이 우리의 신뢰할 만한 PostgreSQL 단일 데이터베이스의 한계를 넘어설 것이 분명해졌습니다. 이 데이터베이스는 5년 동안 1만 배의 성장을 견디며 충실히 서비스를 지원해왔죠. 당직 엔지니어들은 종종 데이터베이스 CPU 스파이크로 인해 새벽에 깨어나야 했고, 단순한 카탈로그 관련 마이그레이션조차 안전하지 않고 불확실해졌습니다.
샤딩에 있어서 빠르게 성장하는 스타트업들은 미묘한 균형을 맞춰야 합니다. 2000년대에는 너무 이른 샤딩의 위험성을 경고하는 블로그 포스트들이 쏟아져 나왔습니다. 유지보수 부담 증가, 애플리케이션 코드의 새로운 제약사항, 아키텍처의 경로 의존성 등이 주요 문제점으로 지적됐죠.¹ 물론 우리의 규모에서는 샤딩이 불가피했습니다. 문제는 단지 '언제'였을 뿐입니다.
우리에게 전환점이 온 것은 PostgreSQL의 VACUUM 프로세스가 지속적으로 중단되기 시작하면서였습니다. 이로 인해 데이터베이스가 더 이상 사용되지 않는 튜플(dead tuple)에서 디스크 공간을 회수하지 못하게 됐죠. 디스크 용량은 늘릴 수 있었지만, 더 걱정스러웠던 것은 트랜잭션 ID(TXID) 순환의 가능성이었습니다. 이는 PostgreSQL이 기존 데이터 손상을 방지하기 위해 모든 쓰기 작업을 중단하는 안전 장치입니다. TXID 순환이 서비스에 치명적인 위협이 될 수 있다는 것을 깨달은 우리 인프라 팀은 즉시 작업에 착수했습니다.
샤딩 스키마 설계하기
데이터베이스 샤딩이 처음이신가요? 간단히 설명드리면 이렇습니다: 점점 더 큰 인스턴스로 데이터베이스를 수직 확장하는 대신, 데이터를 여러 데이터베이스에 분산하여 수평으로 확장하는 것입니다. 이렇게 하면 성장에 맞춰 쉽게 새로운 서버를 추가할 수 있습니다. 다만 이제 데이터가 여러 곳에 분산되어 있으므로, 분산 환경에서 성능과 일관성을 최대화하는 시스템을 설계해야 합니다.
애플리케이션 레벨 샤딩
우리는 자체 파티셔닝 스키마를 구현하고 애플리케이션 로직에서 쿼리를 라우팅하기로 했습니다. 이를 애플리케이션 레벨 샤딩이라고 합니다. 초기 검토 단계에서 PostgreSQL용 Citus나 MySQL용 Vitess 같은 기성 샤딩/클러스터링 솔루션도 고려했습니다. 이러한 솔루션들은 단순함이 매력적이고 크로스-샤드 도구들을 기본으로 제공하지만, 실제 클러스터링 로직이 블랙박스처럼 감춰져 있었고 우리는 데이터 분산을 직접 제어하고 싶었습니다.²
애플리케이션 레벨 샤딩을 위해 우리는 다음과 같은 핵심적인 설계 결정을 해야 했습니다:
어떤 데이터를 샤딩할 것인가? 우리 데이터셋의 특이한 점은 블록 테이블이 사용자가 만든 콘텐츠의 트리 구조를 반영하며, 이는 크기, 깊이, 분기 요소가 매우 다양하다는 것입니다. 예를 들어, 한 대기업 고객이 생성하는 부하는 수많은 개인 워크스페이스를 합친 것보다 더 큽니다. 우리는 필요한 테이블만 샤딩하면서도 연관된 데이터의 근접성(locality)을 보존하고자 했습니다.
어떻게 데이터를 분할할 것인가? 좋은 파티션 키는 데이터가 샤드 전체에 균일하게 분산되도록 보장합니다. 파티션 키의 선택은 분산 조인이 비용이 많이 들고 트랜잭션 보장이 일반적으로 단일 서버로 제한된다는 점에서 애플리케이션 구조와도 밀접한 관련이 있습니다.
샤드를 얼마나 만들고 어떻게 구성할 것인가? 이는 테이블별 논리적 샤드의 수와, 논리적 샤드와 물리적 서버 간의 구체적인 매핑 방식을 모두 포함하는 고려사항입니다.
결정 1: 블록과 연관된 모든 데이터 샤딩
Notion의 데이터 모델은 '블록'이라는 개념을 중심으로 구성되어 있으며, 각 블록은 데이터베이스의 한 행을 차지합니다. 따라서 블록 테이블이 샤딩의 최우선 대상이었습니다. 하지만 한 블록은 space(워크스페이스)나 discussion(페이지 단위 및 인라인 토론 스레드) 같은 다른 테이블을 참조할 수 있고, discussion은 다시 comment 테이블의 행을 참조하는 식으로 이어집니다.
우리는 블록 테이블에서 외래 키 관계로 연결된 모든 테이블을 샤딩하기로 결정했습니다. 모든 테이블을 샤딩할 필요는 없습니다. 하지만 만약 어떤 레코드가 메인 데이터베이스에 있는데 그와 연관된 블록이 다른 물리적 샤드에 있다면, 서로 다른 데이터 저장소에 쓰기 작업을 할 때 데이터 불일치가 발생할 수 있었기 때문입니다.
결정 2: 워크스페이스 ID 기반 데이터 파티셔닝
샤딩할 테이블을 결정한 후에는 이들을 어떻게 분할할지 정해야 했습니다. 효과적인 파티션 방식을 선택하는 것은 데이터의 분포와 연결 패턴에 크게 좌우됩니다. Notion이 팀 기반 제품이라는 점을 고려하여, 우리는 워크스페이스 ID를 기준으로 데이터를 파티셔닝하기로 결정했습니다.³
각 워크스페이스는 생성 시점에 UUID를 할당받으므로, UUID 공간을 균일한 크기의 버킷으로 나눌 수 있었습니다. 샤딩된 테이블의 모든 행이 블록이거나 블록과 연관되어 있고, 각 블록은 정확히 하나의 워크스페이스에 속한다는 점에서 워크스페이스 ID는 파티션 키로 적합했습니다. 사용자들은 대개 한 번에 하나의 워크스페이스 내에서 데이터를 조회하므로, 이 방식으로 대부분의 크로스-샤드 조인을 피할 수 있었습니다.
결정 3: 용량 계획
엔지니어들 사이에서 농담처럼 돌아다니는 질문이 있습니다: "100만 개의 요청을 보내는 한 명의 사용자와 싸울래? 아니면 한 개의 요청을 보내는 100만 명의 사용자와 싸울래?"
파티셔닝 방식을 결정한 후, 우리의 목표는 현재 데이터를 처리하면서도 향후 2년간의 사용량 예측을 적은 노력으로 충족할 수 있는 샤딩 구성을 설계하는 것이었습니다. 다음과 같은 제약 조건들을 고려해야 했습니다:
인스턴스 타입: IOPS(초당 입출력 작업 수)로 측정되는 디스크 I/O 처리량은 AWS 인스턴스 타입과 디스크 볼륨 모두에 의해 제한됩니다. 우리는 기존 수요를 충족하기 위해 최소 60K의 총 IOPS가 필요했고, 필요한 경우 더 확장할 수 있는 여유도 필요했습니다.
물리적 및 논리적 샤드의 수: PostgreSQL을 안정적으로 운영하고 RDS 복제 보장을 유지하기 위해, 테이블당 500GB, 물리적 데이터베이스당 10TB의 상한을 설정했습니다. 우리는 논리적 샤드의 수와 물리적 데이터베이스의 수를 선택해야 했고, 이때 샤드들이 데이터베이스 간에 균등하게 분배될 수 있어야 했습니다.
인스턴스의 수: 인스턴스가 많아지면 유지보수 비용이 증가하지만, 시스템은 더욱 안정적이 됩니다. 이 트레이드오프를 신중히 고려해야 했습니다.
비용: 우리는 데이터베이스 구성에 따라 비용이 선형적으로 증가하기를 원했고, 컴퓨팅 능력과 디스크 공간을 독립적으로 확장할 수 있는 유연성도 필요했습니다.
수치 분석을 마친 후, 우리는 32개의 물리적 데이터베이스에 걸쳐 480개의 논리적 샤드를 균등하게 분산하는 아키텍처를 선택했습니다. 계층 구조는 다음과 같습니다:
- 물리적 데이터베이스 (총 32개)
- 논리적 샤드, PostgreSQL 스키마로 구현 (데이터베이스당 15개, 총 480개)
- block 테이블 (논리적 샤드당 1개, 총 480개)
- collection 테이블 (논리적 샤드당 1개, 총 480개)
- space 테이블 (논리적 샤드당 1개, 총 480개)
- 기타 샤딩이 필요한 모든 테이블도 동일한 방식으로 구성
우리는 데이터베이스당 15개의 자식 테이블을 가진 단일 파티션 테이블을 유지하는 대신, schema001.block, schema002.block 등을 독립된 테이블로 구성하기로 했습니다. 네이티브 파티션 테이블을 사용하면 다음과 같은 두 단계의 라우팅 로직이 필요합니다:
- 애플리케이션 코드: 워크스페이스 ID → 물리적 데이터베이스
- 파티션 테이블: 워크스페이스 ID → 논리적 스키마
독립된 테이블을 유지함으로써 애플리케이션에서 특정 데이터베이스와 논리적 샤드로 직접 라우팅할 수 있었습니다. 우리는 워크스페이스 ID에서 논리적 샤드로의 라우팅을 위한 단일 진실 공급원(single source of truth)을 원했기 때문에, 테이블을 개별적으로 구성하고 모든 라우팅을 애플리케이션 단에서 처리하기로 결정했습니다.
샤드로의 마이그레이션
샤딩 스키마를 확립한 후에는 이를 실제로 구현할 차례였습니다. 모든 마이그레이션 프로젝트에서 우리는 다음과 같은 기본 프레임워크를 따릅니다:
- 이중 쓰기: 새로운 데이터를 기존 데이터베이스와 새 데이터베이스 모두에 기록
- 백필: 이중 쓰기가 시작되면 기존 데이터를 새 데이터베이스로 이전
- 검증: 새 데이터베이스의 데이터 무결성 확인
- 전환: 실제로 새 데이터베이스로 전환 (이중 읽기부터 시작하여 점진적으로 모든 읽기를 마이그레이션하는 등의 방식으로 진행 가능)
감사 로그를 활용한 이중 쓰기 구현
이중 쓰기 단계는 새 데이터베이스가 아직 실제 서비스에 사용되지 않더라도 새로운 데이터가 기존 데이터베이스와 새 데이터베이스 모두에 확실히 기록되도록 보장합니다. 이를 위한 몇 가지 접근 방식을 검토했습니다:
1. 직접 이중 쓰기: 가장 단순해 보이는 방식으로, 두 데이터베이스에 직접 쓰는 방법입니다. 하지만 어느 한쪽의 쓰기 작업에라도 문제가 발생하면 데이터베이스 간 불일치가 빠르게 발생할 수 있어, 핵심 프로덕션 데이터를 다루기에는 너무 위험했습니다.
2. 논리적 복제(Logical Replication): PostgreSQL의 내장 기능으로, 게시/구독 모델을 사용해 명령을 여러 데이터베이스에 전파합니다. 하지만 소스와 대상 데이터베이스 간의 데이터 변환 기능이 제한적이라는 단점이 있었습니다.
3. 감사 로그와 따라잡기 스크립트: 마이그레이션 대상 테이블에 대한 모든 쓰기 작업을 기록하는 감사 로그 테이블을 만듭니다. 별도의 따라잡기 프로세스가 이 로그를 순차적으로 처리하면서 필요한 수정과 함께 새 데이터베이스에 적용합니다.
우리는 결국 감사 로그 방식을 선택했습니다. 논리적 복제는 초기 스냅샷 단계에서 block 테이블의 높은 쓰기 처리량을 따라가지 못했기 때문입니다.
기존 데이터 백필링
들어오는 새로운 쓰기가 성공적으로 새 데이터베이스에 전파되는 것을 확인한 후, 우리는 기존 데이터를 마이그레이션하는 백필 프로세스를 시작했습니다. AWS m5.24xlarge 인스턴스의 모든 96개 CPU를 활용한 결과, 프로덕션 환경의 전체 데이터를 백필하는 데 약 3일이 소요됐습니다.
신뢰할 수 있는 백필 프로세스라면 반드시 오래된 데이터를 쓰기 전에 레코드 버전을 비교하여, 더 최신 업데이트가 있는 레코드는 건너뛰어야 합니다. 이렇게 하면 따라잡기 스크립트와 백필을 어떤 순서로 실행하더라도, 새 데이터베이스는 결국 단일 데이터베이스의 상태와 동일하게 수렴하게 됩니다.
데이터 무결성 검증
마이그레이션은 기반이 되는 데이터의 무결성만큼만 신뢰할 수 있습니다. 따라서 샤드가 단일 데이터베이스와 동기화된 후에는 철저한 검증 프로세스를 시작했습니다.
검증 스크립트 실행: 우리의 검증 스크립트는 주어진 값에서 시작하는 UUID 공간의 연속된 범위를 검사하면서, 단일 데이터베이스의 각 레코드를 해당하는 샤딩된 레코드와 비교했습니다. 전체 테이블 스캔은 비용이 너무 많이 들기 때문에, UUID를 무작위로 샘플링하고 그 주변 범위를 집중적으로 검증하는 방식을 택했습니다.
다크 읽기(Dark Reads) 도입: 읽기 쿼리를 마이그레이션하기 전에, 기존 데이터베이스와 새 데이터베이스에서 동시에 데이터를 가져오는 '다크 읽기' 기능을 추가했습니다. 두 소스의 데이터를 비교하고 샤딩된 데이터는 버리되, 발견된 불일치는 모두 로깅했습니다. 이 방식은 API 응답 시간을 다소 늘렸지만, 실제 전환이 안전할 것이라는 확신을 주었습니다.
특별한 예방 조치로, 마이그레이션 코드와 검증 코드는 서로 다른 엔지니어가 작성했습니다. 한 사람이 두 부분을 모두 구현하면 같은 실수를 반복할 가능성이 있어 검증의 신뢰성이 떨어질 수 있기 때문입니다.
어려웠던 순간들과 배운 교훈
샤딩 프로젝트는 Notion 엔지니어링 팀의 최고의 순간을 보여주었지만, 되돌아보면 다르게 접근할 수 있었을 결정들도 있었습니다. 주요한 교훈들을 공유하겠습니다:
1. 더 일찍 샤딩을 시작했어야 했다
작은 팀으로서 우리는 조기 최적화의 위험성을 잘 알고 있었습니다. 하지만 기존 데이터베이스가 한계에 도달할 때까지 기다린 것은 결과적으로 더 큰 제약을 가져왔습니다. 이미 과부하 상태인 데이터베이스에 추가 부하를 주지 않기 위해 마이그레이션 과정에서 매우 조심스럽게 진행해야 했기 때문입니다.
이로 인해 이중 쓰기를 위한 논리적 복제 사용이 어려워졌습니다. 파티션 키로 사용할 워크스페이스 ID가 기존 데이터베이스에 아직 채워지지 않은 상태였고, 이 컬럼을 채우는 작업 자체가 이미 부하가 높은 모놀리스에 더 큰 부담을 주었을 것이기 때문입니다. 대신 우리는 샤드에 데이터를 쓸 때마다 각 행을 실시간으로 백필해야 했고, 이를 위해 복잡한 커스텀 스크립트를 개발해야 했습니다.
2. 무중단 마이그레이션을 목표로 했어야 했다
이중 쓰기의 처리량이 최종 전환 과정에서 가장 큰 병목이었습니다. 서버를 중단한 후에는 따라잡기 스크립트가 샤드로의 쓰기 전파를 완료할 때까지 기다려야 했습니다. 만약 전환 중에 샤드를 따라잡는 데 30초 미만이 걸리도록 스크립트를 최적화하는 데 일주일을 더 투자했다면, 로드 밸런서 수준에서 무중단 전환이 가능했을 것입니다.
3. 복합 기본 키 도입을 고려했어야 했다
현재 샤딩된 테이블의 행들은 두 개의 키를 사용합니다: 기존 데이터베이스의 기본 키였던 id와 현재 구성의 파티션 키인 space_id입니다. 어차피 전체 테이블 스캔이 필요했던 상황에서, 두 키를 하나의 새로운 컬럼으로 통합했다면 애플리케이션 전반에 걸쳐 space_id를 전달해야 하는 번거로움을 피할 수 있었을 것입니다.
결론: 성공적인 도전
이러한 '아쉬운 점들'에도 불구하고, 샤딩 프로젝트는 큰 성공이었습니다. 사용자들의 입장에서는 단 몇 분의 다운타임으로 제품이 눈에 띄게 빨라졌습니다. 내부적으로는 시간에 쫓기는 상황에서도 팀이 얼마나 조직적으로 협력하고 결단력 있게 실행할 수 있는지를 보여주었습니다.
급박한 일정 속에서도 장기적인 기술적 영향을 깊이 있게 고민하는 것을 멈추지 않는 분들이라면, 저희와 함께 이런 도전적인 문제들을 해결해나가면 좋겠습니다. 함께해요!
👥 더 나은 데브필을 만드는 데 의견을 보태주세요
Top 1% 개발자로 거듭나기 위한 처방전, DevPill 구독자 여러분 안녕하세요 :)
저는 여러분들이 너무 궁금합니다.
어떤 마음으로 뉴스레터를 구독해주시는지,
어떤 환경에서 최고의 개발자가 되기 위해 고군분투하고 계신지,
제가 드릴 수 있는 도움은 어떤 게 있을지.
아래 설문조사에 참여해주시면 더 나은 콘텐츠를 제작할 수 있도록 힘쓰겠습니다. 설문에 참여해주시는 분들 전원 1개월 유료 멤버십 구독권을 선물드립니다. 유료 멤버십에서는 아래와 같은 혜택이 제공됩니다.
- DevPill과의 1:1 온라인 커피챗
- 멤버십 전용 슬랙 채널 참여권
- 채용 정보 공유 / 스터디 그룹 형성 / 실시간 기술 질의응답
- 이력서/포트폴리오 템플릿
댓글
의견을 남겨주세요