Local-first 소프트웨어: 오프라인에서 실행되고 CRDT로 동기화

1. 개요

현재 대부분의 애플리케이션은 서버가 데이터의 “진실”을 유지하고 클라이언트는 단지 표시하는 방식을 취합니다. 네트워크가 끊기면 앱이 거의 사용 불가능합니다. Local-first 소프트웨어는 이 방식을 역전시킵니다: 데이터가 먼저 사용자의 기기에 있고, 서버는 동기화를 돕기만 합니다.

이 글은 local-first가 무엇인지, 왜 주목받고 있는지, 그리고 **CRDT(Conflict-free Replicated Data Type)**가 데이터를 병합할 때 락이나 “유일한 승자” 없이 어떻게 작동하는지 설명합니다. 이 글을 읽은 후에는 Java 21로 기본 CRDT를 직접 구현할 수 있고, Spring Boot 3로 동기화 엔드포인트를 만들 수 있으며, 이 방식을 언제 선택하지 말아야 하는지 알게 될 것입니다.

2. Local-first란 무엇이고 왜 주목받는가

Local-first 애플리케이션은 일반적으로 다음과 같은 특징을 가집니다:

  • 기기에서의 즉시 읽기/쓰기: 모든 쓰기 작업이 로컬 스토리지(SQLite, IndexedDB, 파일 등)로 즉시 저장되고, 네트워크 왕복을 기다리지 않습니다.
  • 오프라인 작동: 네트워크가 끊겨도 데이터를 편집할 수 있습니다. 네트워크가 복구되면 동기화됩니다.
  • 여러 기기, 여러 사람의 협업: 복제본(replica)들이 동시에 편집되고 자동으로 같은 상태로 수렴합니다.
  • 사용자가 데이터를 소유: 서버가 사라져도 기기의 데이터는 여전히 존재합니다.

이 방식이 더 많이 논의되는 몇 가지 이유가 있습니다:

  1. 사용자 경험의 기대: 사용자들은 클릭할 때마다 스피너 없이 즉시 반응하는 앱에 익숙합니다.
  2. 더 강력한 기기: 스마트폰과 노트북이 로컬 데이터베이스와 데이터 병합 로직을 실행할 수 있을 만큼 강력해졌습니다.
  3. 성숙한 생태계: Automerge, Yjs와 같은 CRDT 라이브러리들과 SQLite를 위한 많은 동기화 엔진들이 처음부터 구현하는 것보다 실제 적용을 훨씬 쉽게 만들었습니다.
  4. 개인정보 보호와 데이터 주권: 데이터를 사용자의 기기에 보관함으로써 중앙 서버의 의존성을 줄입니다.

가장 큰 도전은 동기화입니다. 두 기기가 오프라인에서 동일한 데이터를 수정하면, 다시 만날 때 누가 맞습니까?

3. CRDT가 충돌을 해결하는 방법

CRDT는 모든 replica가 서로의 업데이트를 충분히 받은 후 항상 같은 상태로 수렴하도록 설계된 데이터 구조입니다. 이 수렴은 업데이트를 받은 순서와 관계없이 일어납니다. 서버가 중재자 역할을 할 필요가 없고, 락도 필요하지 않습니다.

주로 두 가지 종류가 있습니다:

  • State-based (CvRDT): replica들이 전체 상태를 서로에게 보내고 merge를 호출합니다.
  • Operation-based (CmRDT): replica들이 각 작업을 보냅니다. 전송 채널이 각 작업이 정확히 한 번 전달되는 것을 보장해야 합니다.

이 글은 이해하기 쉽고 설명하기 쉬운 state-based 방식을 사용합니다. 수렴하기 위해 merge 함수는 세 가지 성질을 만족해야 합니다:

교환 법칙      : merge(a, b) == merge(b, a)
결합 법칙      : merge(merge(a, b), c) == merge(a, merge(b, c))
멱등성         : merge(a, a) == a

이 세 가지 성질 덕분에 메시지가 늦게 도착하거나, 순서가 뒤바뀌거나, 중복되어도 결과는 영향을 받지 않습니다.

4. 기본 CRDT 구현하기

아래의 예제는 JDK만 사용합니다. 5번 섹션의 Spring Boot 부분에서는 추가 의존성이 필요합니다.

