데이터베이스가 정말 필요한가
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 계산 결과
- 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 연결 기능 추가
-
Homepage
-
개발자
- 데이터베이스가 정말 필요한가