🏢 Notion 기술 스택 분석

24 min read
notiontypescriptreactpostgresqlawscloudflarewebsocketsqlitewasmelectronblock-editorcollaborationdatabase

노션이 수천만 사용자에게 All-in-One 워크스페이스를 제공하는 기술 스택 심층 분석 - 블록 기반 에디터, 실시간 협업, 그리고 확장 가능한 데이터베이스

🏢 Notion 기술 스택 분석

문서, 데이터베이스, 칸반 보드를 하나로 통합한 Notion의 기술 스택을 심층 분석합니다.
"A new tool that blends your everyday work apps into one" - 모든 것을 블록으로 만드는 기술


📊 회사 개요

서비스 규모

  • 사용자: 3,000만+ (개인 및 팀)
  • 기업 고객: 400만+ 팀
  • 월간 블록 생성: 10억+ 개
  • 데이터베이스 항목: 5억+ 개
  • 일일 API 요청: 1억+ 건

엔지니어링 조직

  • 엔지니어: 200명+ (전체 직원 500+)
  • 문화: "Augmenting Human Intellect"
  • 원격 우선: 글로벌 분산 팀
  • 기술 철학: 단순함과 강력함의 균형

기술적 도전과제

  1. 블록 기반 에디터: 무한히 중첩 가능한 구조
  2. 실시간 협업: 동시 편집과 충돌 해결
  3. 유연한 데이터베이스: 스프레드시트 같은 강력함
  4. 오프라인 지원: 로컬 우선 아키텍처

🏗️ 아키텍처 Overview

시스템 다이어그램

┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ Clients │────►│ API │────►│ Database │ │(Web/Desktop)│ │ Gateway │ │ Service │ └─────────────┘ └─────────────┘ └─────────────┘ │ │ │ │ ┌─────▼─────┐ ┌─────▼─────┐ │ │ Real-time │ │PostgreSQL │ │ │ Service │ │ Cluster │ │ └───────────┘ └───────────┘ │ │ │ └────────────────────┤ │ ┌─────▼─────┐ ┌─────▼─────┐ │ Search │ │ Block │ │ Service │ │ Storage │ └───────────┘ └───────────┘ 

핵심 설계 원칙

  1. Everything is a Block: 모든 콘텐츠는 블록
  2. Local-First: 오프라인 우선 설계
  3. CRDT: 충돌 없는 동시 편집
  4. Flexible Schema: 동적 데이터베이스 구조
  5. Progressive Enhancement: 점진적 기능 향상

기술 진화

  • 2013-2015: 초기 프로토타입 (웹 기반)
  • 2016-2017: 데스크톱 앱 출시
  • 2018-2019: 데이터베이스 기능 추가
  • 2020-2021: API 플랫폼 공개
  • 2022-2023: AI 통합 (Notion AI)
  • 2024-현재: 엔터프라이즈 확장

🧩 기술 스택 상세

Backend 기술

블록 저장 시스템

