PowerSync로 로컬 우선 앱 만들기: 버킷과 Sync Rules, 로컬 SQLite, React Native, 충돌 해결

이 글의 핵심

PowerSync가 서버 데이터를 버킷 단위로 클라이언트 SQLite에 복제하는 방식, Sync Rules로 사용자별 데이터 범위를 정하는 법, React Native 통합과 쓰기 경로의 충돌 해결 패턴을 정리합니다.

이 글의 핵심

PowerSync는 클라이언트의 SQLite와 서버 데이터베이스 사이에서 증분 동기화를 제공하는 로컬 우선(local-first) 동기화 엔진입니다. 처음에는 PostgreSQL을 대상으로 시작했고 이후 MongoDB, MySQL이 추가되었으며, SQL Server 지원도 최근에 추가되었습니다(지원 상태는 공식 문서에서 확인하는 것이 좋습니다). 앱은 오프라인에서도 로컬 SQLite를 읽고 쓰며, 온라인이 되면 변경분을 서버와 맞춥니다.

모바일에서 화면마다 API를 호출하고 로딩 스피너를 띄우는 모델은 지하나 이동 중처럼 연결이 불안정한 환경에서 쉽게 무너집니다. 로컬 SQLite를 화면의 데이터 원천으로 쓰면 UI는 네트워크와 무관하게 즉시 반응하고, 서버와의 일관성은 동기화 레이어가 백그라운드에서 맞춥니다. PowerSync는 그 동기화 레이어를 제품으로 제공합니다.


PowerSync란 무엇인가

로컬 우선이 의미하는 것

로컬 우선은 사용자가 보는 데이터의 1차 저장소가 단말의 SQLite라는 뜻입니다. 읽기는 네트워크 왕복 없이 SQL 쿼리로 끝나고, 쓰기는 로컬에 먼저 반영된 뒤 업로드 큐를 통해 서버로 전달됩니다. 서버는 권한과 비즈니스 규칙을 적용해 자기 데이터베이스에 반영하고, 그 결과가 다시 동기화 스트림을 타고 모든 클라이언트로 내려옵니다.

구성 요소

역할설명
클라이언트 SDKSQLite 래핑, 동기화 세션, 감시 쿼리(watched queries), 쓰기 큐
PowerSync 서비스서버 DB의 변경을 읽어 Sync Rules에 따라 버킷으로 나누고 클라이언트에 스트리밍
백엔드 커넥터(클라이언트 코드)fetchCredentials()로 PowerSync 서비스 접속용 토큰을 받아 오고, uploadData()로 로컬 쓰기를 여러분의 백엔드 API에 전송
애플리케이션 백엔드업로드된 쓰기를 검증해 서버 DB에 반영하는 여러분의 API

중요한 점은 PowerSync 서비스가 읽기 방향(서버에서 클라이언트로)만 담당한다는 것입니다. 쓰기는 PowerSync 서비스를 거치지 않고 여러분의 API로 바로 갑니다. 그래서 쓰기 검증과 권한 검사는 기존 백엔드 로직을 그대로 쓸 수 있습니다.

기존 방식과의 차이

온라인 우선 앱은 화면마다 REST/GraphQL을 호출하고 응답을 캐시했다가 다시 요청하는 패턴이 일반적입니다. 로컬 우선에서는 캐시가 아니라 SQLite가 주 저장소이고 동기화는 백그라운드에서 계속 돌아갑니다. 캐시 무효화 시점을 화면마다 고민할 필요가 없어지는 대신, 어떤 데이터를 단말에 내려보낼지와 쓰기 충돌을 어떻게 처리할지를 설계해야 합니다.


핵심 개념: 버킷과 부분 복제

Sync Bucket

PowerSync는 데이터를 버킷이라는 단위로 나누어 클라이언트에 공급합니다. “특정 user_id의 주문 목록”이나 “특정 project_id의 이슈”처럼 앱 도메인에 맞는 단위로 정의합니다. 버킷은 Sync Rules의 파라미터와 연결되므로 사용자마다 받는 버킷 집합이 다릅니다. 같은 버킷을 받는 사용자들은 서버에서 한 번 계산된 버킷 데이터를 공유하므로, 사용자마다 쿼리를 따로 실행하는 방식보다 서버 부하가 작습니다.

부분 복제

