본 포스트는 Notion의 "The data model behind Notion's flexibility"(2021.5.18) 포스트 리뷰이며,
개인적으로 새롭게 알게된 정보/사례를 정리하는 목적으로 작성 됐습니다.
이번 포스트는 Notion의 데이터 모델에 대해 이야기합니다.
Block Model
Notion은 다음 2가지 철학에 기반합니다.
- 컴퓨터는 사람들에게 정보에 대한 권한을 부여함으로써 인간 문제 해결 능력을 증대시키는 도구이다.
- 정보는 독립적 데이터의 조합이다.
Notion은 정보가 어떠한 제약이나 컨테이너 없이 독립적으로 존재할 수 있는 프레임워크를 구축했으며, 대신 세부적인 수준에서 사용자의 손에 권한을 부여했습니다. 이 프레임워크는 블록 기반으로 구축되었습니다. 텍스트, 이미지, 목록, 데이터베이스의 행, 심지어 페이지 자체도 모두 블록이며, Notion 내에서 다른 블록 유형으로 변환하거나 자유롭게 이동할 수 있는 동적 정보 단위입니다.

각 블록에는 다음과 같은 속성이 있습니다.
블록 자체를 설명하는 속성
- ID : 각 블록은 ID로 고유하게 식별할 수 있습니다. 브라우저의 URL 끝에서 페이지 블록의 ID를 볼 수 있습니다. Notion의 ID에는 무작위로 생성된 UUID(UUID v4)를 사용합니다.
- properties : 특정 블록에 대한 사용자 정의 속성을 포함하는 데이터 구조입니다.
- type : 모든 블록에는 유형이 있으며, 이는 블록이 표시되는 방식과 블록의 속성이 해석되는 방식을 정의합니다. Notion은 다양한 유형의 블록을 지원하며, 대부분은 버튼을 누를 때 나타나는 "새 블록" 메뉴 `+`나 `/`메뉴에서 볼 수 있습니다.
다른 블록과의 관계를 정의하는 속성
- content : 이 블록 내부의 콘텐츠를 나타내는 블록 ID의 배열, (ex. 글머리 기호 목록의 중첩된 글머리 기호 항목 또는 토글 내부의 텍스트) 내 ID들을 "하향 포인터"로 생각하고, 그들이 참조하는 블록을 "콘텐츠" 또는 "렌더 자식"이라고 부릅니다. 블록과 렌더링 자식 간의 이러한 계층적 관계를 "렌더 트리"라고 합니다. 블록 유형마다 렌더링 방식이 다릅니다. 이러한 렌더링 방식의 차이는 다른 정보를 다룰 때 정보를 어떻게 구성하고 표시해야 하는지에 대한 사용자 의도를 보존하는 개념입니다.
- parent : 블록의 부모의 블록 ID입니다.
유형 변경
Notion에는 `Turn into`라는 기능이 있습니다. 이 기능에서 블록의 유형을 변경해도 블록의 속성이나 내용은 변경되지 않습니다. 유형 속성만 변경됩니다. 블록 유형에서 속성 저장소를 분리하면 렌더링 로직을 효율적으로 변환하고 변경할 수 있습니다. 이는 협업에도 필수적입니다. 가능한 한 사용자의 의도를 보존하기 때문입니다.
들여쓰기
기존 워드 프로세서에서 들여쓰기는 표현적입니다. 여백에서 텍스트의 간격에만 영향을 미칩니다. Notion에서 들여쓰기는 구조적입니다. 렌더 트리의 구조를 반영합니다. 다시 말해, Notion에서 들여쓰기를 할 때는 스타일을 추가하는 것이 아니라 블록과 콘텐츠 간의 관계를 조작하는 것입니다.
Block Permissions
블록은 자신이 위치한 블록(트리에서 위에 있는 블록)에 따라 권한을 상속받습니다. Notion의 블록 권한 시스템은 아래와 같은 속성들을 만족시키기 위한 구성입니다.
첫번째는 명확성입니다. 협업 및 동시성 모델을 단순화하기 위해 여러 콘텐츠 배열에서 블록을 참조하도록 허용했습니다. 하지만 블록은 여러 곳에서 참조될 수 있기 때문에 어떤 블록에서 권한을 상속받을지 모호합니다. 그리고 권한 시스템에서는 모호함이 용납되지 않습니다.
두번째는 효율성입니다. 블록에 대한 권한 검사를 구현하려면 트리를 조회하여 해당 블록의 조상을 트리의 루트(작업 공간)까지 가져와야 합니다. 모든 블록의 콘텐츠 배열을 검색하여 이 조상 경로를 찾으려는 것은 비효율적이며, 특히 클라이언트에서 그렇습니다. 대신, Notion은 권한 시스템에 대해 "위쪽 포인터"인 부모 속성을 사용합니다. 위쪽 부모 포인터와 아래쪽 콘텐츠 포인터는 서로 미러링됩니다.
이러한 권한 시스템은 트랜잭션 처리에도 영향을 미칩니다. Notion에서 블록, 사용자, 워크스페이스 등과 같은 모든 종류의 지속형 데이터를 "레코드"라고 합니다. 그리고 많은 작업이 일반적으로 두 개 이상의 레코드를 변경하기 때문에 작업은 서버에서 그룹으로 커밋되는 트랜잭션으로 일괄 처리됩니다.
새로운 블록은 고립되어 생성되지 않습니다. 블록은 부모의 콘텐츠 배열에도 추가되므로 콘텐츠 트리에서 올바른 위치에 있습니다. 따라서 클라이언트도 이를 위한 작업을 생성합니다. 이러한 모든 개별 변경 작업은 트랜잭션으로 그룹화됩니다.
Block Processing
사용자가 블록을 업데이트 했을 때 처리 과정