// Notion의 블록 모델 interface Block { id: string; type: BlockType; properties: Record<string, any>; content?: string[]; children?: string[]; // 자식 블록 ID parent: string; created_time: number; last_edited_time: number; created_by: string; last_edited_by: string; version: number; } type BlockType = | "text" | "heading_1" | "heading_2" | "heading_3" | "bulleted_list_item" | "numbered_list_item" | "toggle" | "to_do" | "quote" | "divider" | "callout" | "image" | "video" | "file" | "pdf" | "bookmark" | "code" | "equation" | "database" | "table_row" | "column_list" | "synced_block" | "template"; // 블록 서비스 export class BlockService { private db: PostgresClient; private cache: RedisClient; private search: ElasticsearchClient; async getBlock(blockId: string, depth: number = 0): Promise<Block> { // 캐시 확인 const cached = await this.cache.get(`block:${blockId}`); if (cached) return JSON.parse(cached); // DB에서 조회 const block = await this.db.query("SELECT * FROM blocks WHERE id = $1", [ blockId, ]); // 자식 블록 재귀적 로드 if (depth > 0 && block.children?.length > 0) { block.childBlocks = await Promise.all( block.children.map((childId) => this.getBlock(childId, depth - 1)), ); } // 캐시 저장 await this.cache.setex(`block:${blockId}`, 300, JSON.stringify(block)); return block; } async updateBlock( blockId: string, updates: Partial<Block>, userId: string, ): Promise<Block> { // 낙관적 잠금 const current = await this.getBlock(blockId); if (updates.version !== current.version) { throw new ConflictError("Version mismatch"); } // 업데이트 적용 const updated = { ...current, ...updates, version: current.version + 1, last_edited_time: Date.now(), last_edited_by: userId, }; // 트랜잭션으로 저장 await this.db.transaction(async (trx) => { // 블록 업데이트 await trx.query( `UPDATE blocks SET properties = $1, content = $2, version = $3, last_edited_time = $4, last_edited_by = $5 WHERE id = $6`, [ updated.properties, updated.content, updated.version, updated.last_edited_time, updated.last_edited_by, blockId, ], ); // 히스토리 저장 await trx.query( `INSERT INTO block_history (block_id, version, properties, content, edited_by, edited_at) VALUES ($1, $2, $3, $4, $5, $6)`, [ blockId, current.version, current.properties, current.content, userId, Date.now(), ], ); }); // 캐시 무효화 await this.cache.del(`block:${blockId}`); // 실시간 브로드캐스트 await this.broadcastUpdate(blockId, updated); // 검색 인덱스 업데이트 await this.search.update({ index: "blocks", id: blockId, body: updated, }); return updated; } } 

데이터베이스 시스템

// Notion의 유연한 데이터베이스 interface Database { id: string; title: string; properties: Record<string, Property>; views: View[]; filters: Filter[]; sorts: Sort[]; } interface Property { id: string; name: string; type: PropertyType; options?: any; } type PropertyType = | "title" | "text" | "number" | "select" | "multi_select" | "date" | "person" | "files" | "checkbox" | "url" | "email" | "phone" | "formula" | "relation" | "rollup"; // 데이터베이스 쿼리 엔진 export class DatabaseQueryEngine { async executeQuery( databaseId: string, query: DatabaseQuery, ): Promise<QueryResult> { // 1. 스키마 로드 const schema = await this.getSchema(databaseId); // 2. 쿼리 최적화 const optimizedQuery = this.optimizeQuery(query, schema); // 3. 실행 계획 생성 const executionPlan = this.createExecutionPlan(optimizedQuery); // 4. 쿼리 실행 let results = await this.executeplan(executionPlan); // 5. 필터 적용 if (query.filter) { results = this.applyFilters(results, query.filter); } // 6. 정렬 적용 if (query.sorts) { results = this.applySorts(results, query.sorts); } // 7. Formula/Rollup 계산 results = await this.computeDerivedProperties(results, schema); // 8. 페이지네이션 const paginated = this.paginate(results, query.page_size, query.cursor); return { results: paginated.items, has_more: paginated.has_more, next_cursor: paginated.next_cursor, }; } // Relation 처리 async resolveRelations( rows: DatabaseRow[], property: RelationProperty, ): Promise<void> { const relatedIds = rows.flatMap( (row) => row.properties[property.id]?.relation || [], ); const relatedRows = await this.batchGetRows( property.relation_database_id, relatedIds, ); // 관계 데이터 매핑 const relatedMap = new Map(relatedRows.map((row) => [row.id, row])); rows.forEach((row) => { const relationIds = row.properties[property.id]?.relation || []; row.properties[property.id].resolved = relationIds.map((id) => relatedMap.get(id), ); }); } } 

Frontend 기술

블록 에디터