4.1. G-Counter: 증가만 하는 카운터

여러 기기에서 Order의 조회 수를 세고 싶다고 가정합시다. 각 replica는 자신의 슬롯만 증가시킵니다. Merge할 때 각 슬롯의 최댓값을 가져옵니다.

import java.util.HashMap;
import java.util.Map;

public final class GCounter {
    private final Map<String, Long> counts = new HashMap<>();

    public void increment(String replicaId) {
        counts.merge(replicaId, 1L, Long::sum);
    }

    public long value() {
        return counts.values().stream().mapToLong(Long::longValue).sum();
    }

    public void merge(GCounter other) {
        other.counts.forEach((id, n) -> counts.merge(id, n, Math::max));
    }
}

increment는 replicaId의 슬롯만 건드리므로 두 replica는 절대 서로를 덮어쓰지 않습니다. merge는 Math::max를 사용하고, max 연산은 교환, 결합, 멱등성을 가집니다. 빠른 테스트:

GCounter phone = new GCounter();
GCounter laptop = new GCounter();
phone.increment("phone");
phone.increment("phone");
laptop.increment("laptop");

phone.merge(laptop);
laptop.merge(phone);
phone.merge(laptop); // 반복 merge도 결과를 손상시키지 않음
System.out.println(phone.value() + " " + laptop.value());
3 3

감소도 지원하려면 PN-Counter를 사용합니다: 두 개의 G-Counter, 하나는 증가용, 하나는 감소용이며, 값은 두 카운터의 차이입니다.

4.2. LWW-Register: “마지막 쓰기 승리” 값

Customer.displayName 같은 단일 필드의 경우, 가장 간단한 방법은 가장 최신 타임스탬프를 가진 값을 유지하는 것입니다. 타임스탬프가 같으면 replicaId로 구분하여 결과가 항상 결정적이 되도록 합니다.

public record LwwRegister<T>(T value, long timestamp, String replicaId) {

    public LwwRegister<T> merge(LwwRegister<T> other) {
        if (other.timestamp != timestamp) {
            return other.timestamp > timestamp ? other : this;
        }
        return other.replicaId.compareTo(replicaId) > 0 ? other : this;
    }
}
var a = new LwwRegister<>("Lan", 100, "phone");
var b = new LwwRegister<>("Lan Nguyễn", 105, "laptop");
System.out.println(a.merge(b).value() + " | " + b.merge(a).value());
Lan Nguyễn | Lan Nguyễn

주의: 기기의 시계는 보통 서로 약간 다릅니다. 한 머신이 몇 분 빠르면 매번 모든 쓰기를 “이기게” 됩니다. 실제로는 Hybrid Logical Clock이나 Lamport clock을 순수 물리 시간 대신 사용하는 경우가 많습니다.

LWW는 간단하지만 두 수정 중 하나를 자동으로 버립니다. 협업 텍스트 편집의 경우 이는 받아들일 수 없습니다. 그럴 때는 RGA 같은 문자열용 CRDT나 Automerge과 Yjs가 사용하는 알고리즘이 필요합니다.

4.3. OR-Set: 추가/제거할 수 있는 집합

공유 쇼핑 목록이 있다고 가정합시다. 단순한 구현은 한 머신이 오프라인에서 “우유”를 제거하고 다른 머신이 동시에 다시 “우유”를 추가할 때 망가집니다. Observed-Remove Set은 각 add에 고유한 태그를 붙여서 이를 해결합니다. remove 명령은 replica가 본 적 있는 태그만 제거합니다.

import java.util.*;

public final class ORSet {
    private final Map<String, Set<String>> adds = new HashMap<>();
    private final Map<String, Set<String>> removes = new HashMap<>();

    public void add(String item) {
        adds.computeIfAbsent(item, k -> new HashSet<>()).add(UUID.randomUUID().toString());
    }

    public void remove(String item) {
        removes.computeIfAbsent(item, k -> new HashSet<>())
               .addAll(adds.getOrDefault(item, Set.of()));
    }

    public Set<String> elements() {
        Set<String> result = new TreeSet<>();
        adds.forEach((item, tags) -> {
            Set<String> alive = new HashSet<>(tags);
            alive.removeAll(removes.getOrDefault(item, Set.of()));
            if (!alive.isEmpty()) result.add(item);
        });
        return result;
    }