- 새로운 블록을 생성하면 클라이언트는 해당 블록을 부모의 콘텐츠 배열에 추가하고, 이 모든 변경 사항을 작업(operation)으로 정의하여 트랜잭션(transaction)으로 묶습니다.
- 트랜잭션은 로컬 상태에 적용되어 메모리와 로컬 캐시(RecordCache)에 저장되고, UI는 재렌더링되어 변경 사항을 화면에 표시합니다. 동시에 트랜잭션은 TransactionQueue에 저장되고, 서버로 API 요청을 통해 전송됩니다.
- 서버는 요청을 받아 관련된 블록과 부모를 로드하여 "이전" 상태를 메모리에 저장하고, 트랜잭션을 적용하여 "이후" 상태를 생성합니다.
- 권한과 데이터 일관성을 검증한 후, 변경 사항을 데이터베이스에 커밋하고 클라이언트에 성공적인 HTTP 응답을 보냅니다.
- 백그라운드에서는 버전 히스토리 스냅샷 생성, 블록 텍스트 인덱싱, MessageStore를 통한 실시간 업데이트 등의 추가 작업이 수행됩니다.
사용자가 블록을 업데이트 했을 때 협업자 블록의 동기화 과정

- 사용자가 변경을 저장하면(saveTransactions 프로세스), API는 MessageStore에 새로운 레코드 버전을 알립니다.
- 모든 클라이언트는 WebSocket을 통해 MessageStore와 연결되어 있으며, 특정 레코드의 변경 사항을 구독하고 있습니다. MessageStore는 해당 레코드를 구독 중인 클라이언트들에게 버전 업데이트 알림을 보냅니다.
- 클라이언트는 알림을 받고 로컬 캐시에 저장된 레코드의 버전과 비교합니다.
- 버전이 다르면 syncRecordValues API 요청을 통해 서버에 최신 데이터를 요청합니다.
- 서버는 최신 레코드 데이터를 응답하고, 클라이언트는 이를 로컬 캐시에 저장한 후 UI 재렌더링을 통해 사용자에게 최신 정보를 보여줍니다.
사용자가 화면에 접속 했을 때 처리 과정

- 공유된 페이지의 링크를 클릭하면, 수 ms 내에 클라이언트는 로컬 데이터만을 사용하여 페이지를 로드하려고 시도합니다. 웹에서는 메모리에 로드된 블록 데이터를 사용하고, 네이티브 앱에서는 RecordCache에서 데이터를 불러옵니다.
- 필요한 블록 데이터가 로컬에 없을 경우, 클라이언트는 페이지 데이터를 서버로부터 받아오기 위해 API를 호출합니다. 이때 사용되는 API 메서드는 loadPageChunk입니다.
- loadPageChunk는 시작 지점(페이지 블록의 ID)에서부터 콘텐츠 트리를 순회하며, 해당 블록들과 의존 레코드를 반환합니다.
- 최악의 경우, 필요한 데이터를 모두 수집하기 위해 데이터베이스에 여러 번 접근해야 할 수 있습니다.
- 로드된 모든 데이터는 메모리에 저장되며, 네이티브 앱의 경우 RecordCache에 저장됩니다.
- 데이터가 메모리에 로드되면, 클라이언트는 React를 사용하여 페이지를 구성하고 렌더링하여 사용자에게 완전한 페이지를 표시합니다.
Reference.
'Case-Study' 카테고리의 다른 글
| Notion CaseStudy :: Postgres Sharding 리뷰 (3) | 2024.10.22 |
|---|---|
| Notion CaseStudy :: 데이터 레이크 구축 및 확장 리뷰 (5) | 2024.10.12 |