// Notion의 블록 에디터 컴포넌트 import { useEffect, useRef, useState } from "react"; import { Editor, Transforms, createEditor } from "slate"; import { useCollaboration } from "./collaboration"; interface BlockEditorProps { blockId: string; initialContent: any; onSave: (content: any) => void; } export function BlockEditor({ blockId, initialContent, onSave, }: BlockEditorProps) { const editor = useMemo(() => withNotion(createEditor()), []); const [value, setValue] = useState(initialContent); const collaboration = useCollaboration(blockId); // 협업 동기화 useEffect(() => { const unsubscribe = collaboration.subscribe((operation) => { if (operation.source !== "local") { // 원격 변경사항 적용 Editor.withoutNormalizing(editor, () => { operation.ops.forEach((op) => { editor.apply(op); }); }); } }); return unsubscribe; }, [collaboration]); // 로컬 변경사항 처리 const handleChange = (newValue: any) => { setValue(newValue); // 변경사항 감지 const operations = editor.operations; if (operations.length > 0) { // 협업 서버로 전송 collaboration.sendOperations(operations); // 디바운스된 저장 debouncedSave(newValue); } }; // 블록 타입별 렌더링 const renderElement = useCallback((props: any) => { const { element, children, attributes } = props; switch (element.type) { case "heading_1": return <h1 {...attributes}>{children}</h1>; case "code": return ( <CodeBlock {...attributes} language={element.language} onLanguageChange={(lang) => updateBlockProperty(element, "language", lang) } > {children} </CodeBlock> ); case "database": return ( <DatabaseView {...attributes} databaseId={element.databaseId} viewId={element.viewId} /> ); case "toggle": return ( <ToggleBlock {...attributes} isOpen={element.isOpen} onToggle={() => updateBlockProperty(element, "isOpen", !element.isOpen) } > {children} </ToggleBlock> ); default: return <p {...attributes}>{children}</p>; } }, []); // 슬래시 명령어 const handleKeyDown = (event: KeyboardEvent) => { if (event.key === "/") { showBlockMenu(); } // 블록 이동 단축키 if (event.metaKey || event.ctrlKey) { switch (event.key) { case "ArrowUp": moveBlockUp(); break; case "ArrowDown": moveBlockDown(); break; case "d": duplicateBlock(); break; } } }; return ( <Slate editor={editor} value={value} onChange={handleChange}> <Editable renderElement={renderElement} onKeyDown={handleKeyDown} placeholder="Type '/' for commands..." spellCheck autoFocus /> </Slate> ); } // Notion 특화 플러그인 function withNotion(editor: Editor) { const { insertData, insertText, deleteBackward } = editor; // 드래그 앤 드롭 지원 editor.insertData = (data: DataTransfer) => { if (data.files.length > 0) { // 파일 업로드 처리 Array.from(data.files).forEach((file) => { uploadFile(file).then((url) => { insertFileBlock(editor, url, file.type); }); }); return; } insertData(data); }; // 자동 포맷팅 editor.insertText = (text: string) => { // 마크다운 단축키 if (text === " ") { const [match] = Editor.nodes(editor, { match: (n) => n.type === "paragraph", }); if (match) { const [node, path] = match; const text = Node.string(node); // 헤딩 변환 if (text.match(/^#{1,3}/)) { const level = text.match(/^#+/)[0].length; Transforms.setNodes( editor, { type: `heading_${level}` }, { at: path } ); Transforms.delete(editor, { at: { path, offset: 0 }, distance: level + 1, }); return; } // 리스트 변환 if (text.match(/^[-*]/)) { Transforms.setNodes( editor, { type: "bulleted_list_item" }, { at: path } ); Transforms.delete(editor, { at: { path, offset: 0 }, distance: 2, }); return; } } } insertText(text); }; return editor; } 

실시간 협업

CRDT 기반 동기화