    public void merge(ORSet other) {
        union(adds, other.adds);
        union(removes, other.removes);
    }

    private static void union(Map<String, Set<String>> target, Map<String, Set<String>> source) {
        source.forEach((k, v) -> target.computeIfAbsent(k, x -> new HashSet<>()).addAll(v));
    }

    public ORSetState toState() {
        return new ORSetState(adds, removes);
    }

    public static ORSet fromState(ORSetState state) {
        ORSet set = new ORSet();
        union(set.adds, state.adds());
        union(set.removes, state.removes());
        return set;
    }
}
import java.util.Map;
import java.util.Set;

public record ORSetState(Map<String, Set<String>> adds, Map<String, Set<String>> removes) {}

작동 방식 분석:

  • add는 각 추가마다 새로운 UUID를 생성합니다. 기존 요소를 다시 추가해도 마찬가지입니다.
  • remove는 요소의 현재 태그를 removes로 복사합니다. 다른 replica가 추가했지만 이 replica가 아직 본 적 없는 태그는 계속 살아있습니다.
  • elements는 최소 하나의 제거되지 않은 태그를 가진 요소를 반환합니다. 결과: 추가와 제거가 동시에 발생하면 추가가 승리합니다 (add-wins).
  • merge는 단순한 집합 합집합이므로 세 가지 성질을 모두 만족합니다.
  • ORSetState는 동기화할 때 Jackson으로 JSON으로 직렬화할 수 있는 간단한 record입니다.

5. Spring Boot로 서버를 통한 동기화

Local-first 모델에서 서버는 누가 맞는지를 결정하지 않습니다. 그냥 항상 온라인인 replica일 뿐이며, 기기들이 상태를 교환하도록 도와줍니다. Web starter 의존성을 추가합니다(spring-boot-starter-parent에서 버전 관리):

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-test</artifactId>
    <scope>test</scope>
</dependency>

Controller는 클라이언트에서 상태를 받아서 서버의 복제본으로 merge한 다음, 병합된 상태를 돌려줍니다:

import org.springframework.web.bind.annotation.*;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;

@RestController
@RequestMapping("/lists/{listId}")
public class ShoppingListSyncController {

    private final Map<String, ORSet> store = new ConcurrentHashMap<>();

    @PostMapping("/sync")
    public synchronized ORSetState sync(@PathVariable String listId,
                                        @RequestBody ORSetState clientState) {
        ORSet serverCopy = store.computeIfAbsent(listId, id -> new ORSet());
        serverCopy.merge(ORSet.fromState(clientState));
        return serverCopy.toState();
    }
}

클라이언트 쪽은 역순으로 작동합니다: 상태를 올리고, 응답을 받고, 로컬 복제본으로 merge합니다. merge가 멱등성이므로 클라이언트는 안전하게 재시도할 수 있으며 네트워크가 불안정해도 괜찮습니다. curl로 시도해봅시다:

curl -s -X POST localhost:8080/lists/family/sync \
  -H "Content-Type: application/json" \
  -d '{"adds":{"우유":["t1"],"계란":["t2"]},"removes":{"계란":["t2"]}}'
{"adds":{"우유":["t1"],"계란":["t2"]},"removes":{"계란":["t2"]}}

클라이언트는 반환된 상태에서 elements()를 계산하고 [우유]를 받습니다.

주의: synchronized와 ConcurrentHashMap은 단순히 설명 목적일 뿐입니다. 프로덕션에서는 상태를 데이터베이스에 저장하고 전체 상태 대신 변경 부분(delta)만 보내야 합니다. 각 listId에 대해 인증 및 권한 부여도 필요합니다.

6. 수렴 동작 테스트하기

테스트할 가장 중요한 것은: 두 replica가 동시에 수정하고 어느 순서로든 merge하면, 같은 결과가 나와야 한다는 것입니다.

import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;

class ORSetTest {

