데이터베이스가 정말 필요한가

2 hours ago 2
  • 모든 데이터베이스는 결국 파일 시스템 위의 구조화된 파일 집합으로, 초기 단계의 애플리케이션은 직접 파일을 관리해도 충분한 성능 확보 가능
  • 동일한 서버를 Go, Bun, Rust로 구현해 파일 스캔·인메모리 맵·디스크 이진 탐색 세 가지 접근 방식을 비교한 결과, 단순 파일 접근만으로도 높은 처리량 달성 가능
  • 인메모리 맵 방식이 최고 성능(최대 169k req/s) 을 보였으며, SQLite는 25k req/s로 안정적이지만 오버헤드 존재
  • 대부분의 서비스는 SQLite 단일 파일로도 9천만 DAU 수준까지 처리 가능하며, 초기 제품 단계에서는 별도 데이터베이스가 불필요함
  • 데이터셋이 RAM을 초과하거나 조인·다중 조건 검색·동시 쓰기·트랜잭션이 필요한 시점부터 데이터베이스 도입이 필요함

데이터베이스가 정말 필요한가

  • 데이터베이스는 결국 파일 집합이며, SQLite는 단일 파일, PostgreSQL은 디렉터리와 프로세스로 구성됨
    • 모든 데이터베이스는 파일시스템에 읽기·쓰기하며, 코드에서 open()을 호출하는 것과 동일한 방식으로 동작
    • 따라서 핵심은 “파일을 쓸 것인가”가 아니라 “데이터베이스의 파일을 쓸 것인가, 직접 관리할 것인가”임
    • 초기 단계의 많은 애플리케이션은 직접 관리해도 충분한 성능 확보 가능

실험 구성

  • 동일한 HTTP 서버를 Go, Bun(TypeScript), Rust로 구현하고 두 가지 저장 전략을 비교
    • users.jsonl, products.jsonl, orders.jsonl 세 개의 JSONL 파일 사용
    • POST /users로 생성, GET /users/:id로 조회
    • 조회 경로(GET)만 벤치마크 대상으로 설정
  • 접근 방식 1: 매 요청마다 파일 읽기

    • 요청 시 파일을 열고 모든 줄을 스캔하며 JSON 파싱 후 ID 일치 여부 확인
    • 평균적으로 파일의 절반을 읽어야 하므로 O(n) 복잡도
    • 데이터가 커질수록 요청 처리 속도 급격히 저하
  • 접근 방식 2: 메모리에 전체 로드

    • 시작 시 전체 파일을 읽어 ID 기반 해시맵에 저장
    • 쓰기는 맵과 파일에 동시에 반영, 읽기는 단일 맵 조회로 O(1)
    • 파일은 영속 저장소 역할, 맵은 인덱스 역할 수행
    • Go의 sync.RWMutex, Rust의 RwLock으로 병렬 읽기 지원
  • 접근 방식 3: 디스크에서 이진 탐색

    • 모든 데이터를 RAM에 올리지 않고도 빠른 조회를 위한 중간 해법
    • ID 기준으로 정렬된 데이터 파일과 고정 폭 인덱스 파일(58바이트/레코드) 생성
    • ReadAt으로 인덱스를 O(log n) 탐색 후, 해당 오프셋에서 단일 레코드 읽기
    • 새로운 레코드 추가 시 정렬이 깨지므로 주기적 인덱스 재생성 또는 병합 필요
    • 이 병합 패턴은 LSM-tree의 동작과 유사

벤치마크 환경

  • 데이터셋 규모: 10k, 100k, 1M 레코드
  • 부하 도구: wrk, 10초간 4스레드·50동시 연결로 무작위 GET 요청 수행
  • 동일 머신(Apple M1 Mac mini, macOS 15)에서 Go 1.26, Bun 1.3, Rust 1.94로 테스트
  • Go에서는 추가로 이진 탐색(디스크)SQLite(modernc.org/sqlite) 비교