전체 테이블을 단말에 복제하는 것은 모바일에서 대개 비현실적입니다. 부분 복제는 파라미터 쿼리와 데이터 쿼리로 이 단말에 필요한 행만 SQLite로 가져오는 방식입니다. 범위를 너무 넓게 잡으면 초기 동기화가 느려지고 단말 저장 공간을 낭비하며, 너무 좁게 잡으면 화면에 필요한 행이 빠져 버그가 납니다.

읽기 경로와 쓰기 경로

읽기는 서버 DB에서 PowerSync 서비스를 거쳐 클라이언트 SQLite로 내려옵니다(초기 스냅샷 후 증분). 쓰기는 클라이언트 SQLite에 반영된 뒤 업로드 큐에 쌓이고, uploadData()가 이를 여러분의 API로 보내 서버 DB에 반영되면 다시 읽기 경로를 타고 내려옵니다. UI는 항상 로컬 SQL만 보고, 네트워크는 백그라운드의 책임으로 남습니다.

로컬 쓰기는 서버에 업로드되고 그 결과가 동기화로 돌아올 때까지 로컬 값으로 보입니다. PowerSync는 업로드 큐가 비고 서버 체크포인트가 그 쓰기를 포함할 때까지 서버 데이터로 로컬 변경을 덮어쓰지 않으므로, 사용자가 방금 입력한 값이 잠깐 이전 값으로 되돌아가는 깜빡임이 생기지 않습니다.


Sync Rules

Sync Rules는 YAML로 작성하며, 누가 어떤 버킷을 받는지와 버킷 안에 어떤 행이 들어가는지를 선언합니다. 파라미터 쿼리(parameter query)와 데이터 쿼리(data query)의 조합이 핵심입니다.

bucket_definitions:
  # "내가 멤버인 프로젝트" 단위로 버킷을 나눔
  project_data:
    # 이 사용자에게 어떤 project_id 버킷들이 필요한지 결정
    parameters: |
      SELECT project_id
      FROM project_members
      WHERE user_id = request.user_id()
    # 각 버킷에 들어갈 행
    data:
      - SELECT * FROM issues WHERE project_id = bucket.project_id
      - SELECT * FROM issue_comments WHERE project_id = bucket.project_id

파라미터 쿼리는 이 사용자에게 어떤 project_id 버킷들이 필요한지 결정하고, 데이터 쿼리는 각 버킷에 채울 행을 고릅니다. request.user_id()는 클라이언트가 보낸 JWT의 sub 클레임에서 오므로 클라이언트가 임의로 바꿀 수 없습니다.

설계할 때 걸리는 제약이 몇 가지 있습니다. 데이터 쿼리는 버킷 파라미터를 모두 참조해야 하고, 데이터 쿼리 안에서 JOIN을 쓸 수 없습니다. 위 예에서 issue_comments를 issues와 조인해 프로젝트를 찾을 수 없으므로, 댓글 테이블에도 project_id 컬럼을 비정규화해 두어야 합니다. 서버 스키마를 설계할 때부터 동기화 단위의 키를 하위 테이블에 함께 두는 편이 나중에 덜 고생합니다.

버킷 단위가 너무 크면 한 번에 동기화되는 행이 많아 초기 동기화가 느려집니다. 반대로 사용자 한 명이 수천 개의 버킷을 받게 쪼개면 버킷마다 체크섬과 메타데이터를 관리하는 오버헤드가 커집니다. 공식 문서도 클라이언트당 버킷 수에 권장 상한을 두고 있으니 설계 단계에서 확인해야 합니다.

서버 스키마를 바꿀 때는 Sync Rules도 함께 바꿔야 할 수 있습니다. Sync Rules를 배포하면 PowerSync 서비스가 버킷을 다시 계산하고 클라이언트가 재동기화하므로, 데이터가 많은 서비스에서는 배포 타이밍을 계획해야 합니다.

PowerSync는 Sync Rules의 후속으로 Sync Streams라는 방식을 도입하고 있습니다. 새 프로젝트를 시작한다면 공식 문서에서 현재 권장 방식을 먼저 확인하고, 기존 Sync Rules 프로젝트는 마이그레이션 가이드를 참고하는 것이 좋습니다.


로컬 SQLite: 스키마와 쿼리

클라이언트 스키마

