Lulucat

왜 우리는 아직 데이터베이스를 사용하지 않는가

Gaoge ZhangGaoge Zhang

iPad 손글씨 앱에 Turso/libSQL을 평가하고 모든 것을 측정한 뒤 플랫 스냅샷 파일을 선택했다. 이 워크로드에는 데이터베이스가 필요하지 않으며, 각 단계는 이미 존재하는 문제에 대해서만 비용을 지불해야 한다.

Lulucat Notes는 iPad용 손글씨 앱이다. 지난주까지는 캔버스 하나만 있었고 두 번째 노트라는 개념도 없었다. 이제 여러 문서를 다루고 각 문서에 여러 페이지를 담는 노트 라이브러리를 추가하려 했고, 가장 먼저 나온 아키텍처 질문은 저장소였다.

데이터베이스가 당연한 답처럼 느껴졌다. 노트 앱은 구조화된 데이터를 저장한다. 구조화된 데이터는 데이터베이스에 넣는다. 우리는 Turso와 Swift SDK를 평가하고, iOS 시뮬레이터에서 실행하고, 실제 스트로크 데이터로 벤치마크한 뒤 사용하지 않기로 했다.

대신 플랫 파일을 선택했다. 무엇을 알아냈고 왜 그렇게 결정했는지 설명하겠다.

나무 책상 위에 손글씨 메모와 펜이 놓인 펼쳐진 노트.

Gabriel CoxUnsplash에 올린 사진. Unsplash License.

앱이 실제로 데이터에 하는 일

손글씨 앱의 데이터 접근 패턴은 좁고 예측 가능하다. 읽기는 페이지를 열고 그 안의 모든 요소 — 모든 스트로크와 모든 이미지 — 를 한꺼번에 메모리에 올리는 일이다. 캔버스는 모든 것을 담고 있으며 부분 쿼리를 실행하지 않는다. 쓰기는 펜 스트로크를 끝내고 페이지에 요소 하나를 추가하는 일이다. 드물게 사용자가 스트로크의 일부를 지우거나 선택 영역을 옮기거나 무언가를 삭제할 때도 있지만, 그것 역시 한 페이지, 한 요소 단위의 작업이다.

동시 접근은 없다. 한 사람이 한 문서의 한 페이지에 한 번에 쓴다. 문서 간 검색도 없다. 노트 라이브러리에 필요한 것은 문서마다 제목, 타임스탬프, 페이지 수, 표지 썸네일뿐이며, 어느 것도 페이지 내용을 읽을 필요가 없다.

쿼리, 인덱스, 동시성 조정은 데이터베이스가 만들어진 이유다. 우리 앱은 셋 중 어느 것도 사용하지 않는다.

Turso 평가

Turso의 libSQL 엔진을 위한 공식 Swift SDK인 libsql-swift를 평가했다.

SDK는 작동한다. 9개의 테스트 케이스가 모두 통과한다. 앱 복사본에 통합하고, iOS 시뮬레이터용으로 빌드하고, 실행한 다음, 앱 샌드박스에 로컬 데이터베이스를 만들었다. 한 번의 트랜잭션에서 샘플링 포인트 3,400개씩을 가진 스트로크 100개를 작성했다. BLOB 데이터는 4,080,000바이트였다. 개발용 Mac에서는 약 0.019초가 걸렸다.

PRAGMA wal_checkpoint(TRUNCATE)를 실행하자 WAL 파일은 0으로 줄었다. 그래서 메인 .db 파일만 다른 위치에 복사하고, 열어서, 모든 데이터를 다시 읽을 수 있었다. 엔진 자체는 견고하다.

SDK에는 비용이 있다. CLibsql.xcframework는 161 MB다. 링크한 뒤 Debug 시뮬레이터 빌드는 약 1.9 MB에서 약 8.2 MB로 늘었다. API는 동기식이고 블로킹 방식이며 Swift Concurrency 래퍼가 없다. 명시적인 close() 메서드도 없다. Transaction.commit()은 오류를 던지지 않는다. 기반 C API가 void를 반환하기 때문이다. 저장소의 README는 이 SDK를 “technical preview”라고 표시하고 있으며, 가장 최근 커밋은 우리가 평가하기 약 1년 전인 2025년 7월이었다.

Turso 생태계에는 빈틈이 있다. Turso는 이제 새 프로젝트에 새로운 “Turso Database” 엔진과 “Turso Sync” 프로토콜을 권장한다. Turso Sync에는 TypeScript, Python, Go, Rust용 클라이언트 SDK가 있다. Swift용 SDK는 없다. 더 오래된 Embedded Replica 모드는 libsql-swift에 있지만, Swift initializer가 완전히 local-first인 모바일 앱에 필요한 offline 매개변수를 노출하지 않는다. 지금 libsql-swift를 도입하면 로컬 SQLite 포크는 얻지만, Turso를 특별하게 만드는 동기화 기능은 얻지 못한다.

지금 데이터베이스가 우리에게 치르게 할 비용

