Daily-It

개발, AI, 인프라, 자동화와 일상 IT 제품 후기를 직접 써보며 정리하는 기술 블로그입니다.

WatermelonDB 사용 후기: 다음 React Native 프로젝트에서는 이렇게 쓰고 싶다

React Native에서 로컬 데이터를 다루다 보면 처음에는 AsyncStorage나 단순 SQLite 래퍼로도 충분해 보인다. 그런데 화면이 늘고, 오프라인 상태에서도 데이터를 봐야 하고, 서버와 다시 맞춰야 하는 요구가 생기면 이야기가 달라진다. 그때 다시 보게 되는 도구가 WatermelonDB였다.

예전에 WatermelonDB를 소개하는 글은 적어둔 적이 있다. 이번 글은 개념 소개보다 직접 써보며 느낀 장점과 단점, 그리고 다음 프로젝트에서 어떻게 설정하고 시작할지에 가까운 후기다.

연관 글

내가 WatermelonDB를 단순 로컬 DB로 보지 않은 이유

백엔드와 회사 솔루션을 오래 만들다 보면 데이터는 결국 구조와 운영의 문제로 돌아온다. 처음에는 간단한 저장소처럼 보여도, 화면이 늘고 관계가 생기고 동기화가 들어오면 “어디에 저장할까”보다 어떤 기준으로 변경을 추적하고 다시 맞출 것인가가 더 중요해진다.

React Native에서도 마찬가지였다. 작은 앱이라면 AsyncStorage나 단순 SQLite 래퍼로 충분할 수 있다. 하지만 오프라인 상태에서도 데이터를 보고, 나중에 서버와 맞추고, 화면이 데이터 변경에 반응해야 한다면 처음부터 구조를 다르게 잡아야 한다. 그래서 WatermelonDB를 다음 프로젝트 후보로 다시 보게 됐다.

왜 WatermelonDB를 다시 보게 됐나

내가 WatermelonDB에 관심을 둔 이유는 단순했다. 앱에서 보여줘야 할 데이터가 점점 많아지고, 네트워크가 항상 안정적이지 않으며, 사용자가 오프라인에서도 어느 정도 작업을 이어가야 하는 상황이 생겼기 때문이다.

WatermelonDB 공식 문서는 이 도구를 “수백 개에서 수만 개 레코드까지 빠르게 다루는 React/React Native용 reactive database framework”로 설명한다. 내부적으로는 React Native에서 SQLite adapter를 사용하고, 웹에서는 LokiJS adapter를 쓰는 구조다. 핵심은 데이터를 전부 JavaScript 메모리에 올려놓는 방식이 아니라, 필요한 순간에 필요한 데이터만 lazy하게 가져온다는 점이다.

이 점은 백엔드 개발자 관점에서도 꽤 납득이 간다. 서버에서도 모든 데이터를 한 번에 메모리에 올리지 않는다. 필요한 조건으로 쿼리하고, 인덱스를 잡고, 변경 단위를 관리한다. WatermelonDB도 프론트엔드 로컬 DB를 그런 감각으로 다루게 해준다.

써보며 좋았던 점

1. 앱 시작 시점에 부담이 적다

WatermelonDB의 가장 큰 장점은 많은 데이터를 다룰 때 앱 시작 부담을 줄여준다는 점이다. 단순 상태 관리 + persistence 구조는 데이터가 작을 때는 편하지만, 레코드가 늘어나면 앱 시작 시점에 전체 데이터를 읽고 복원하는 비용이 커진다.

WatermelonDB는 필요한 데이터를 쿼리할 때 가져오고, SQLite 쪽에서 조건을 처리한다. 그래서 목록, 상세, 검색, 카운트처럼 화면별로 필요한 데이터만 다루는 구조를 만들기 좋다.

2. React 화면과 데이터 변경을 연결하기 좋다

WatermelonDB는 withObservables를 통해 모델이나 쿼리 결과를 화면에 반응형으로 연결할 수 있다. 데이터가 바뀌면 관련 화면이 다시 렌더링되는 흐름을 만들 수 있다.

