앱이 느리게 느껴지는 이유와 PowerSync로 해결한 방법

발행: (2026년 5월 11일 AM 12:37 GMT+9)
17 분 소요
원문: Dev.to

I’m unable to retrieve the article content from the link you provided. Could you please paste the text you’d like translated here? Once you do, I’ll translate it into Korean while preserving the source line, formatting, markdown, and any code blocks or URLs.

MVP 허니문 단계

그 느낌을 아시죠? MVP를 구축하고 있을 때는 모든 것이 순조롭게 진행됩니다. 몇 명의 사용자, 빈 데이터베이스, 빠른 서버. 사용자가 버튼을 클릭하면 프론트엔드가 요청을 보내고, 백엔드가 응답하며, UI가 업데이트됩니다. 모든 것이 예측 가능하고 직관적으로 느껴집니다.

그 단계에서는 아키텍처가 문제없이 확장될 것이라고 쉽게 믿게 됩니다. 쿼리는 빠르고, 테이블은 작으며, 사용자 흐름은 단순합니다. 모든 폼이 순식간에 저장되고, 모든 리스트가 즉시 열립니다.

확장성 문제

그럼 제품이 성장합니다. 리스트는 길어지고, 필터는 복잡해지며, 분석 기능이 추가되고, 테이블 관계는 더 엉망이 되고, 사용자는 계속 늘어납니다.

우리는 Finsight를 구축하면서 이 문제에 직면했습니다. 이런 종류의 제품에서는 읽기 작업이 많습니다: 거래, 카테고리, 필터, 합계, 월별 보기, 빠른 편집 등. 모든 화면이 서버 응답을 기다려야 한다면, 전체 제품이 무겁게 느껴지기 시작합니다.

머지않아 사용자는 실제로 앱을 사용하기보다 로더를 바라보는 데 더 많은 시간을 보내게 됩니다. 리스트를 열고, 기다립니다. 필드를 변경하고, 다시 기다립니다. 인터넷은 정상이고 서버도 가동 중인데도 제품은 여전히 느리게 느껴집니다.

전통적인 최적화

  • PostgreSQL 인덱스 확인
  • 페이지네이션 추가
  • 엔드포인트 캐시
  • 핵심 경로에서 무거운 계산을 분리
  • EXPLAIN ANALYZE 실행
  • 불필요한 JOIN 제거
  • 대형 쿼리를 작은 쿼리로 분할
  • 시리얼라이저 최적화
  • 프론트엔드에 디바운스 추가

이 모든 것이 중요하고, 종종 도움이 됩니다. 하지만 우리 경우에는 문제가 단순히 느린 백엔드 때문이 아니었습니다. 실제 문제는 요청‑대기 아키텍처 자체였습니다.

요청‑및‑대기 문제

클래식한 흐름은 다음과 같습니다:

click → request → wait → response → update UI

네트워크와 백엔드가 빠른 한, 이것은 괜찮게 느껴집니다. 모바일 인터넷이 불안정해지거나, 서버가 조금 더 오래 걸리거나, 데이터베이스가 무거운 쿼리로 멈추는 순간, 인터페이스는 대기에 갇히게 됩니다. 앱이 답을 받을 때까지 사용자는 진행할 수 없습니다. 모든 행동이 네트워크와의 작은 협상이 됩니다.

로컬‑우선 아키텍처로 전환

우리는 더 이상 이를 용인하지 않기로 하고 다른 길을 선택했습니다. 인터페이스의 주요 데이터 소스가 사용자의 기기에 있는 local SQLite database가 되는 아키텍처로 옮겼습니다.

Important disclaimer: 백엔드는 사라지지 않았습니다. 여전히 인증, 권한, 비즈니스 규칙 및 검증을 담당합니다. PostgreSQL은 여전히 중앙 저장소로 남아 있습니다. 하지만 React는 리스트를 표시하거나 화면의 필드를 업데이트할 때마다 API를 호출할 필요가 없어졌습니다.

새로운 흐름은 다음과 같습니다:

React UI → Local SQLite → PowerSync → Backend → PostgreSQL

이제 사용자가 save 버튼을 누르면 레코드가 먼저 로컬 데이터베이스에 저장되고, UI가 거의 즉시 업데이트되며, PowerSync가 백그라운드에서 변경 사항을 백엔드에 전송합니다:

click → local write → update UI → sync in background

네트워크는 여전히 중요하지만, 이제는 사용자와 인터페이스 사이에 더 이상 장애물이 되지 않습니다.

PowerSync 작동 방식

프런트엔드는 PowerSync를 통해 로컬 SQLite와 작업합니다. 컴포넌트는 화면마다 별도의 API를 알지 못합니다. 이들은 훅이나 DAL 레이어를 통해 로컬 데이터베이스에 대해 SQL 쿼리를 실행합니다.

백엔드의 역할도 변경됩니다. 이제는 매 렌더링마다 JSON을 반환하는 레이어가 아닙니다. 권한, 제약 조건, 엔티티 간 관계, 그리고 업로드 큐에서 들어오는 작업을 확인하는 장소가 됩니다.