클라이언트 스키마는 PowerSync SDK의 Schema, Table, column 타입으로 선언합니다. 각 테이블에는 텍스트 id 컬럼이 자동으로 생기므로 직접 선언하지 않으며, 서버 테이블의 기본 키도 id라는 이름의 값으로 내려와야 합니다(Sync Rules에서 SELECT uuid AS id, ...처럼 별칭을 줄 수 있습니다). 컬럼 타입은 SQLite가 다루는 text, integer, real 세 가지입니다.

import { column, Schema, Table } from '@powersync/react-native';

const issues = new Table(
  {
    project_id: column.text,
    title: column.text,
    status: column.text,
    created_at: column.text,
  },
  { indexes: { by_project: ['project_id'] } }
);

export const AppSchema = new Schema({ issues });

클라이언트 마이그레이션이 필요 없는 이유

PowerSync는 동기화된 행을 내부 테이블에 JSON으로 저장하고, 위에서 선언한 스키마는 그 위에 만든 뷰로 노출합니다. 그래서 앱 버전업으로 컬럼을 추가하거나 빼도 클라이언트에서 마이그레이션 스크립트를 돌릴 필요가 없습니다. 새 컬럼은 서버가 이미 보내고 있었다면 바로 보이고, 스키마에서 뺀 컬럼은 데이터가 남아 있어도 뷰에서 보이지 않을 뿐입니다. 서버 쪽 스키마 변경과 Sync Rules 변경, 그리고 새 컬럼을 기대하는 앱 버전의 배포 순서를 맞추는 것이 실제로 신경 써야 할 부분입니다.

감시 쿼리

SDK는 특정 SQL의 결과가 바뀔 때 다시 실행되는 감시 쿼리를 지원합니다. 화면 단위로 SELECT를 걸어 두면 동기화나 로컬 쓰기로 관련 테이블이 바뀔 때 자동으로 새 결과가 전달됩니다. 동기화 이벤트를 직접 구독해 화면을 갱신하는 것보다 데이터 흐름이 단순해집니다.

감시 쿼리는 쿼리가 참조하는 테이블이 바뀌면 다시 실행되므로, 큰 테이블을 넓게 훑는 쿼리를 여러 화면에 걸어 두면 동기화 중에 재실행이 몰릴 수 있습니다. 화면에 필요한 컬럼과 행만 고르고, 자주 필터링하는 컬럼에는 인덱스를 선언해 두는 편이 좋습니다.

Expo 등 일부 환경에서 Async Iterator 기반 API를 쓰려면 폴리필과 Babel 플러그인이 필요합니다. 공식 React Native 문서의 Polyfills 절을 프로젝트에 맞게 적용해야 합니다.


React Native 통합

패키지 설치

Expo 기준으로는 다음과 같이 설치합니다. 버전은 프로젝트에서 고정해 두는 것이 좋습니다.

npx expo install @powersync/react-native @journeyapps/react-native-quick-sqlite

SQLite 어댑터로 OP-SQLite를 쓰는 @powersync/op-sqlite 패키지도 있으므로, 이미 쓰고 있는 SQLite 라이브러리와 빌드 파이프라인에 맞춰 선택합니다. 어느 쪽이든 네이티브 모듈이라 Expo Go에서는 동작하지 않고 개발 빌드(development build)가 필요합니다.

앱 부트스트랩

앱 시작 시 SQLite 파일 이름을 정하고(사용자별로 분리할지 포함), PowerSyncDatabase 인스턴스를 만든 뒤, 커넥터로 PowerSync 서비스에 연결하고, React 트리 상위에 Provider를 둡니다. 데이터베이스 인스턴스는 앱 전체에서 하나만 있어야 하므로 모듈 수준에서 만들어 두는 편이 안전합니다.

import React from 'react';
import { PowerSyncDatabase } from '@powersync/react-native';
import { PowerSyncContext } from '@powersync/react';
import { AppSchema } from './schema';
import { BackendConnector } from './connector';

export const db = new PowerSyncDatabase({
  schema: AppSchema,
  database: { dbFilename: 'app.sqlite' },
});

// 로그인 후 호출
export async function startSync() {
  await db.connect(new BackendConnector());
}

export function AppProviders({ children }: { children: React.ReactNode }) {
  return (
    <PowerSyncContext.Provider value={db}>
      {children}
    </PowerSyncContext.Provider>
  );
}

하위 컴포넌트에서는 @powersync/react의 useQuery('SELECT * FROM issues WHERE project_id = ?', [projectId])로 감시 쿼리 결과를 받고, useStatus()로 연결·동기화 상태를 읽습니다.