처음에는 HOC 방식이 요즘 React 스타일과 조금 다르게 느껴질 수 있다. 그래도 로컬 DB 변경을 화면에 붙이는 구조 자체는 명확하다. “조회한 뒤 상태에 넣고, 수정 후 다시 refetch” 같은 코드를 매번 만들지 않아도 된다.

3. 오프라인 우선 구조를 설계하기 좋다

WatermelonDB는 로컬 DB일 뿐이고, 서버 동기화는 직접 구현해야 한다. 이게 단점이기도 하지만, 반대로 백엔드 개발자 입장에서는 장점이기도 했다. 동기화 프로토콜을 명확하게 설계할 수 있기 때문이다.

공식 sync 문서도 pullChanges, pushChanges, lastPulledAt, schemaVersion, migration 같은 개념을 전제로 한다. 서버가 어떤 변경분을 내려주고, 클라이언트가 어떤 변경분을 올릴지 명확히 정의해야 한다.

실제 수치로 보면 어느 정도 차이가 날까

공식 문서에는 WatermelonDB, SQLite 래퍼, AsyncStorage를 같은 조건에서 비교한 표준 벤치마크가 따로 정리되어 있지는 않았다. 오히려 WatermelonDB GitHub에는 WatermelonDB와 AsyncStorage를 비교하는 벤치마크가 필요하다는 이슈가 열려 있었다. 그래서 아래 수치는 공식 성능 보증이라기보다, 공개된 외부 벤치마크를 읽을 때 참고할 만한 정도로 봐야 한다.

React Native 저장소 get 성능 비교

Marc Rousavy의 StorageBenchmark는 React Native 0.68, Hermes, iPhone 11 Pro, Debug 빌드에서 단일 문자열 값을 가져오는 get 작업을 1,000번 반복한 결과를 공개하고 있다. 결과는 다음과 같았다.

저장소 1,000회 get 결과 비고
react-native-mmkv 12ms 가장 빠름
WatermelonDB 53ms AsyncStorage보다 빠르고, ORM/DB 계층이 있음
RealmDB 81ms 객체 DB
react-native-quick-sqlite 82ms SQLite 래퍼
AsyncStorage 242ms 단순 key-value 저장소

이 결과만 보면 WatermelonDB는 AsyncStorage보다 약 4.5배 빠르고, quick-sqlite보다도 빠르게 나온다. 다만 이 벤치마크는 단일 get 작업 반복 기준이다. 목록 쿼리, join에 가까운 relation 처리, 동기화, 대량 insert/update, release 빌드, Android 저사양 기기에서는 결과가 달라질 수 있다.

웹 기반 local-first 비교에서의 WatermelonDB

또 다른 참고 자료로 client-side-databases 프로젝트가 있다. 이 프로젝트는 Angular 기반 웹 채팅 앱을 여러 local-first DB로 구현해 비교한다. 여기서 WatermelonDB는 React Native SQLite adapter가 아니라 LokiJS adapter 기준이라, 내 React Native 사용 판단에 그대로 가져오기는 어렵다. 그래도 local-first 앱에서의 상대적인 감각은 볼 수 있다.

항목 WatermelonDB 비교상 눈에 띄는 점
First full render 275ms 표에 나온 후보 중 빠른 편
Insert one message 5ms 가장 빠른 축
20개 메시지 순차 insert 107ms Firebase 4639ms, PouchDB 241ms보다 빠름
Message insert to list change 4ms 화면 반응 측면에서 강점
Message search query time 23ms RxDB LokiJS 22ms와 비슷한 수준
Storage usage 2164kb 빠른 대신 저장공간은 작은 편은 아님

이 수치들을 보고 내가 내린 결론은 단순하다. AsyncStorage는 설정값이나 작은 key-value 저장에는 여전히 편하지만, 수천 건 이상의 목록/상세/검색/동기화를 다루는 앱의 메인 저장소로 쓰기에는 방향이 다르다. SQLite 래퍼는 직접 SQL을 제어하기 좋지만, relation, observe, sync 구조는 직접 더 많이 만들어야 한다. WatermelonDB는 그 중간에서 로컬 DB 설계와 React 화면 연결을 어느 정도 묶어주는 선택지로 볼 수 있다.