PowerSync는 동기화 레이어를 담당합니다. 클라이언트에 데이터를 전달하고, 로컬 SQLite를 동기화 상태로 유지하며, 로컬 변경 사항을 상위로 전송합니다.

부분 복제 및 동기화 규칙

사람들이 가장 먼저 묻는 질문 중 하나는 전체 데이터베이스가 사용자의 기기에 모두 저장되는지 여부입니다.

아니요. PowerSync의 핵심은 부분 복제입니다. 클라이언트는 사용자가 접근 권한이 있는 행만 받습니다.

예시 sync_rules.yaml:

bucket_definitions:
  by_workspace:
    parameters: |
      SELECT workspace_id
      FROM workspace_memberships
      WHERE user_id = request.user_id()

    data:
      - SELECT * FROM records WHERE workspace_id = bucket.workspace_id
      - SELECT * FROM categories WHERE workspace_id = bucket.workspace_id

추가 데이터는 절대로 동기화되지 않습니다. 이는 두 가지 큰 장점을 제공합니다:

  1. 사용자는 다른 사람에게 속한 행을 물리적으로 받지 않습니다.
  2. 백엔드와 PostgreSQL이 훨씬 적은 일상적인 읽기 작업에 관여합니다. 리스트, 정렬, 필터링, 그리고 일부 분석 작업을 모두 로컬에서 수행할 수 있습니다.

로컬 쿼리 예시

SELECT *
FROM records
WHERE workspace_id = ?
ORDER BY created_at DESC
LIMIT 50;

인덱스가 필요하다면, 그것도 로컬에 존재합니다:

CREATE INDEX records_workspace_created_at_idx
ON records (workspace_id, created_at);

이는 사용자의 바로 옆에 놓인 일반적인 데이터베이스이며, 단순히 대비용 캐시가 아니라 인터페이스를 위한 실제 데이터 소스입니다. 리스트를 열 때 서버와의 왕복 요청에 의존하지 않게 되면서 UI가 더 빠르게 느껴집니다.

PowerSync 토큰을 위한 백엔드 엔드포인트

class GetPowerSyncToken(APIView):
    permission_classes = [IsAuthenticated]

    def get(self, request):
        token = create_powersync_jwt(str(request.user.id))

        return Response({
            "token": token,
            "powersync_url": settings.POWERSYNC_URL,
        })

PowerSync은 해당 토큰의 클레임을 확인하고 동기화 규칙을 적용할 때 이를 사용합니다. 일반 앱 세션은 자체 수명 주기를 가지며, 동기화는 별도의 단기간 유효한 패스를 받습니다.

프론트엔드 쓰기 흐름

프론트엔드에서 가장 큰 변화는 저장을 백엔드에 즉시 POST 요청으로 처리하던 방식을 중단한 것입니다. 이제 프론트엔드가 먼저 로컬 데이터베이스를 업데이트합니다:

await powerSync.writeTransaction(async (tx) => {
  await tx.execute(
    `INSERT INTO records (id, workspace_id, amount, created_at)
     VALUES (?, ?, ?, ?)`,
    [id, workspaceId, amount, createdAt]
  );

  await tx.execute(
    `UPDATE categories
        SET usage_count = COALESCE(usage_count, 0) + 1
      WHERE id = ?`,
    [categoryId]
  );
});

하나의 로컬 트랜잭션으로 여러 관련 엔티티를 업데이트할 수 있습니다. 사용자는 결과를 즉시 확인할 수 있으며, 동기화와 검증은 백그라운드에서 진행됩니다.

업로드 전 큐 압축

레코드가 오프라인에서 여러 번 편집될 때, 중간 상태를 모두 전송하지 않도록 큐를 압축합니다:

const transaction = await database.getNextCrudTransaction();
const byKey = new Map();

for (const item of transaction.crud || []) {
  const key = `${item.table}::${item.id}`;
  const previous = byKey.get(key);

  byKey.set(
    key,
    previous ? mergeOperations(previous, item) : item
  );
}

const batch = [...byKey.values()];

await postBatchWithRetries(uploadUrl, batch);
await transaction.complete();

우리는 행별로 작업을 그룹화하고 서버에 실제로 적용해야 하는 것만 전송합니다—잡음이 줄어들고, 중복 작업이 감소하며, 연결이 복구될 때 발생하는 엣지 케이스도 감소합니다.

서버‑사이드 검증

Local‑first가 프론트엔드가 신뢰받는다는 의미는 아닙니다. 백엔드는 여전히 큐에서 들어오는 모든 작업을 검증합니다:

for index, operation in enumerate(batch):
    try:
        with transaction.atomic():
            action = operation["op"]
            table = operation["table"]
            row_id = operation["id"]
            data = operation.get("data", {})

            if action == "PUT":
                apply_put(table, row_id, data)
            elif action == "PATCH":
                apply_patch(table, row_id, data)
            elif action == "DELETE":
                apply_delete(table, row_id)
            else:
                raise ValidationError("Unsupported operation")

    except ValidationError as exc:
        errors.append({
            "index": index,
            "table": operation.get("table"),
            "id": operation.get("id"),
            "retryable": False,
            "detail": str(exc),
        })

