🏢 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.