SDK가 성숙했다고 해도 우리 워크로드에는 아무것도 사 주지 않는 비용을 계속 치러야 한다.

WAL 사이드카 관리. 실행 중인 데이터베이스는 -wal-shm 동반 파일을 만든다. 문서를 복사하려면 먼저 체크포인트를 실행하거나 세 파일을 모두 원자적으로 복사해야 한다. .lnote 패키지를 Files나 AirDrop으로 내보내려면 사용자가 볼 수 없고 개발자가 잊을 수도 없는 사전 내보내기 단계가 필요해진다.

어댑터 계층. 스트로크를 BLOB로 직렬화했다가 다시 역직렬화해야 한다. 페이지 요소에는 캔버스가 그대로 렌더링할 수 있는 자연스러운 배열 순서가 있다. 데이터베이스를 사용하면 행 순서와 z-index 열이 새로 필요해진다. 같은 데이터를 표현하는 두 방식 사이에 변환 계층을 작성하고, 스키마가 바뀔 때마다 유지 보수해야 한다.

161 MB 의존성. Debug 빌드가 2 MB 미만인 앱에서 앱 자체보다 80× 이상 큰 의존성은 눈여겨볼 비용이다. 특히 “technical preview”로 표시된 채 1년 동안 활동이 없는 의존성이라면 더 그렇다.

이 비용들은 가설이 아니다. 의존성을 링크하는 순간 시작된다. 그리고 우리 앱이 사용하지 않는 기능 — 쿼리, 인덱싱, 동시 쓰기 — 을 사는 대가다.

우리가 출시한 해법: 스냅샷 파일 패키지

.lnote 문서는 디렉터리 패키지다.

Documents/Notes/<UUID>.lnote/
  manifest.json              # library cache: title, time, page count, cover
  document.json              # source of truth: document metadata + page order
  pages/
    <page-uuid>.content      # one snapshot per page
  assets/                    # document-level shared resources
    <asset-uuid>.jpg
  thumbnails/
    <page-uuid>.jpg          # per-page thumbnail; first page doubles as cover

document.json은 문서 구조의 정본이다. 문서의 ID, 제목, 타임스탬프, 그리고 각 페이지의 캔버스 크기·타임스탬프·요소 수를 담은 정렬된 페이지 목록이 들어 있다. 페이지 콘텐츠 파일은 앱이 이미 사용하는 것과 같은 양자화 정수 인코딩으로 요소 배열을 저장한다. 좌표와 반지름은 0.1포인트 정밀도이고, 압력은 1,000분의 1 단위이며, 타임스탬프는 상대 밀리초다.

노트 라이브러리는 manifest.json과 표지 썸네일만 읽는다. document.json이나 어떤 페이지 콘텐츠도 파싱하지 않는다. 페이지를 열면 .content 파일 하나를 로드한다. 스트로크 데이터에 닿는 파일 읽기는 이것뿐이다.

페이지가 write amplification을 해결한다

손글씨 앱에는 이미 페이지라는 개념이 있다. 사용자가 생각하는 단위이고, 스와이프해서 이동하는 대상이다. 페이지를 영속성 단위로 삼으면 자동 저장은 바뀐 페이지만 다시 쓴다.

손글씨 한 페이지 — 예를 들어 스트로크 1,0002,000개 — 는 양자화 포맷에서 약 35 MB를 차지한다. 샘플링 포인트 3,400개가 있는 스트로크 21개짜리 기록 하나는 양자화하면 약 55 KB다. 최신 하드웨어에서 페이지 스냅샷 하나를 플래시 저장소에 쓰는 데는 10~20밀리초가 걸린다. 0.5초 디바운스를 사용하면 저장은 사용자에게 보이지 않는다.

저장 비용은 문서의 전체 페이지 수가 아니라 현재 페이지에 쓰인 양에 따라 커진다. 200페이지 노트북도 2페이지 노트북만큼 빠르게 저장된다. 더티 페이지 하나만 다시 쓰기 때문이다.

모든 쓰기는 원자적 파일 연산을 사용한다. 임시 파일에 쓰고 이름을 바꾼다. 따라서 저장 중간에 충돌이 나도 잘린 페이지가 만들어질 수 없다. 백그라운드로 들어가면 더티 페이지를 모두 즉시 flush한다. 캔버스 하나만 있던 시절부터 앱이 해 오던 동작과 같다.

트랜잭션 없는 일관성

파일 패키지에는 트랜잭션이 없지만 같은 목적을 이루는 명확한 소유권 규칙이 있다.

참조보다 리소스를 먼저. 사용자가 이미지를 삽입하면 에셋 파일을 즉시 assets/에 쓴다. ID로 에셋을 참조하는 페이지 스냅샷은 디바운스된 자동 저장이 나중에 쓴다. 페이지가 디스크에 없는 에셋을 참조하는 순간은 없다.

