ElectricSQL Shapes API로 PostgreSQL을 브라우저에 실시간 동기화하기 — Local-First 웹 앱에서 오프라인 쓰기와 서버 DB를 연결하는 sync-engine 패턴
실시간 기능을 WebSocket으로 직접 구현하다 보면 어느 순간 재연결 로직이 비즈니스 로직의 두 배가 된 파일을 마주하게 됩니다. 연결 상태 추적, 오프라인 큐잉, 재연결 시 상태 재조정 — 이 인프라성 코드가 서비스의 핵심 기능을 잠식합니다.
ElectricSQL은 "읽기 경로 동기화를 PostgreSQL 논리 복제 + HTTP Long-Polling으로 표준화하고, 개발자는 비즈니스 로직에만 집중하도록" 설계되었습니다. 이 글에서는 Shapes API의 동작 원리, 읽기/쓰기 경로 분리와 인가 처리, 오프라인 쓰기를 서버 DB와 연결하는 4가지 패턴을 다룹니다.
한 가지 짚고 넘어갈 것이 있습니다. "자동 병합"이라는 표현은 CRDT나 충돌 해결 알고리즘을 연상시키지만, Electric이 실제로 제공하는 것은 다릅니다. Electric은 읽기 경로를 자동화합니다 — Postgres 변경을 클라이언트에 스트리밍하는 부분. 쓰기 경로는 개발자가 직접 선택한 패턴으로 설계하고, 그 쓰기가 Postgres에 반영되면 Electric이 클라이언트로 전파합니다. 이 두 경로의 결합이 "오프라인 쓰기와 서버 DB를 연결한다"는 의미입니다. 충돌 해결은 서버 API의 몫으로 남습니다.
핵심 개념
ElectricSQL과 논리 복제
ElectricSQL(이하 Electric)은 PostgreSQL과 브라우저/클라이언트 사이에 위치하는 오픈소스 sync 엔진입니다. PostgreSQL의 논리 복제(logical replication) 기능을 통해 WAL(Write-Ahead Log)을 구독하고, DB 변경이 발생하면 클라이언트에 스트리밍합니다. Elixir로 구현되었으며 Apache 2.0 라이선스로 self-hosted가 가능합니다.
읽기는 Electric 경로(인가 프록시를 경유해 — 아래에서 자세히 다룹니다), 쓰기는 기존 API가 담당합니다. 이 경로 분리 덕분에 기존 REST API나 GraphQL을 건드리지 않고 sync 레이어만 추가할 수 있습니다.
Shapes: 동기화의 핵심 단위
Shape는 Electric에서 동기화의 기본 단위입니다. 특정 테이블의 행 부분집합을 선언적으로 정의하는 구독 개념으로, SQL WHERE 절로 범위를 지정합니다.
"로그인한 사용자의 todos"를 구독하는 요청은 다음과 같습니다:
GET /v1/shape?table=todos&where=user_id%3D%27abc-123%27&offset=-1offset=-1은 "처음부터 shape 로그 전체를 주세요"라는 의미입니다. 서버는 shape log 형태로 응답하며, 로그가 클 경우 electric-offset 헤더로 다음 커서 위치를 반환합니다. 이후 요청에서 이 오프셋을 이어서 사용하는 방식으로 증분 동기화가 이루어집니다.
Shape의 범위를 너무 크게 잡으면 초기 로드 비용이 커집니다.
WHERE절로 필요한 데이터만 구독하는 것이 설계의 핵심입니다. 단,WHERE절 범위 제한은 성능 최적화이지 보안 인가 경계가 아닙니다. 인가 처리는 반드시 서버 사이드에서 이루어져야 하며, 이는 다음 섹션에서 다룹니다.
HTTP Shapes API 동작 방식
Electric의 실시간 스트리밍은 WebSocket이 아닌 HTTP Long-Polling 방식으로 동작합니다. ?live=true 파라미터를 붙이면 서버가 커넥션을 유지한 채 새 데이터가 도착할 때까지 대기합니다.
응답을 받으면 클라이언트는 즉시 다음 long-poll 요청을 보냅니다. 이 루프가 끊기지 않고 반복되면서 실시간 스트림이 유지됩니다. 이 구조 덕분에 별도 WebSocket 인프라 없이 표준 HTTP만으로 실시간 스트림을 구현할 수 있고, CDN 캐싱도 가능하며 기존 모니터링 도구를 그대로 활용할 수 있습니다.
React 클라이언트에서는 @electric-sql/react 패키지의 useShape 훅이 이 모든 로직을 추상화해줍니다. 컴포넌트가 마운트되는 순간 shape 구독이 시작되고, Postgres에서 변경이 발생하면 data가 자동으로 업데이트됩니다. 재연결 로직, 오프셋 관리, 상태 업데이트 모두 훅 내부에서 처리됩니다.
읽기/쓰기 경로의 분리와 인가 처리
Electric은 읽기 경로(PostgreSQL → 클라이언트)만 담당합니다. 쓰기는 Electric이 처리하지 않습니다. 기존 REST API, GraphQL, 서버 액션 등을 그대로 유지하면서 sync 레이어만 추가할 수 있다는 뜻이며, 이것이 Electric 설계의 강점입니다.
읽기 경로에서 반드시 짚어야 할 것이 인가(Authorization) 입니다.
Electric 자체에는 인가 기능이 없습니다. 클라이언트가 where 파라미터를 임의로 조작해 다른 사용자 데이터를 구독할 수 있는 구조입니다. 해결책은 Electric 앞에 인가 프록시를 두는 것입니다. 클라이언트는 자체 엔드포인트를 호출하고, 서버가 토큰을 검증한 뒤 Electric에 전달할 where 절을 직접 설정합니다.
// 서버 측 (예: Next.js Route Handler) — /api/todos-shape
export async function GET(req: Request) {
const session = await getServerSession(req)
if (!session) {
return new Response('Unauthorized', { status: 401 })
}
const electricUrl = new URL(`${process.env.ELECTRIC_URL}/v1/shape`)
electricUrl.searchParams.set('table', 'todos')
// 서버에서 세션 기반으로 설정 — 클라이언트 입력 미사용
electricUrl.searchParams.set('where', `user_id = '${session.userId}'`)
// offset, live 등 Electric 파라미터는 클라이언트에서 그대로 전달
const incoming = new URL(req.url)
for (const [key, value] of incoming.searchParams) {
if (['offset', 'live', 'cursor'].includes(key)) {
electricUrl.searchParams.set(key, value)
}
}
return fetch(electricUrl.toString())
}// 클라이언트 측 — 직접 Electric에 접근하지 않음
function TodoList() {
const { data: todos, isLoading } = useShape({
url: '/api/todos-shape',
})
if (isLoading) return <div>동기화 중...</div>
return (
<ul>
{todos.map(todo => (
<li
key={todo.id}
style={{ textDecoration: todo.completed ? 'line-through' : 'none' }}
>
{todo.title}
</li>
))}
</ul>
)
}이 패턴에서 쓰기 경로의 인가는 기존 API에서 그대로 처리합니다. 읽기와 쓰기 경로가 독립적이므로 각자의 인가 로직을 자연스럽게 분리할 수 있습니다.
실전 적용
4가지 Write 패턴 선택하기
쓰기 경로는 개발자가 직접 선택해야 합니다. Electric 공식 문서는 4가지 패턴을 제시하며, 어떤 패턴을 선택할지는 오프라인 요구사항과 복잡도 감내 수준에 따라 달라집니다.
패턴 1: Online Writes — 가장 단순합니다. 기존 API를 그대로 사용하고, Electric은 읽기 동기화만 담당합니다. 오프라인 상태에서는 쓰기가 실패하지만, 대부분의 B2B SaaS에는 이것으로 충분합니다.
패턴 2: Optimistic State — 느린 네트워크 환경에서 로컬 상태를 즉시 반영하고, API 응답 이후 Electric이 서버 변경을 sync합니다. 가장 많이 쓰이는 패턴입니다.
const [localOverrides, setLocalOverrides] = useState<Record<string, Partial<Todo>>>({})
async function toggleTodo(id: string, currentCompleted: boolean) {
const newCompleted = !currentCompleted
// UI에 즉시 반영
setLocalOverrides(prev => ({ ...prev, [id]: { completed: newCompleted } }))
try {
await fetch(`/api/todos/${id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ completed: newCompleted }),
})
// API 성공 직후 낙관적 상태를 제거한다.
// Electric sync가 완료되기 전에 실행되므로, 서버 반영 전 상태가
// 짧은 순간 노출될 수 있다(flicker). flicker를 방지하려면
// useShape의 변경 콜백에서 이 클리어를 수행해야 한다.
setLocalOverrides(prev => {
const next = { ...prev }
delete next[id]
return next
})
} catch {
setLocalOverrides(prev => {
const next = { ...prev }
delete next[id]
return next
})
}
}
// 렌더링 시: 로컬 오버라이드가 서버 데이터를 우선
const mergedTodos = todos.map(todo => ({ ...todo, ...localOverrides[todo.id] }))패턴 3: Shared Persistent Optimistic State — 낙관적 상태를 IndexedDB 같은 영구 저장소에 저장하여 컴포넌트 간 일관성을 높입니다. 페이지를 새로고침해도 오프라인에서 작업한 내용이 보존됩니다.
패턴 4: Through-the-Database Sync — 가장 강력하지만 복잡도도 가장 높습니다. PGlite(브라우저 내 임베디드 Postgres)와 결합하여 애플리케이션 코드가 로컬 DB에만 읽고 씁니다.
Through-the-Database Sync 구현 살펴보기
이 패턴은 로컬에 두 개의 테이블을 운영합니다.
-- Electric이 서버 데이터를 채워주는 동기화 테이블
CREATE TABLE todos_synced (
id UUID PRIMARY KEY,
title TEXT NOT NULL,
completed BOOLEAN DEFAULT FALSE,
user_id TEXT NOT NULL
);
-- 아직 서버에 전송되지 않은 로컬 변경
CREATE TABLE todos_local (
id UUID PRIMARY KEY,
title TEXT NOT NULL,
completed BOOLEAN DEFAULT FALSE,
user_id TEXT NOT NULL,
is_pending BOOLEAN DEFAULT TRUE
);
-- 애플리케이션이 읽는 뷰: 로컬 변경이 서버 데이터를 오버라이드
CREATE VIEW todos AS
SELECT * FROM todos_local WHERE is_pending = TRUE
UNION ALL
SELECT s.* FROM todos_synced s
WHERE s.id NOT IN (
SELECT id FROM todos_local WHERE is_pending = TRUE
);애플리케이션 코드는 todos_local에 쓰고 todos 뷰를 읽습니다.
한 가지 명시적으로 설명이 필요한 부분이 있습니다. 서버 전송이 완료된(is_pending = FALSE) todos_local 행은 이 뷰에 나타나지 않습니다. 이 행의 처리는 백그라운드 워커가 담당합니다. 워커는 is_pending = TRUE인 행을 서버 API로 전송하고, 성공하면 is_pending을 FALSE로 업데이트합니다. 이후 Electric sync로 todos_synced에 서버 응답이 반영되면, 워커가 해당 todos_local 행을 삭제합니다. 그 시점부터 뷰는 todos_synced의 최신 데이터를 노출합니다.
이 패턴은 구현 난이도가 상당합니다. 롤백 처리 시 컨텍스트 손실 문제도 있고, 로컬 스키마 관리도 만만치 않습니다. 단순 CRUD 앱이라면 패턴 2나 3으로 시작하는 것이 현실적입니다.
실제 프로덕션 사례: Trigger.dev
Trigger.dev(오픈소스 백그라운드 잡 플랫폼)는 Electric을 사용해 실시간 잡 실행 상태를 대시보드에 동기화합니다. 초당 수만 건 규모의 변경사항을 처리하는 이 사례는 Electric v1.1 스토리지 엔진 재설계를 직접 촉발했습니다.
v1.1은 2025년 8월에 출시되었으며, 기존 대비 쓰기 성능 100배 이상, 읽기 성능 70배 이상 향상을 달성했습니다(수치는 Electric v1.1 출시 블로그 기준). 높은 CPU 사용률과 대형 트랜잭션 시 읽기 차단 문제를 해결하기 위해 스토리지 엔진을 처음부터 새로 구축한 결과입니다.
장단점 분석
장점 정리
| 장점 | 설명 |
|---|---|
| 기존 인프라 재사용 | Postgres 스키마 변경 없이 레이어로 추가 가능. 기존 API도 유지 |
| 선언적 API | Shape 하나 정의하면 실시간 스트림 완성. WebSocket 서버 불필요 |
| 표준 HTTP 기반 | CDN 캐싱 가능, 방화벽 이슈 적음, 기존 모니터링 도구 재사용 |
| 오픈소스 + 자체 호스팅 | Apache 2.0 라이선스. 벤더 락인 없음 |
| PGlite 결합 | 브라우저 내 Postgres와 연동 시 네트워크 레이턴시 없는 완전한 local-first 경험 |
단점 및 고려사항
| 단점 | 설명 |
|---|---|
| 쓰기 경로 미지원 | 클라이언트 쓰기는 개발자가 직접 구현해야 함 |
| 내장 인가 없음 | Shape 인가는 서버 사이드 프록시로 별도 구현 필요 |
| Through-the-DB 복잡도 | 완전한 local-first는 복잡한 로컬 스키마와 워커 로직 필요 |
| 충돌 해결 미제공 | 복잡한 비즈니스 로직의 충돌 해결은 서버 API에서 별도 구현 필요 |
| 대용량 shape 비용 | WHERE 절로 범위를 제한하지 않으면 초기 로드 비용 급증 |
| PostgreSQL 전용 | MySQL, MongoDB 등 비 Postgres DB는 지원하지 않음 |
| PGlite 쓰기 sync 실험 단계 | PGlite에서 서버로의 쓰기 sync는 현재도 실험적 단계. 프로덕션 적용 전 최신 릴리즈 노트 확인 권장 |
경쟁 도구와의 비교
| 도구 | 강점 | 선택 기준 |
|---|---|---|
| ElectricSQL | 기존 Postgres 재사용, 웹 DX | Postgres 기반 팀, 기존 API 유지 원할 때 |
| PowerSync | 모바일 프로덕션 성숙도 | 모바일 앱, SQLite 기반 환경 |
| Zero (Rocicorp) | 세밀한 반응형 쿼리, 웹 DX | Postgres 기반이지만 자체 zero-cache 서버가 필요한 다른 아키텍처 |
| TanStack DB | Fine-grained 반응형 UI | Electric을 어댑터 중 하나로 지원하는 독립적 클라이언트 DB |
실무에서 흔한 실수
1. WHERE 절을 보안 경계로 착각하기
where 파라미터는 클라이언트가 임의로 변조할 수 있습니다. 서버 사이드 인가 프록시 없이 클라이언트에서 직접 Electric에 접근하면, 브라우저 개발자 도구에서 where 값을 바꿔 다른 사용자 데이터를 구독할 수 있습니다.
// 위험: 클라이언트가 where 파라미터를 임의로 변조 가능
useShape({
url: `${ELECTRIC_URL}/v1/shape`,
params: { table: 'todos', where: `user_id = '${userId}'` },
})
// 올바른 방식: 인가 프록시를 통해 서버에서 where 절 설정
useShape({ url: '/api/todos-shape' })2. Shape 범위를 너무 넓게 잡기
서버 사이드 인가 프록시에서 where 절로 사용자 데이터를 필터링하는 것은 성능 측면에서도 필수입니다. 전체 테이블을 구독하면 초기 로드 비용이 급증합니다.
// 인가 프록시에서 반드시 범위를 제한해야 함
electricUrl.searchParams.set('where', `user_id = '${session.userId}'`)3. Electric이 쓰기도 처리한다고 오해하기
useShape로 받은 데이터를 수정하려면 별도의 API 호출이 반드시 필요합니다. Electric은 그 변경이 Postgres에 반영된 이후의 sync만 담당합니다.
4. 낙관적 업데이트 없이 서버 왕복 UX 제공하기
일반 환경에서도 서버 왕복 레이턴시는 체감됩니다. 단순한 토글 하나에도 낙관적 업데이트를 적용하면 사용자 경험이 크게 달라집니다. 패턴 2부터 시작해도 충분합니다.
마치며
ElectricSQL의 핵심은 "읽기 경로 동기화"를 Postgres 논리 복제 + HTTP Long-Polling으로 표준화한 것입니다. WebSocket 인프라 없이 실시간 스트림을 구현하고, 기존 API를 건드리지 않으면서 sync 레이어를 추가할 수 있습니다. 충돌 해결과 쓰기 인가는 개발자가 직접 설계하는 영역으로 남겨져 있습니다.
쓰기 패턴은 요구사항에 따라 단계적으로 도입하는 것이 현실적입니다. 대부분의 앱은 패턴 1(Online Writes)이나 패턴 2(Optimistic State)로 충분하며, 완전한 local-first가 필요할 때만 패턴 4(Through-the-DB)를 고려하는 것이 좋습니다. Trigger.dev의 사례처럼 실제 규모의 트래픽을 처리하는 사례가 늘면서, local-first 아키텍처는 "실험적"에서 "프로덕션 고려 가능"한 수준으로 올라서고 있습니다.
직접 시작해보려면 세 단계를 추천합니다:
-
Docker로 Electric을 로컬에 실행하고
useShape훅으로 기존 Postgres 테이블을 구독해보기. Docker, Postgres 논리 복제 설정, Electric 서버 실행이 전제되지만, 이 과정을 마치면 Postgres 변경이 브라우저에 실시간으로 반영되는 것을 직접 확인할 수 있습니다. -
기존 앱에서 폴링 또는 WebSocket으로 처리하던 실시간 기능 하나를 Shape로 교체해보기. 작은 범위에서 마이그레이션 비용과 효과를 가늠할 수 있습니다.
-
오프라인 시나리오를 식별한 후 적합한 Write 패턴 선택하기. 위의 결정 트리를 참고해 Optimistic State부터 점진적으로 적용해보시면 됩니다.
참고 자료
electric-sql.com과electric.ax는 모두 ElectricSQL 공식 도메인으로 운영되며, 접속 시 한쪽으로 리다이렉트될 수 있습니다.
- Electric 공식 문서 - Shapes 가이드
- Electric HTTP API 레퍼런스
- Electric 공식 문서 - Writes 가이드 (4가지 패턴)
- Electric TypeScript 클라이언트 문서
- GitHub: electric-sql/electric
- GitHub: electric-sql/pglite
- PGlite 공식 문서 - Electric sync
- Electric v1.1 출시 블로그 - 100x faster writes
- Electric BETA 출시 블로그 (2024.12)
- Local-first with your existing API - ElectricSQL 블로그
- TanStack DB + ElectricSQL 통합 - Neon 블로그
- ElectricSQL vs PowerSync vs Zero 비교 (2026)
- Comparing local-first frameworks - Neon
- LogRocket: ElectricSQL로 local-first 앱 만들기
- Building an offline first E2E encrypted web app with PGlite and Electric
- GitHub: write-patterns 예제 코드
- GitHub: LinearLite 클론 예제
- DeepWiki: ElectricSQL 시스템 아키텍처
- Local-First Architecture: CRDTs & Sync Engines 2026