// Notion의 CRDT 구현 import { Doc, Text } from "yjs"; import { WebsocketProvider } from "y-websocket"; import { IndexeddbPersistence } from "y-indexeddb"; export class CollaborationManager { private doc: Doc; private provider: WebsocketProvider; private persistence: IndexeddbPersistence; private awareness: Awareness; constructor(pageId: string, userId: string) { this.doc = new Doc(); // 로컬 영속성 (오프라인 지원) this.persistence = new IndexeddbPersistence(pageId, this.doc); // WebSocket 연결 this.provider = new WebsocketProvider( "wss://notion-sync.com", pageId, this.doc, { params: { userId }, WebSocketPolyfill: ReconnectingWebSocket, }, ); // Awareness (커서, 선택 영역) this.awareness = this.provider.awareness; this.awareness.setLocalState({ user: { id: userId, color: generateUserColor(userId) }, cursor: null, }); } // 텍스트 동기화 syncText(blockId: string, callback: (text: string) => void) { const ytext = this.doc.getText(blockId); ytext.observe((event) => { callback(ytext.toString()); }); return { insert: (index: number, text: string) => { ytext.insert(index, text); }, delete: (index: number, length: number) => { ytext.delete(index, length); }, format: (index: number, length: number, attributes: any) => { ytext.format(index, length, attributes); }, }; } // 블록 트리 동기화 syncBlockTree(callback: (tree: BlockTree) => void) { const ytree = this.doc.getMap("blocks"); ytree.observe((event) => { const tree = this.buildBlockTree(ytree); callback(tree); }); return { addBlock: (parentId: string, block: Block) => { this.doc.transact(() => { ytree.set(block.id, block); const parent = ytree.get(parentId); if (parent) { parent.children = [...(parent.children || []), block.id]; ytree.set(parentId, parent); } }); }, moveBlock: (blockId: string, newParentId: string, index: number) => { this.doc.transact(() => { // 트랜잭션으로 원자적 이동 const block = ytree.get(blockId); const oldParent = this.findParent(blockId, ytree); const newParent = ytree.get(newParentId); if (oldParent) { oldParent.children = oldParent.children.filter( (id) => id !== blockId, ); ytree.set(oldParent.id, oldParent); } if (newParent) { newParent.children.splice(index, 0, blockId); ytree.set(newParentId, newParent); } block.parent = newParentId; ytree.set(blockId, block); }); }, }; } } 

오프라인 지원

Local-First 아키텍처

// 오프라인 우선 데이터 동기화 export class OfflineSync { private localDB: IDBDatabase; private syncQueue: SyncOperation[] = []; private isOnline: boolean = navigator.onLine; constructor() { // 온라인 상태 모니터링 window.addEventListener("online", () => this.handleOnline()); window.addEventListener("offline", () => this.handleOffline()); // Service Worker 등록 if ("serviceWorker" in navigator) { navigator.serviceWorker.register("/sw.js"); } } async saveLocal(operation: Operation) { const tx = this.localDB.transaction(["operations"], "readwrite"); const store = tx.objectStore("operations"); // 로컬 저장 await store.add({ ...operation, timestamp: Date.now(), synced: false, }); // 온라인이면 즉시 동기화 if (this.isOnline) { this.syncOperation(operation); } else { // 오프라인이면 큐에 추가 this.syncQueue.push(operation); } } private async handleOnline() { this.isOnline = true; // 대기 중인 작업 동기화 const pendingOps = await this.getPendingOperations(); for (const op of pendingOps) { try { await this.syncOperation(op); await this.markSynced(op.id); } catch (error) { if (error.code === "CONFLICT") { // 충돌 해결 await this.resolveConflict(op, error.serverVersion); } } } } private async resolveConflict(localOp: Operation, serverVersion: any) { // 3-way merge const baseVersion = await this.getBaseVersion(localOp.blockId); const merged = this.threeWayMerge(baseVersion, localOp, serverVersion); // 병합된 버전 저장 await this.saveLocal(merged); // UI에 충돌 알림 this.notifyConflictResolved(localOp.blockId, merged); } } // Service Worker self.addEventListener("fetch", (event) => { if (event.request.url.includes("/api/")) { event.respondWith( caches.match(event.request).then((cachedResponse) => { const fetchPromise = fetch(event.request).then((networkResponse) => { // 캐시 업데이트 if (networkResponse.status === 200) { const responseToCache = networkResponse.clone(); caches.open("notion-api-v1").then((cache) => { cache.put(event.request, responseToCache); }); } return networkResponse; }); // 캐시 우선, 네트워크 폴백 return cachedResponse || fetchPromise; }), ); } }); 

기술 스택 요약:

  • Frontend: React, TypeScript, Slate.js, Electron
  • Backend: Node.js, PostgreSQL, Redis
  • 실시간: WebSocket, Yjs (CRDT)
  • 검색: Elasticsearch
  • 인프라: AWS, Cloudflare
  • 모바일: React Native + Native

💡 핵심 기술 인사이트

1. 블록 기반 아키텍처의 힘

  • 무한한 확장성과 조합 가능성
  • 일관된 데이터 모델
  • 재사용 가능한 컴포넌트

2. 유연한 데이터베이스 시스템

  • 스키마리스 설계
  • 다양한 뷰 (테이블, 보드, 갤러리 등)
  • 강력한 필터링과 정렬