정본이 이긴다. document.jsonpages/ 디렉터리가 정본이다. manifest.json은 캐시다. 둘이 다르면 다음 저장에서 캐시를 정본에 맞게 조정한다. 썸네일은 파생 데이터이므로 언제든 다시 생성할 수 있다.

매달린 참조보다 고아가 낫다. 충돌의 최악의 결과는 고아 에셋이다. assets/에 있지만 어떤 페이지도 참조하지 않는 파일이다. 고아는 문서를 닫을 때 정리한다. 반대의 경우, 즉 페이지가 사라진 파일을 참조하는 일은 일어날 수 없다. 참조하는 페이지 스냅샷보다 먼저 에셋을 쓰기 때문이다.

이 규칙들은 WAL 체크포인트와 트랜잭션 격리보다 생각하기 쉽고, 앱의 단일 프로세스·단일 페이지 접근 패턴에도 정확히 맞는다.

업그레이드 경로는 기록되어 있다

지금 플랫 파일을 선택한다고 해서 영원히 플랫 파일을 선택하는 것은 아니다. 패키지 구조는 스토리지 엔진을 업그레이드할 때 패키지 자체는 바꾸지 않고 그 안의 내용만 바꿀 수 있도록 설계했다.

레벨 1: 스냅샷 + append journal. write amplification이 언젠가 눈에 띄게 된다면 — 예를 들어 스트로크 수천 개가 있는 페이지에 계속 쓰면서 저장 지연이 느껴진다면 — 각 페이지 파일을 스냅샷과 append-only journal로 나눈다. 새 요소는 [length][CRC][type][payload] 프레임으로 추가한다. journal을 재생할 때 CRC가 맞지 않는 프레임은 버리므로 충돌 안전성을 얻는다. journal이 임계값을 넘거나 페이지를 닫으면 스냅샷에 다시 병합한다. 외부 의존성 없이 약 200줄의 코드로 가능하다.

페이지 수준 스냅샷이 이미 페이지 간 write amplification을 없애므로 이 단계는 오랫동안 필요하지 않을 수도 있다. 5 MB 페이지를 0.5초마다 다시 쓰는 것은 플래시 쓰기 예산 안에 충분히 들어온다.

레벨 2: SQLite 데이터베이스. 앱에 언젠가 노트 전체 텍스트 검색, 요소 단위 동기화, 문서 간 인덱싱이 필요해진다면 SQLite가 맞는 도구가 된다. 그때 유력한 엔진은 GRDB일 것이다. 성숙하고 소스에서 컴파일되는 Swift 래퍼이며 바이너리 크기 오버헤드가 거의 없다. Turso 생태계, 특히 Swift용 Turso Sync가 실제 제품 요구가 될 때에만 libsql-swift를 다시 검토한다.

마이그레이션 경로는 기계적이다. 각 페이지의 요소 배열을 strokes / images 테이블로 매핑하고, 스트로크마다 변경할 수 없는 BLOB 하나를 둔다(리틀엔디언 바이너리에서 샘플링 포인트 하나당 16바이트). 100개 스트로크에 대한 0.019초 벤치마크는 이 접근이 실현 가능하다는 것을 확인한다. 출시 전에 마이그레이션은 이전 패키지와 새 데이터베이스의 요소 수와 에셋 수가 일치하는지, 충돌 복구·WAL 한도·백그라운드 flush가 모두 통과하는지 검증한다.

언제 비용을 지불할 것인가

이 결정은 데이터베이스에 대한 판단이 아니다. SQLite는 공유 상태에서 동시 쓰기, 복잡한 쿼리, 충돌 복구를 처리한다. 하지만 현재 우리 앱에는 그 어느 것도 필요하지 않다. 앱이 아직 겪지 않은 문제를 해결하는 기능에 미리 비용을 지불하는 것은 순손실이다.

데이터베이스 비용 — 의존성, WAL 관리, 어댑터 계층, 바이너리 크기 — 은 라이브러리를 링크하는 순간 시작된다. 앱에 실행할 쿼리, 유지할 인덱스, 조율할 동시 쓰기가 생길 때 이점이 시작된다. 지금 단계에서는 셋 중 어느 것도 없다.

스토리지 진화의 각 단계는 이미 나타난 문제에 대해서만 비용을 지불한다. 페이지 수준 스냅샷은 오늘 우리가 가진 문제, 즉 전체 파일을 다시 쓰지 않고 여러 페이지 문서를 저장하는 문제에 비용을 지불한다. write amplification을 측정할 수 있게 되면 append journal이 그 문제에 비용을 지불한다. 검색이나 동기화가 제품 요구가 되면 데이터베이스가 그 문제에 비용을 지불한다.

패키지 구조, manifest, 문서 스키마는 어떤 스토리지 엔진에도 묶여 있지 않다. 경계가 올바른 곳에 있기 때문에 전환 비용이 낮다. 데이터베이스가 정말 필요한 날이 오면, 가상의 문제가 아니라 이미 측정된 구체적인 문제를 위해 도입할 것이다.