아쉬웠던 점과 조심할 부분

1. 설정이 가볍지는 않다

WatermelonDB는 “설치하고 바로 끝”이라는 느낌의 라이브러리는 아니다. React Native에서는 패키지 설치, decorator 설정, schema, migration, model, adapter, database 객체까지 차례대로 잡아야 한다.

특히 TypeScript, Babel decorator, React Native 버전, New Architecture, Expo 여부에 따라 신경 쓸 부분이 생긴다. GitHub 이슈를 보면 Expo SDK, New Architecture, Bridgeless Mode, Hermes 관련 질문이 계속 보인다. 즉, 프로젝트 환경이 최신 React Native 쪽으로 갈수록 먼저 호환성을 확인해야 한다.

2. 동기화는 결국 내가 설계해야 한다

WatermelonDB는 동기화 primitive와 adapter 흐름을 제공하지만, 백엔드 sync API는 직접 만들어야 한다. 서버는 pull 요청에서 변경된 레코드와 삭제된 ID를 내려줘야 하고, push 요청에서는 클라이언트 변경분을 트랜잭션으로 반영해야 한다.

여기서 충돌 처리를 가볍게 보면 나중에 고생한다. 공식 문서도 push 중 서버에서 같은 레코드가 바뀌면 push를 중단하고 다시 pull하도록 설명한다. “일단 마지막 값으로 덮어쓰기”로 시작하면 빠르지만, 업무 데이터에서는 위험할 수 있다.

3. schema와 migration을 대충 잡으면 나중에 힘들다

로컬 DB는 한번 배포되면 사용자 기기 안에 남는다. 서버 DB처럼 운영자가 직접 들어가서 고치기 어렵다. 그래서 WatermelonDB를 쓸 때는 schema version과 migration을 처음부터 진지하게 봐야 한다.

처음부터 모든 테이블을 완벽하게 만들 수는 없다. 하지만 최소한 다음은 정해두고 시작해야 한다.

  • 테이블 이름과 컬럼 이름 규칙
  • 서버 ID와 로컬 ID를 어떻게 매핑할지
  • 삭제를 hard delete로 할지, syncable delete로 처리할지
  • 나중에 추가될 컬럼의 기본값을 어떻게 넣을지
  • migration 실패 시 앱에서 어떻게 대응할지

다음 프로젝트에서는 이렇게 시작하겠다

다음 React Native 프로젝트에서 WatermelonDB를 쓴다면, 나는 기능 구현보다 먼저 데이터 경계를 정할 것 같다.

1. WatermelonDB가 맡을 데이터부터 제한한다

모든 데이터를 WatermelonDB에 넣지는 않을 것이다. 다음 기준에 맞는 데이터만 넣고 싶다.

데이터 유형 WatermelonDB 적합도 이유
오프라인에서도 봐야 하는 목록/상세 데이터 높음 로컬 저장과 빠른 조회가 중요함
서버와 주기적으로 동기화되는 업무 데이터 높음 pull/push 변경 추적 구조와 잘 맞음
단순 설정값, 토큰, 플래그 낮음 AsyncStorage나 secure storage가 더 단순함
임시 UI 상태 낮음 React state/Zustand/Recoil 등이 더 적합함
대용량 첨부 파일 낮음 파일 시스템/스토리지 경로만 DB에 저장하는 편이 나음

2. 설치와 기본 설정

공식 문서 기준으로 설치는 다음 흐름이다.

npm install @nozbe/watermelondb
npm install -D @babel/plugin-proposal-decorators

Babel 설정에는 decorator legacy 옵션이 필요하다.

{
  "presets": ["module:metro-react-native-babel-preset"],
  "plugins": [
    ["@babel/plugin-proposal-decorators", { "legacy": true }]
  ]
}