Expo와 네이티브 빌드 유의사항

expo-updates 등 다른 라이브러리가 자체 SQLite를 포함해 네이티브 심볼이 충돌하는 경우가 보고되어 있으며, 공식 문서는 이때 Podfile 설정으로 해결하는 방법을 안내합니다. Expo 없이 순수 React Native를 쓰는 경우에는 Metro의 inlineRequires 설정과 관련된 이슈가 있어 공식 문서의 metro.config.js 안내를 따라야 합니다. 이런 문제는 시뮬레이터 하나에서 확인하고 끝내면 다른 플랫폼에서 늦게 드러나므로, 초기에 iOS와 Android 실기기 빌드를 모두 돌려 보는 것이 좋습니다.

UI와의 연결

화면은 로컬 SQL 결과만 구독하게 두고, 동기화 진행 상태는 useStatus()로 별도 인디케이터에 표시합니다. 읽기는 항상 로컬 속도로 일어나고 동기화 상태는 부가 정보라는 구분을 지키면, 네트워크가 느릴 때도 화면 전환이 막히지 않습니다.


백엔드 커넥터와 서버 측 책임

변경 캡처

PowerSync 서비스는 서버 DB의 변경을 데이터베이스가 제공하는 복제 기능으로 읽습니다. PostgreSQL은 논리 복제(logical replication), MongoDB는 change streams, MySQL은 binlog를 사용합니다. 그래서 PostgreSQL이라면 wal_level = logical 설정과 복제 권한을 가진 사용자, publication이 필요하고, 관리형 DB에서는 이 설정이 허용되는지 먼저 확인해야 합니다.

셀프호스트와 클라우드

PowerSync Cloud를 쓰면 서비스 운영을 맡길 수 있고, 오픈 에디션으로 직접 호스팅할 수도 있습니다. 셀프호스트는 DB 복제 권한, 네트워크 경로, 모니터링을 직접 책임지는 대신 데이터가 외부 서비스를 거치지 않습니다. VPC 내부 DB나 감사 요구사항이 있다면 이 차이가 선택을 좌우합니다.

uploadData와 쓰기 경로

uploadData()는 클라이언트 커넥터에 구현하는 함수입니다. SDK는 로컬 쓰기를 PUT(삽입·전체 교체), PATCH(변경된 필드만), DELETE 작업으로 기록해 큐에 쌓고, 연결이 가능할 때 uploadData()를 호출합니다.

async uploadData(database: AbstractPowerSyncDatabase) {
  const transaction = await database.getNextCrudTransaction();
  if (!transaction) return;

  try {
    for (const op of transaction.crud) {
      // op.op: 'PUT' | 'PATCH' | 'DELETE', op.table, op.id, op.opData
      await api.applyChange(op.table, op.op, op.id, op.opData);
    }
    await transaction.complete();
  } catch (e) {
    if (isPermanentValidationError(e)) {
      // 서버가 영구적으로 거부한 쓰기: 기록해 두고 큐에서 제거
      reportRejectedWrite(transaction, e);
      await transaction.complete();
    } else {
      throw e; // 네트워크 오류 등: SDK가 나중에 다시 시도
    }
  }
}

uploadData()에서 예외를 던지면 SDK는 같은 트랜잭션을 나중에 다시 시도합니다. 서버가 검증 실패로 영구히 거부하는 쓰기에도 예외를 던지면 그 트랜잭션이 큐 맨 앞에 영원히 남아 뒤의 모든 쓰기가 막힙니다. 일시적 오류와 영구적 오류를 구분해, 영구적 오류는 기록을 남기고 complete()로 큐에서 빼는 처리가 반드시 필요합니다. 큐에서 뺀 쓰기는 서버 상태가 다시 동기화되면서 로컬에서도 사라지므로, 사용자에게 저장 실패를 알리는 UI도 함께 설계해야 합니다.

서버 쪽 API는 기존 백엔드처럼 트랜잭션·권한·비즈니스 규칙을 적용해 DB에 반영합니다. 서버 DB가 최종 기준이며, 반영된 결과는 변경 캡처를 통해 다른 단말로 퍼집니다.


충돌 해결

기본 동작