3. 로컬 우선 설계

  • 오프라인에서도 완전한 기능
  • 빠른 반응성
  • 데이터 소유권 보장

📈 성과 & 지표

기술적 성과

  • 페이지 로드: < 1초 (캐시된 경우)
  • 실시간 동기화: < 100ms 지연
  • 오프라인 지원: 100% 기능 가능
  • API 응답시간: P50 < 50ms
  • 검색 성능: < 200ms (수백만 블록)

비즈니스 영향

  • 사용자 증가: YoY 100%+
  • 기업 고객: Fortune 500 중 50%+
  • 개발자 생산성: 40% 향상 (자체 측정)
  • API 파트너: 1,000+ 통합

🎓 Notion에서 배울 점

✅ 적용 가능한 패턴

  1. 블록 기반 설계: 확장 가능한 콘텐츠 모델
  2. CRDT 협업: 충돌 없는 동시 편집
  3. 로컬 우선: 오프라인 지원 아키텍처
  4. 유연한 스키마: 동적 데이터베이스
  5. 점진적 향상: 기능별 단계적 로딩

❌ 주의사항

  1. 복잡한 데이터 모델: 블록 트리 관리
  2. 성능 최적화: 대규모 페이지 처리
  3. 검색 색인: 실시간 업데이트 부하
  4. 동기화 복잡성: 충돌 해결 로직

📚 추천 리소스


🔮 미래 전망

현재 집중 분야

  1. Notion AI: GPT 통합 작성 도우미
  2. Notion Calendar: 일정 관리 통합
  3. Notion Mail: 이메일 클라이언트
  4. 고급 자동화: Zapier 같은 워크플로우
  5. 엔터프라이즈 보안: SSO, 감사 로그

기술 투자 영역

  • WASM: 브라우저 성능 향상
  • 분산 시스템: 글로벌 확장
  • AI/ML: 스마트 추천, 자동 정리
  • 모바일 최적화: 네이티브 수준 경험
  • 개발자 플랫폼: 더 많은 API, SDK

"우리는 도구를 만드는 것이 아니라, 사람들이 더 나은 방식으로 생각하고 일할 수 있는 환경을 만들고 있습니다."

  • Notion Engineering

마지막 업데이트: 2025-01-28

Found this helpful? Share it with others!
Tweet

🔗 Related Content

You might also be interested in these articles

🏗️ stack

🔄 Realtime Collaboration Stack

Figma, Notion 같은 실시간 협업 앱 구축을 위한 기술 스택 - WebSocket, CRDT, WebRTC로 만드는 동시 편집 시스템

21 min read
nodejs, socketio+13
Read more
🏢 company

🏢 Figma 기술 스택 분석

피그마가 브라우저에서 실시간 협업 디자인 툴을 구현한 기술 스택 심층 분석 - C++, WebAssembly, WebGL로 만든 차세대 디자인 플랫폼

22 min read
figma, c+++11
Read more
🏢 company

🏢 Canva 기술 스택 분석

캔바가 1억 명 이상의 사용자에게 쉽고 강력한 디자인 도구를 제공하는 기술 스택 심층 분석 - Java, TypeScript, AWS로 구축한 대규모 디자인 플랫폼

32 min read
canva, java+10
Read more
🏢 company

🏢 Stripe 기술 스택 분석

스트라이프가 전 세계 수백만 비즈니스의 결제를 처리하는 기술 스택 심층 분석 - Ruby, Go, Java로 구축한 금융 인프라의 미래

33 min read
stripe, ruby+11
Read more
🏢 company

🏢 Airbnb 기술 스택 분석

에어비앤비가 전 세계 400만 개의 숙소를 연결하는 글로벌 마켓플레이스 기술 스택 심층 분석 - Ruby on Rails에서 Service-Oriented Architecture로의 진화

24 min read
airbnb, ruby+12
Read more
🏢 company

🏢 Discord 기술 스택 분석

디스코드가 전 세계 1.5억 명에게 실시간 음성/비디오 통화와 채팅을 제공하는 기술 스택 심층 분석 - Elixir, Rust, WebRTC로 구축한 차세대 커뮤니케이션 플랫폼

24 min read
discord, elixir+12
Read more

Found this helpful?

Help us improve this content by contributing on GitHub or sharing your feedback with the community.