이미 React Native 프로젝트에서 다른 Babel 플러그인을 쓰고 있다면 순서 충돌이 없는지 확인해야 한다. 이 부분은 프로젝트마다 다르기 때문에 설치 후 바로 iOS/Android 빌드를 둘 다 확인하는 게 좋다.

3. schema와 migration 파일을 먼저 만든다

나는 model/schema.ts, model/migrations.ts를 먼저 만들고 시작하는 편이 좋다고 본다.

// model/schema.ts
import { appSchema, tableSchema } from '@nozbe/watermelondb'

export const mySchema = appSchema({
  version: 1,
  tables: [
    tableSchema({
      name: 'projects',
      columns: [
        { name: 'server_id', type: 'string', isIndexed: true },
        { name: 'name', type: 'string' },
        { name: 'updated_at', type: 'number' },
        { name: 'is_deleted', type: 'boolean' },
      ],
    }),
    tableSchema({
      name: 'tasks',
      columns: [
        { name: 'server_id', type: 'string', isIndexed: true },
        { name: 'project_id', type: 'string', isIndexed: true },
        { name: 'title', type: 'string' },
        { name: 'done', type: 'boolean' },
        { name: 'updated_at', type: 'number' },
      ],
    }),
  ],
})
// model/migrations.ts
import { schemaMigrations } from '@nozbe/watermelondb/Schema/migrations'

export default schemaMigrations({
  migrations: [
    // version 2부터 여기에 추가
  ],
})

server_id를 따로 두는 이유는 로컬 ID와 서버 ID를 분리하고 싶기 때문이다. 동기화 충돌이나 임시 생성 데이터를 다루다 보면 이 구분이 도움이 된다.

4. Model은 얇게 시작한다

Model에는 테이블명, 필드, relation 정도만 두고 비즈니스 로직을 너무 많이 넣지 않는 편이 좋겠다.

// model/Project.ts
import { Model } from '@nozbe/watermelondb'
import { field, children } from '@nozbe/watermelondb/decorators'

export default class Project extends Model {
  static table = 'projects'

  static associations = {
    tasks: { type: 'has_many', foreignKey: 'project_id' },
  } as const

  @field('server_id') serverId!: string
  @field('name') name!: string
  @field('updated_at') updatedAt!: number
  @field('is_deleted') isDeleted!: boolean

  @children('tasks') tasks!: any
}
// model/Task.ts
import { Model } from '@nozbe/watermelondb'
import { field, relation } from '@nozbe/watermelondb/decorators'

export default class Task extends Model {
  static table = 'tasks'

  static associations = {
    projects: { type: 'belongs_to', key: 'project_id' },
  } as const

  @field('server_id') serverId!: string
  @field('project_id') projectId!: string
  @field('title') title!: string
  @field('done') done!: boolean
  @field('updated_at') updatedAt!: number

  @relation('projects', 'project_id') project!: any
}

5. Database 객체를 한 곳에서 만든다

React Native에서는 SQLite adapter를 사용한다.

// model/database.ts
import { Database } from '@nozbe/watermelondb'
import SQLiteAdapter from '@nozbe/watermelondb/adapters/sqlite'

import { mySchema } from './schema'
import migrations from './migrations'
import Project from './Project'
import Task from './Task'

const adapter = new SQLiteAdapter({
  schema: mySchema,
  migrations,
  jsi: true,
  onSetUpError: error => {
    console.error('WatermelonDB setup failed', error)
  },
})

export const database = new Database({
  adapter,
  modelClasses: [Project, Task],
})

jsi: true는 성능상 유리하지만, 프로젝트의 React Native 버전과 디버깅 방식에 영향을 줄 수 있다. 공식 문서에서도 JSI를 쓰면 Chrome Remote Debugging 같은 방식이 맞지 않을 수 있다고 안내한다. 그래서 처음 설정할 때 디버깅 방법까지 같이 정해두는 게 좋다.

6. 쓰기는 반드시 writer 안에서 처리한다

WatermelonDB에서는 생성, 수정, 삭제를 database.write() 안에서 처리한다.

import { database } from './database'