PowerSync 자체에는 충돌 병합 엔진이 없습니다. 쓰기는 큐 순서대로 여러분의 API에 도착하고, API가 적용하는 방식이 곧 충돌 정책입니다. 클라이언트가 PATCH에 변경된 필드만 담아 보내므로, 백엔드가 이를 UPDATE ... SET 변경된 필드만으로 적용하면 결과는 필드 단위의 마지막 쓰기 우선이 됩니다. 서로 다른 필드를 두 사용자가 고치면 둘 다 남고, 같은 필드를 고치면 서버에 나중에 도착한 쪽이 이깁니다. 오프라인으로 오래 있던 기기가 나중에 올라오면, 그 사이 다른 사람이 고친 값을 오래된 값으로 덮을 수 있다는 뜻이기도 합니다.

백엔드에서의 커스텀 처리

이 기본 동작이 맞지 않는 도메인이라면 업로드 API에서 직접 규칙을 구현합니다. 재고나 잔액처럼 덧셈이 의미 있는 값은 클라이언트가 계산한 최종값으로 덮지 말고, 로컬에 “증감 기록” 테이블을 두어 그 행을 업로드한 뒤 서버가 원자적으로 증감을 적용하게 합니다. 주문 상태처럼 허용된 전이만 있어야 하는 필드는 서버가 현재 상태를 확인해 잘못된 전이를 거부합니다. 오래된 쓰기가 최신 값을 덮는 것을 막고 싶다면 행에 버전 컬럼을 두고, 클라이언트가 읽은 버전과 서버 버전이 다르면 거부하거나 별도 충돌 기록을 남기는 방식도 가능합니다.

CRDT와 바이너리 상태

협업 문서나 리치 텍스트처럼 자동 병합이 필요하다면 Yjs 같은 CRDT 라이브러리를 쓰고, 문서 업데이트를 바이너리(PostgreSQL의 BYTEA 등) 행으로 동기화하는 패턴이 공식 데모로 소개되어 있습니다. 이 경우 PowerSync는 업데이트 데이터를 전달할 뿐이고, 의미 있는 병합은 CRDT 라이브러리가 클라이언트에서 수행합니다.

제품 관점

충돌 설계는 기술만의 문제가 아니라 도메인 규칙의 문제입니다. 누가 이기는지를 화면과 알림 정책까지 포함해 정하고, 잠금 표시나 “다른 사람이 편집 중” 같은 UX로 동시 편집 자체를 줄이면 해결해야 할 충돌이 크게 줄어듭니다.


모바일 앱 동기화 패턴

인증과 계정 전환

Sync Rules의 request.user_id()는 fetchCredentials()가 반환한 JWT에서 옵니다. 토큰이 만료되면 SDK가 fetchCredentials()를 다시 호출하므로, 이 함수는 항상 유효한 토큰을 돌려주도록 구현해야 합니다. 로그아웃하거나 계정을 바꿀 때는 db.disconnectAndClear()로 연결을 끊고 로컬 데이터를 지워야 이전 사용자의 데이터가 남지 않습니다. 이때 아직 업로드되지 않은 쓰기도 함께 사라지므로, 업로드 큐가 비어 있지 않다면 사용자에게 경고하는 것이 좋습니다.

초기 동기화

첫 로그인 직후에는 받아야 할 데이터가 가장 많습니다. useStatus()의 hasSynced로 첫 동기화 완료 여부를 확인해 그 전에는 빈 화면 대신 로딩 상태를 보여주고, 데이터가 큰 서비스라면 버킷 우선순위 기능으로 핵심 화면에 필요한 데이터를 먼저 받도록 구성할 수 있습니다.

테스트와 관측

동기화 버그는 재현하기 어렵습니다. 순수 SQL과 도메인 로직은 단위 테스트로, 업로드 처리는 임시 SQLite 파일과 가짜 API로 통합 테스트하고, 실제 기기에서 비행기 모드를 켜고 끄며 쓰기·재연결 시나리오를 확인합니다. 운영 중에는 업로드 실패 횟수, 큐에 남은 작업 수, 첫 동기화 완료까지 걸린 시간을 클라이언트에서 수집하고, 업로드 요청에 기기와 세션을 식별하는 값을 실어 서버 로그와 연결해 두면 사고 분석이 빨라집니다.


참고

  • PowerSync 공식 문서: https://docs.powersync.com/
  • 클라이언트 SDK 레퍼런스의 React Native/Expo 섹션
  • 공식 문서의 Demo Apps / Example Projects 갤러리

같이 보면 좋은 글