Electric SQL로 로컬 우선 앱 만들기: Postgres Shape 동기화, PGlite, 쓰기·충돌 전략
이 글의 핵심
Electric은 PostgreSQL에서 변경분을 읽어 Shape 단위로 클라이언트에 실시간 전달하는 동기화 엔진입니다. 읽기 경로는 Electric, 쓰기 경로는 API·낙관적 상태·로컬 DB 등 팀이 선택해 설계하는 구조를 이해하면 로컬 우선 앱을 안전하게 만들 수 있습니다.
이 글의 핵심
Electric SQL(이하 Electric)은 PostgreSQL 데이터의 변경을 추적해, 정의한 Shape(부분 집합) 단위로 클라이언트·엣지·다른 서비스에 증분으로 전달하는 오픈소스 동기화 엔진입니다. 로컬 우선(local-first) 앱에서 말하는 빠른 읽기와 오프라인 읽기를 가능하게 하는 읽기 경로 동기화에 초점을 맞추고, 쓰기를 Postgres에 반영하는 방식은 하나로 강제하지 않고 기존 백엔드와 조합하도록 설계되어 있습니다.
초기 ElectricSQL은 SQLite와 Postgres 사이의 양방향 동기화와 충돌 해결까지 제공하는 프로젝트였습니다. 2024년에 읽기 동기화에 집중하는 지금의 구조로 다시 작성되었기 때문에, 옛 글이나 예제에서 보이는 electrify() 같은 API는 현재 버전에 없습니다. 제품 구성과 패키지 API가 계속 바뀌고 있으므로 운영 전에는 Electric 공식 문서와 GitHub 저장소를 기준으로 확인하세요.
Electric SQL의 핵심 개념
Postgres를 진실 공급원으로 두는 읽기 동기화
Electric은 Postgres의 논리적 복제(logical replication)로 변경 스트림을 받아, 구독 중인 Shape에 해당하는 행·열의 변경을 클라이언트에 전달합니다. 그래서 대상 Postgres는 wal_level = logical로 설정되어 있어야 하고, Electric이 쓸 복제 권한을 가진 사용자가 필요합니다. 애플리케이션 입장에서는 서버 DB가 권위 있는 읽기 소스가 되고, 클라이언트는 동기화된 로컬 표현(메모리, SQLite, PGlite 등) 위에서 UI를 그립니다.
동기화 엔진과 앱 스택의 분리
최근 문서는 Postgres Sync를 중심에 두고, UI·상태 계층은 TanStack DB 등과 조합하는 구성을 보여 줍니다. Electric은 데이터 파이프라인의 한 구간이고, 라우팅·인증·비즈니스 규칙·쓰기 API는 기존처럼 백엔드에서 다룹니다.
책임 분리
| 영역 | Electric이 하는 일 | 애플리케이션·백엔드가 하는 일 |
|---|---|---|
| 읽기 | Postgres → 클라이언트 Shape 스트림 | Shape 범위 설계, 캐시 정책 |
| 쓰기 | 제공하지 않음 | API, 트랜잭션, 권한, 검증 |
| 보안 | Shape 요청을 그대로 처리 | 토큰 검증, 행 단위 접근 제어, 프록시 |
| 오프라인 | 읽기 스트림 재개, 오프셋 기반 이어 받기 | 쓰기 큐, 재시도, 충돌 UX |
Electric만 켠다고 오프라인 쓰기가 생기지는 않습니다. 쓰기 경로는 의도적으로 설계해야 하고, 이 표가 그 논의의 출발점입니다.
Shape 기반 동기화
Shape란?
Shape는 Postgres의 많은 데이터 중 이 기기·이 세션에 실어 나를 부분 집합을 정의하는 단위입니다. 테이블 전체를 내려받는 대신 필요한 행과 열만 동기화해 네트워크 사용량, 로컬 저장 공간, 개인정보 노출 범위를 함께 줄입니다.
Shape는 다음 요소로 정의됩니다.
| 요소 | 설명 |
|---|---|
table | 루트 Postgres 테이블(schema.table 형식 가능) |
where (선택) | 포함할 행을 고르는 SQL 표현식 |
columns (선택) | 동기화할 열 목록. 기본키 열은 반드시 포함해야 함 |
여러 클라이언트가 같은 Shape를 구독할 수 있고, 서로 다른 Shape가 같은 행을 포함할 수도 있습니다. 같은 정의의 Shape는 서버에서 한 번만 계산되어 로그로 저장되고 여러 구독자가 공유하므로, 사용자마다 조건이 조금씩 다른 Shape를 무수히 만들면 그만큼 서버의 Shape 로그도 늘어난다는 점을 염두에 두어야 합니다.
HTTP API로 구독하기
클라이언트는 GET /v1/shape에 쿼리 파라미터로 Shape를 지정합니다. 첫 요청은 현재 데이터의 스냅샷을 받고, 이후에는 응답 헤더로 받은 오프셋과 핸들을 붙여 그 지점 이후의 변경만 받습니다. 최신 상태에 도달하면 live=true 모드로 전환해 long-polling으로 새 변경을 기다립니다. 응답은 insert·update·delete 연산을 담은 Shape 로그이고, TypeScript 클라이언트가 이를 행 집합으로 재구성(materialize)해 줍니다.
이 방식은 평범한 HTTP GET이라 CDN에서 캐시할 수 있다는 장점이 있습니다. 같은 Shape의 같은 오프셋 구간 요청은 동일한 응답이므로, 많은 클라이언트가 같은 Shape를 처음 받을 때 Electric 서버까지 가지 않고 CDN에서 처리될 수 있습니다.
WHERE 절과 제약
WHERE는 Postgres에서 유효한 결정적(deterministic) 표현이어야 하고, now() 같은 비결정적 함수는 쓸 수 없습니다. 비교·논리 연산·LIKE 등 문서에 나열된 연산이 지원됩니다. 다른 테이블을 참조하는 서브쿼리 필터는 실험적 기능으로 제공되며 별도 기능 플래그가 필요할 수 있습니다.
사용자 입력을 WHERE에 넣을 때는 문자열 연결로 SQL을 조립하지 말고 $1 같은 위치 매개변수를 쓰고 값은 params로 분리해 전달해야 SQL 인젝션을 막을 수 있습니다.
컬럼 프로젝션과 처리량
columns로 동기화할 열을 줄이면 네트워크와 로컬 저장 비용이 줄어듭니다. 반대로 변경이 들어올 때마다 Electric은 그 변경이 각 Shape의 WHERE에 해당하는지 평가해야 하므로, Shape 수가 많고 필터가 복잡할수록 처리량에 영향을 줍니다. 문서는 인덱스처럼 빠르게 평가되는 WHERE 패턴(예: user_id = $1 같은 단순 동등 비교)을 따로 설명하고 있으니, 대용량 테이블이라면 Shape 경계를 이 패턴에 맞춰 설계하는 것이 좋습니다.
보안: Shape는 권한 모델이 아니다
Electric은 Shape를 동기화 경계로 제공할 뿐, 누가 어떤 Shape를 요청할 수 있는지는 판단하지 않습니다. 클라이언트가 임의의 table과 where를 조합할 수 있다면 애플리케이션의 권한 모델을 우회하는 통로가 됩니다. 그래서 공식 문서는 프로덕션에서 백엔드 API를 통해 Shape 요청을 검증하라고 반복해서 강조합니다. 인증된 사용자와 테넌트에 맞는 파라미터만 서버가 Electric에 전달하도록 프록시를 두는 것이 기본 구조입니다.
로컬 저장소: 메모리, SQLite, PGlite
로컬 DB의 역할
동기화된 데이터는 다음 중 하나 이상에 저장됩니다.
| 저장소 | 특징 |
|---|---|
| 메모리 | Shape 객체나 정규화 스토어. 단순하지만 새로고침하면 사라짐 |
| SQLite | 모바일·데스크톱 앱에서 널리 쓰이는 임베디드 DB |
| PGlite | Postgres를 WASM으로 빌드한 것으로, 브라우저·Node에서 Postgres 방언 그대로 사용 |
Electric 문서의 웹 예시는 PGlite에 동기화 데이터를 저장하는 흐름을 자주 보여 줍니다. PGlite는 브라우저에서 IndexedDB나 OPFS에 데이터를 영속화할 수 있어 새로고침 후에도 로컬 데이터가 남습니다. SQLite는 Capacitor·Tauri·네이티브 앱처럼 OS에 임베디드 DB가 자연스러운 환경에서 여전히 좋은 선택입니다.
어느 쪽을 고를까
오프라인 읽기 캐시를 디스크에 오래 유지해야 하고, 팀이 이미 SQLite와 그 전문 검색(FTS)·백그라운드 동기화에 익숙한 모바일 앱이라면 SQLite가 자연스럽습니다. 반대로 웹에서 Postgres의 타입과 쿼리를 그대로 쓰고 싶거나, 서버와 클라이언트의 SQL 방언을 하나로 맞추고 싶거나, Electric 예제와 TanStack DB 생태계를 그대로 따르고 싶다면 PGlite가 맞습니다. PGlite는 WASM 바이너리 크기가 수 MB 수준이라 초기 로딩 비용이 있다는 점도 함께 고려해야 합니다.
동기화 데이터와 로컬 쓰기 분리
서버에서 내려온 복제본과 사용자의 임시 쓰기를 같은 테이블에 섞으면, 서버 변경이 다시 들어올 때 어떤 행이 내 미확정 변경인지 구분하기 어려워 머지와 충돌 처리가 복잡해집니다. 공식 through-the-database 예제는 동기화 테이블, 로컬 쓰기용 섀도 테이블, 둘을 합쳐 보여 주는 뷰로 관심사를 분리합니다. 구조는 더 복잡해지지만 롤백과 리베이스를 명확하게 처리할 수 있습니다.
쓰기 전략과 충돌 처리
Electric이 읽기 동기화에 집중하므로, 여러 사용자가 동시에 같은 행을 고치는 문제는 애플리케이션의 쓰기 경로에서 다룹니다. 공식 Writes 가이드는 네 가지 패턴을 제시합니다.
온라인 쓰기
가장 단순한 방식입니다. 읽기는 Electric으로 빠르게 하고, 쓰기는 항상 API로 보내 응답을 기다립니다. 서버가 변경을 커밋하면 그 결과가 Shape 스트림으로 다시 들어와 화면에 반영됩니다. 오프라인 쓰기가 없으므로 충돌이 생길 여지가 작고, 대시보드나 관리 도구처럼 쓰기가 드물거나 서버 조율이 필수인 경우에 적합합니다. 단점은 쓰기 응답이 올 때까지 UI가 기다려야 한다는 점입니다.
낙관적 상태(Optimistic state)
React의 useOptimistic 등으로 UI를 즉시 갱신하고, API 호출이 끝난 뒤 서버 상태가 Shape로 다시 들어오면 낙관적 값을 버립니다. 체감 속도는 좋아지지만, 낙관적 상태를 컴포넌트 범위에만 두면 같은 데이터를 보여 주는 다른 화면에는 반영되지 않아 불일치가 생길 수 있습니다. 또 API 응답이 먼저 오고 Shape 업데이트가 나중에 오면 잠깐 이전 값이 보이는 깜빡임이 생기므로, 낙관적 값을 언제 버릴지(서버 데이터에 해당 변경이 보일 때) 기준을 정해야 합니다.
공유·영속 낙관적 상태
낙관적 쓰기를 Valtio, TanStack Query, 로컬 스토리지 같은 공유 저장소에 큐로 저장하고, Electric이 전달한 서버 데이터와 비교해 이미 반영된 변경은 큐에서 제거합니다. 문서는 다른 사용자의 변경이 스트림으로 들어왔을 때 로컬 낙관적 변경을 그 위에 다시 적용(rebase)하는 예를 설명합니다. 페이지를 새로고침해도 대기 중인 쓰기가 남아 있고, 여러 화면이 같은 상태를 보게 되므로 로컬 우선 SaaS에서 구현 복잡도와 사용성의 균형이 좋은 지점입니다.
DB를 통한 동기화(Through-the-database)
PGlite 등에 동기화 테이블, 로컬 섀도 테이블, 트리거, 변경 로그를 두고, 백그라운드 작업이 변경 로그를 서버 API로 재생합니다. 앱 코드는 로컬 DB에 쓰기만 하면 되므로 완전한 오프라인 쓰기에 가장 가깝지만, 스키마·롤백·서버 거절 처리가 가장 무겁습니다. 공식 예제의 롤백은 서버가 거절하면 로컬 변경을 전부 버리는 단순한 방식이므로, 실서비스에서는 어떤 변경이 왜 거절되었는지 사용자에게 보여 주고 일부만 되돌리는 처리를 직접 설계해야 합니다.
충돌 처리를 설계할 때
최종 판단은 Postgres의 제약 조건과 트랜잭션이 내리도록 하고, 클라이언트는 서버가 거절한 쓰기를 사용자에게 알리고 재시도하거나 되돌리는 UX를 갖춰야 합니다. 많은 업무 앱에서는 서버 타임스탬프 기준 최종 쓰기 우선(LWW)으로 충분하고, 같은 문서를 여러 사람이 실시간으로 편집하는 기능처럼 필드 단위 병합이 필요할 때만 CRDT 같은 별도 계층을 검토합니다. 스트림으로 들어온 다른 사람의 변경과 내 낙관적 변경이 겹쳤을 때 어느 쪽을 우선할지는 미리 정해 문서로 남겨 두어야 구현자마다 다르게 처리하는 일을 막을 수 있습니다. 실제 업무 앱에서 같은 행을 동시에 수정하는 경우는 생각보다 드문 편이므로, CRDT 도입 전에 제품 요구가 정말 그것을 필요로 하는지 먼저 확인하는 것이 좋습니다.
TypeScript 타입과 파싱
제네릭으로 행 타입 지정
@electric-sql/client의 ShapeStream·Shape와 @electric-sql/react의 useShape<T>에 행 타입을 제네릭으로 넘겨 컴파일 타임 검사를 받을 수 있습니다.
import { useShape } from '@electric-sql/react'
type ItemRow = {
id: string
title: string
status: 'active' | 'archived'
}
export function ItemList() {
const { data, isLoading, isError, error } = useShape<ItemRow>({
url: '/api/shape/items', // 프로덕션: 백엔드 프록시
params: {
table: 'items',
where: "status = 'active'",
columns: ['id', 'title', 'status'],
},
})
if (isLoading) return <p>불러오는 중…</p>
if (isError) return <p>동기화 오류: {String(error)}</p>
return (
<ul>
{data.map((row) => (
<li key={row.id}>{row.title}</li>
))}
</ul>
)
}
useShape는 data, isLoading, lastSyncedAt, isError, error 등을 돌려주므로 로딩·오류·마지막 동기화 시각을 UI에 반영하기 좋습니다. 제네릭은 타입 단언일 뿐이라, 서버 스키마와 ItemRow가 어긋나도 런타임에는 알려 주지 않는다는 점에 주의하세요.
electric-schema 헤더와 커스텀 파서
HTTP 응답에는 열 타입 정보를 담은 electric-schema 헤더가 포함되고, 기본 파서는 이를 보고 정수·불리언·실수 같은 일부 Postgres 타입을 JavaScript 값으로 변환합니다. 문서에 나열되지 않은 타입은 문자열로 남습니다. 예를 들어 timestamptz를 Date로, numeric을 원하는 decimal 라이브러리 객체로 받으려면 ShapeStream 옵션의 parser에 타입 이름별 변환 함수를 지정합니다. int8(bigint)은 JavaScript number의 안전 범위를 넘을 수 있으므로 기본적으로 BigInt로 파싱된다는 점도 알아 두어야 합니다.
스키마에서 타입 만들기
Electric이 테이블 타입을 자동 생성해 주지는 않으므로, 보통 Drizzle ORM이나 Prisma로 Postgres 스키마에서 TypeScript 타입을 만들고 Shape의 columns와 같은 열 집합을 고른 타입을 씁니다. 스트림 데이터를 Zod로 검증하는 레이어를 입구에 두면, 서버 마이그레이션으로 열이 바뀌었는데 클라이언트가 아직 옛 버전인 경우를 런타임에 잡을 수 있습니다. 이렇게 하면 서버 마이그레이션에서 클라이언트 타입으로 이어지는 단방향 흐름을 유지하기 쉽습니다.
React 통합
패키지와 훅
@electric-sql/react는 useShape, preloadShape, 스트림 캐시를 다루는 getShapeStream·getShape 등을 제공합니다. 라우트 로더에서 preloadShape로 미리 요청해 두면 컴포넌트가 마운트될 때 이미 데이터가 있어 첫 화면이 안정적으로 그려집니다.
개발과 프로덕션
문서 예시는 개발 편의를 위해 http://localhost:3000/v1/shape에 직접 붙지만, 프로덕션에서는 자사 API URL을 사용합니다. 프록시는 세션·JWT·테넌트 ID를 검증한 뒤 Electric에 넘길 table·where·params를 서버에서 정합니다.
// 클라이언트는 Shape 정의를 모르고, API가 사용자에 맞는 Shape를 결정
const { data } = useShape<TaskRow>({
url: `${import.meta.env.VITE_API_BASE}/sync/tasks`,
})
프록시는 Electric 응답의 본문뿐 아니라 오프셋·핸들 같은 electric-* 응답 헤더와 캐시 헤더도 그대로 전달해야 클라이언트가 다음 요청을 이어 갈 수 있습니다. 구현 시 스트리밍·오류 처리·헤더 전달은 Auth 가이드를 따르는 것이 안전합니다.
구독 해제와 AbortController
useShape에 AbortController의 signal을 넘기면 라이브 업데이트를 중단할 수 있습니다. 다만 같은 Shape의 스트림은 전역 캐시로 공유되므로, 한 컴포넌트에서 abort하면 같은 스트림을 쓰는 다른 구독자도 영향을 받습니다. 라우트·화면 단위로 Shape를 언제 열고 닫을지 정책을 정해 두어야 합니다.
TanStack Query·DB와의 조합
Electric은 읽기 스트림이고 TanStack Query는 서버 상태 캐시와 재시도에 강합니다. 쓰기 API는 Mutation으로 두고, 쓰기 후 쿼리를 무효화해 다시 가져오는 대신 Electric 스트림이 변경을 전달하게 하는 구성이 흔합니다. 최신 조합은 TanStack DB 연동 문서를 참고하세요.
오프라인 지향 앱 설계
아키텍처 개요
[PostgreSQL] --논리 복제--> [Electric Sync Service] --HTTP/Shape--> [백엔드 프록시] --> [클라이언트]
|
로컬 DB / 메모리 / UI
|
[PostgreSQL] <------------------ [백엔드 API] <---------REST/GraphQL--------- [사용자 쓰기]
읽기는 Electric이 서버 DB의 일관된 복제본을 제공하고, 쓰기는 API가 검증·권한·부수 효과를 처리합니다. 오프라인일 때 읽기는 로컬 캐시로 버티고, 쓰기는 앞에서 고른 패턴(큐와 재시도, 또는 through-the-database)으로 처리합니다. 네트워크가 돌아오면 클라이언트는 마지막으로 받은 오프셋부터 Shape를 이어 받으므로 전체를 다시 내려받지 않습니다.
로컬 개발 환경
브라우저는 HTTP/1.1에서 같은 호스트에 대한 동시 연결을 6개 정도로 제한합니다. Shape 하나가 live 모드에서 long-polling 연결을 하나씩 붙잡고 있으므로, Shape가 여러 개인 앱을 HTTP/1.1 개발 서버로 띄우면 연결이 바닥나 요청이 멈춘 것처럼 보입니다. 문서가 Caddy 같은 프록시로 로컬에서도 HTTP/2를 켜라고 권장하는 이유가 이것입니다. 그 밖에 느린 Shape는 트러블슈팅 가이드를 참고합니다.
운영에서 결정할 것
Shape는 테넌트·사용자·기간 조건으로 WHERE를 좁혀 필요한 만큼만 동기화하고, 클라이언트가 임의의 Shape를 요청하지 못하게 프록시를 둡니다. 쓰기 패턴은 온라인 쓰기, 낙관적 상태, 영속 낙관적 상태, 로컬 DB 중 요구사항에 맞는 것을 고르고, API가 쓰기를 거절하거나 버전이 충돌했을 때 사용자에게 무엇을 보여 주고 데이터를 어떻게 되돌릴지 정해 둡니다. Electric, Postgres 복제 슬롯, API의 지연과 오류율을 함께 모니터링해야 하는데, 특히 Electric이 멈춘 동안 복제 슬롯이 WAL을 붙잡아 Postgres 디스크가 차는 상황을 감시해야 합니다. 배포는 Electric Cloud와 셀프호스팅 중에서 고릅니다.
학습용 예제
공식 저장소의 examples에는 write-patterns, linearlite 등 참고할 만한 프로젝트가 있습니다. 특히 write-patterns 데모는 같은 앱에서 네 가지 쓰기 패턴을 나란히 비교할 수 있어 팀 학습에 좋습니다.