    @Test
    void concurrentAddAndRemove_convergeWithAddWins() {
        ORSet phone = new ORSet();
        phone.add("우유");
        ORSet laptop = ORSet.fromState(phone.toState()); // 초기 동기화

        phone.remove("우유");   // 오프라인: 폰이 제거
        laptop.add("우유");     // 오프라인: 노트북이 다시 추가
        laptop.add("바게트");

        ORSet a = ORSet.fromState(phone.toState());
        a.merge(laptop);
        ORSet b = ORSet.fromState(laptop.toState());
        b.merge(phone);

        assertThat(a.elements()).isEqualTo(b.elements())
                                .containsExactly("바게트", "우유");
    }

    @Test
    void merge_isIdempotent() {
        ORSet list = new ORSet();
        list.add("계란");
        list.merge(ORSet.fromState(list.toState()));
        assertThat(list.elements()).containsExactly("계란");
    }
}

첫 번째 테스트는 교환성과 add-wins 규칙을 확인합니다. “우유”는 노트북이 추가한 태그이므로 폰이 본 적 없기 때문에 여전히 존재합니다. 두 번째 테스트는 멱등성을 확인합니다. 수렴 오류를 찾기 위해 무작위 작업 시퀀스와 merge 순서를 생성하는 property-based 테스트를 추가해야 합니다.

7. 흔한 실수와 사용하지 말아야 할 경우

흔한 실수:

  • 시스템 시계를 LWW에 사용하지만 시계 오차를 처리하지 않음. 결과적으로 “새로운” 데이터가 “오래된” 데이터로 덮어씌워집니다.
  • 메타데이터 팽창: OR-Set은 제거된 요소의 태그(tombstone)를 영구히 유지합니다. garbage collection 메커니즘이 필요하며, 이는 보통 모든 replica가 제거 작업을 받았다는 것을 보장해야 합니다.
  • 수렴이 비즈니스 로직상 올바름을 의미한다고 생각함: CRDT는 모든 replica가 같다는 것만 보장합니다. 사용자가 원하는 결과를 보장하지는 않습니다. 두 사람이 제목을 동시에 바꾸면 LWW는 여전히 한 버전을 버립니다.
  • 스키마 마이그레이션을 생각하지 않음: 데이터는 기기에 오래 남아있으므로 앱의 구버전과 신버전이 서로의 상태를 읽을 수 있어야 합니다.

Local-first와 CRDT를 사용하지 말아야 할 경우:

  • 필수 글로벌 제약조건: 계좌 잔액이 음수가 될 수 없음, 재고를 초과 판매할 수 없음, 각 좌석은 한 사람만 예약할 수 있음. 이런 불변식들은 중앙 집중식 조정(transaction, 락)이 필요하며 CRDT로는 유지할 수 없습니다.
  • 데이터를 엄격하게 제어해야 함: 규정상 기기에 데이터를 저장할 수 없거나 액세스 권한을 즉시 취소해야 함.
  • 간단한 CRUD 앱, 항상 온라인: 복잡성 비용이 이점보다 큼. REST API와 optimistic locking이 충분합니다.
  • 매우 큰 데이터나 많은 사람이 공유: 전체 상태를 각 기기에 동기화하는 것은 비현실적입니다.

실제로는 보통 하이브리드 방식입니다: 메모, 목록, 초안 같은 협업 부분은 local-first를 사용합니다. 결제 같은 트랜잭션 부분은 여전히 엄격한 제약조건을 가진 서버를 통합니다.

8. 결론

Local-first는 사용자 기기에 데이터를 먼저 놓고 앱을 즉시 반응하도록 만들고 오프라인에서도 작동하도록 합니다. CRDT는 여러 replica가 동시에 수정한 후 자동으로 수렴할 수 있는 기술적 기반이며, merge 함수의 교환성, 결합성, 멱등성 덕분입니다.

이 글에서 Java 21로 G-Counter, LWW-Register, OR-Set을 구현했고, Spring Boot로 동기화 엔드포인트를 만들었으며, 수렴 동작을 테스트하는 방법을 배웠습니다. 실제 제품에서는 CRDT 라이브러리를 처음부터 직접 작성하는 대신 검증된 라이브러리를 사용하는 것이 좋습니다. Local-first를 실제로 이점을 얻는 데이터 부분에만 적용하고, 글로벌 제약조건이 필요한 비즈니스 로직은 서버 쪽에 유지하세요.

local-firstcrdtoffline-firstdistributed-systemsspring-boot