주요 결과

  • 선형 스캔 성능 저하: 1M 레코드에서 Go 23 req/s, Bun 19 req/s로 급격히 느려짐
  • 이진 탐색(디스크): 10k~1M 레코드 구간에서 45k→38k req/s로 15%만 감소
    • OS 페이지 캐시 효과로 상위 인덱스 영역이 항상 메모리에 유지
  • SQLite: 25k req/s, 평균 지연 2ms로 일관된 성능 유지
  • 이진 탐색이 SQLite보다 약 1.7배 빠름, 단순 PK 조회에서는 SQLite의 오버헤드 존재
  • 메모리 맵 방식이 최고 성능: 97k~169k req/s, 지연 0.5ms 이하
  • Bun이 Go보다 빠름: Bun 106k req/s, Go 97k req/s
    • Bun은 JavaScriptCore + Zig(uWebSockets) 기반으로 libuv를 우회
  • Rust가 선형 스캔에서 압도적: Go 대비 3~6배 빠름, JSON 파싱 및 I/O 효율 때문으로 추정
  • 사용 사례별 최적 선택

    • 절대 최고 처리량: Rust 인메모리 맵 (169k req/s)
    • RAM 비적재 조건에서 최고: Go 이진 탐색 (~40k req/s)
    • SQL 필요 시: SQLite (25k req/s)
    • 가장 간단한 구현: Go 선형 스캔 (~20줄 코드)

25,000 req/s의 의미

  • 일반 웹 트래픽은 피크:평균 = 2:1 비율 가정
    • 평균 12,500 req/s → 피크 25,000 req/s 수준
  • 활성 사용자가 시간당 10회 조회, 피크 시 동시 접속률 10%로 가정
    • 피크 요청 수식: DAU × 0.000278
  • 각 접근 방식의 포화 DAU 계산 결과
    • Go 선형 스캔: 2.8M
    • Go 이진 탐색: 144M
    • SQLite: 90M
    • Go 인메모리 맵: 349M
    • Bun 인메모리 맵: 381M
    • Rust 인메모리 맵: 608M
  • 대부분의 제품은 이 수치에 도달하지 않음
    • 예: 10,000명의 SaaS 고객 → 3 req/s, 100,000 DAU 앱 → 30 req/s
  • 결론적으로 대부분의 초기 제품은 데이터베이스가 필요하지 않음
    • 필요 시에도 SQLite 단일 파일로 9천만 DAU까지 처리 가능

데이터베이스가 필요한 시점

  • RAM에 데이터셋이 담기지 않을 때

    • 수천만 레코드 이상이면 인덱스만으로도 수GB 필요
    • 데이터 페이징이 필요하며, 데이터베이스가 이를 자동 처리
  • ID 외 필드로 조회가 필요할 때

    • 다중 조건 검색 시 파일 스캔 또는 추가 맵 필요
    • 여러 맵을 유지하면 사실상 쿼리 엔진을 직접 구현하는 셈
  • 조인이 필요한 경우

    • 여러 파일을 읽어 조합해야 하며, SQL이 더 효율적
  • 다중 프로세스 동시 쓰기 시

    • 각 인스턴스의 인메모리 맵이 분리되어 일관성 상실
    • 외부 단일 진실 소스 필요 → 데이터베이스 역할
  • 엔터티 간 원자적 쓰기 필요 시

    • 주문 생성과 재고 차감의 동시 성공/실패 보장 필요
    • 별도 트랜잭션 로그 구현이 필요하며, DB는 이를 ACID로 해결
    • 이러한 제약이 없는 내부 도구, 사이드 프로젝트, 초기 제품
    • 단일 서버 RAM 내에서 충분히 동작 가능
    • JSONL 파일은 이후 데이터베이스로 손쉽게 마이그레이션 가능

부록 및 코드 제공

  • Go, Bun, Rust 서버 코드 포함
  • 데이터 시드, 벤치마크 실행 스크립트(run_bench.sh) 별도 제공
  • ZIP 파일에는 go-server/, bun-server/, rust-server/, seed.ts 포함
  • 스크립트는 세 규모의 데이터를 시드하고, wrk로 부하 테스트 후 종료

DB Pro 관련 안내

  • DB Pro는 Mac, Windows, Linux용데이터베이스 클라이언트

    • 쿼리, 탐색, 관리 기능을 통합 제공
    • 협업형 웹 플랫폼 및 내장 AI 지원
    • 최신 버전에서는 Val Town의 SQLite 데이터베이스 연결 지원
    • v1.3.0에서는 데이터베이스 생성, 다중 쿼리 편집기, PlanetScale Vitess 연결 기능 추가
Read Entire Article