export async function createProject(name: string) {
  return database.write(async () => {
    return database.get('projects').create(project => {
      project.serverId = ''
      project.name = name
      project.updatedAt = Date.now()
      project.isDeleted = false
    })
  })
}

여러 레코드를 한 번에 처리해야 하면 prepareCreate, prepareUpdate, batch를 고려해야 한다. 하나씩 await로 반복하면 코드가 단순해 보이지만, 데이터가 많아질수록 느려진다.

7. 조회는 필요한 조건만 쿼리한다

WatermelonDB 쿼리는 JavaScript 배열 필터링이 아니라 DB 쿼리로 실행된다. 그래서 처음부터 조건을 명확히 쓰는 습관이 좋다.

import { Q } from '@nozbe/watermelondb'
import { database } from './database'

export function observeProjectTasks(projectId: string) {
  return database
    .get('tasks')
    .query(
      Q.where('project_id', projectId),
      Q.where('done', false),
      Q.sortBy('updated_at', Q.desc),
    )
    .observe()
}

목록 화면에서 “일단 전체 fetch 후 JS에서 필터링”하는 습관은 피하고 싶다. 로컬 DB를 쓰는 이유가 줄어든다.

8. 동기화 API는 처음부터 따로 설계한다

WatermelonDB의 동기화는 클라이언트만으로 끝나지 않는다. 서버가 반드시 필요하다. 프론트엔드는 대략 이런 모양이 된다.

import { synchronize } from '@nozbe/watermelondb/sync'
import { database } from './database'

export async function sync() {
  await synchronize({
    database,
    pullChanges: async ({ lastPulledAt, schemaVersion, migration }) => {
      const response = await fetch(
        `https://api.example.com/sync?last_pulled_at=${lastPulledAt ?? 0}&schema_version=${schemaVersion}`,
      )

      if (!response.ok) {
        throw new Error(await response.text())
      }

      const { changes, timestamp } = await response.json()
      return { changes, timestamp }
    },
    pushChanges: async ({ changes, lastPulledAt }) => {
      const response = await fetch('https://api.example.com/sync', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ changes, lastPulledAt }),
      })

      if (!response.ok) {
        throw new Error(await response.text())
      }
    },
  })
}

다음 프로젝트에서는 서버 API도 같이 설계할 것이다.

  • GET /sync?lastpulledat=...
  • POST /sync
  • 서버 timestamp 기준 통일
  • 삭제 데이터는 ID 배열로 전달
  • push는 반드시 트랜잭션 처리
  • 충돌 시 409 또는 명확한 에러로 중단
  • 클라이언트는 실패 시 한 번 pull 후 retry

다음 프로젝트에서의 내 결론

WatermelonDB는 단순한 로컬 저장소가 아니다. 제대로 쓰려면 작은 로컬 DB 설계를 해야 한다. schema, migration, relation, query, writer, sync까지 함께 봐야 한다.

그래서 다음 프로젝트에서는 이렇게 쓰고 싶다.

  1. 오프라인이 꼭 필요한 데이터만 WatermelonDB에 넣는다.
  2. schema와 migration을 기능 개발 전에 먼저 잡는다.
  3. server ID와 local ID를 구분한다.
  4. 삭제 정책을 sync 기준으로 정한다.
  5. 목록 화면은 observe/query 중심으로 만든다.
  6. 대량 쓰기는 batch로 처리한다.
  7. sync API는 백엔드와 함께 처음부터 설계한다.
  8. Expo/New Architecture/Hermes/JSI 호환성은 프로젝트 시작 시 바로 확인한다.

WatermelonDB는 편한 도구라기보다, 제대로 쓰면 강한 도구에 가깝다. 단순 앱에는 과할 수 있다. 하지만 React Native에서 오프라인 우선 앱을 만들고, 수천~수만 건의 데이터를 빠르게 다뤄야 한다면 여전히 볼 가치가 있다.

내 기준에서는 다음 프로젝트에서도 후보에 올릴 만하다. 다만 이번에는 “나중에 동기화 붙이면 되겠지”가 아니라, 처음부터 동기화와 migration까지 포함해서 설계하고 싶다.

참고 자료