권한, 제한, 엔터티 관계 및 필드 검증은 모두 서버에 남아 있습니다. PowerSync는 변경 사항을 이동시키지만 비즈니스 로직이나 보안을 우회하지 않습니다.

Cross‑Platform Code Sharing

동일한 코드는 웹, PWA, Android TWA, 그리고 iOS WebView 래퍼에서도 작동합니다. 플랫폼별 세부 사항(스토리지, 권한, 푸시 알림, 백그라운드 동작)은 여전히 ​​신경 써야 하지만, 데이터 로직은 공유됩니다. 읽기는 로컬에서, 쓰기는 로컬에서 이루어지고, 동기화는 백그라운드에서 진행되어 웹 UI에서도 네이티브와 같은 느낌을 제공합니다.

Docker Compose 로 아키텍처 실행

services:
  frontend:
    build:
      context: ./frontend
    ports:
      - "4173:4173"

  backend:
    build:
      context: ./backend
    ports:
      - "8000:8000"

  powersync:
    build:
      context: ./powersync
    command: ["start", "-r", "unified"]
    ports:
      - "7001:7001"
    volumes:
      - ./powersync/config:/config

  postgres:
    image: postgres:16
    environment:
      POSTGRES_DB: app
      POSTGRES_USER: app
      POSTGRES_PASSWORD: change_me

간소화된 PowerSync 설정

replication:
  connections:
    - type: postgresql
      uri: !env PS_DATA_SOURCE_URI
      sslmode: disable

storage:
  type: postgresql
  uri: !env PS_STORAGE_PG_URI
  sslmode: disable

sync_rules:
  path: sync_rules.yaml

client_auth:
  jwks_uri: !env PS_JWKS_URL
  audience:
    - !env PS_AUDIENCE
  • PS_DATA_SOURCE_URI는 메인 PostgreSQL 데이터베이스를 가리킵니다.
  • PS_STORAGE_PG_URI는 PowerSync 자체 저장소에 사용됩니다.
  • PS_JWKS_URL은 PowerSync이 JWT를 검증할 수 있게 합니다.

마이그레이션, 충돌 및 보안

마이그레이션

프론트엔드에 대한 마이그레이션 파일을 작성하고 싶지 않았기 때문에 스키마가 변경될 때 로컬 데이터베이스를 삭제하고 동기화에서 다시 빌드합니다. 작동은 하지만 업데이트 후 첫 로드가 다소 거칠게 느껴질 수 있습니다.

충돌 해결

실제 충돌이 발생했습니다: 사용자 A가 오프라인 상태에서 레코드를 편집하고, 사용자 B가 같은 레코드를 온라인에서 편집했으며, A가 다시 온라인이 되었을 때 업로드 큐가 B의 변경 사항을 덮어썼습니다(최신 쓰기 우선). 데이터 자체는 사라지지 않았지만 변경 내용이 사라졌습니다. 충돌을 우아하게 처리하는 것이 필수적입니다.

보안 고려 사항

로컬 데이터베이스가 사용자의 기기에 존재하므로 기존 API에 비해 공격 표면이 확대됩니다. PowerSync는 동기화 규칙을 통해 접근 제어를 시행하고, 백엔드는 모든 업로드를 검증하지만, 민감한 데이터를 로컬에 저장하기 전에 위험성을 평가해야 합니다.

Lessons Learned

  • 디버깅이 더 복잡해집니다: 이제 거짓말을 할 수 있는 네 곳(프론트엔드, 로컬 DB, 동기화 레이어, 백엔드)이 있습니다.
  • 두 데이터베이스를 동기화 유지하는 데 드는 정신적 비용이 상당합니다.
  • 가장 큰 성능 향상은 더 빠른 쿼리 때문이 아니라 주요 상호작용 루프에서 네트워크 대기 시간을 제거한 덕분이었습니다.
  • 불안정한 연결에서도 아키텍처가 다시 “가볍게” 느껴지며, 초기 MVP 시절을 떠올리게 하지만 이제는 확장성을 갖추었습니다.

결론

전환 후, UI는 흔들리는 지하철 연결에서도 즉시 반응하고, 로더는 사라집니다. 앱은 마침내 다시 “가벼운” 느낌을 주며, 초기 MVP 시절과 같이 가볍게 느껴지지만 이제는 성장할 준비가 되었습니다.

다음 번에는 이 로컬 데이터베이스 위에 종단 간 암호화를 추가하는 방법에 대해 쓸 예정이며, 이를 통해 우리조차도 사용자가 저장한 내용을 볼 수 없게 됩니다.

원래는 2026년 5월 2일에 Zentline에 게재되었습니다.

0 조회
Back to Blog

관련 글

더 보기 »