Cliply 1차는 한 사람이 한 PC에서 쓰는 앱이다. 서버도 계정도 없다. 하지만 회사 PC와 집 PC에서 같은 스니펫을 쓰고 싶어질 것은 분명했다. 그래서 설계 문서의 범위 외 목록에 "2차: 서버 동기화(UpdatedAt + IsDeleted 기반 증분 동기화)"를 적고, 1차 스키마를 그 2차에 맞춰 정했다.

나중에 바꾸면 되지 않느냐고 할 수 있다. 하지만 정수 자동 증가 Id로 시작해 사용자 데이터가 쌓인 뒤 GUID로 바꾸는 것은 마이그레이션 중에서도 가장 피곤한 종류다. 지금 비용은 거의 없고 나중 비용은 크다.

세 가지 결정

1. Id는 GUID

PC 두 대가 각자 스니펫을 만들면 정수 Id는 반드시 겹친다. 둘 다 "7번 스니펫"을 갖게 된다. GUID는 어디서 만들어도 겹치지 않으니, 동기화할 때 "같은 Id = 같은 스니펫"이라는 규칙 하나로 충분하다.

SQLite에는 GUID 타입이 없다. EF Core의 기본값은 대문자 문자열인데, Cliply는 소문자 D 형식 TEXT로 통일했다. 2편에서 말했듯 FTS 테이블과 raw SQL로 조인하기 때문에 형식이 한 가지여야 한다.

protected override void ConfigureConventions(ModelConfigurationBuilder configurationBuilder)
{
    // GUID는 소문자 TEXT("D" 형식)로 저장 — FTS 테이블의 SnippetId와 같은 형식
    configurationBuilder.Properties<Guid>().HaveConversion<string>();

    // 시각은 UTC ISO8601 TEXT (사전순 정렬 = 시간순 정렬)
    configurationBuilder.Properties<DateTime>().HaveConversion<UtcIsoDateTimeConverter>();
    configurationBuilder.Properties<DateTime?>().HaveConversion<UtcIsoDateTimeConverter>();
}

시각은 yyyy-MM-ddTHH:mm:ss.fffffffZ 고정 길이 UTC 문자열이다. 길이가 고정이면 문자열 정렬이 곧 시간 정렬이라 SQL의 ORDER BY LastUsedAt DESC가 그대로 맞는다. 표시할 때만 로컬 시각으로 바꾼다. 컨버터 하나를 ConfigureConventions에 걸어 두면 엔티티마다 설정할 필요가 없다.

2. 모든 엔티티에 UpdatedAt

증분 동기화는 "마지막 동기화 이후 바뀐 것만 주고받기"다. 그러려면 행마다 언제 바뀌었는지가 있어야 한다. 스니펫, 태그, 변수 입력값 모두 UpdatedAt을 갖는다.

여기서 고민한 것은 무엇이 "수정"인가였다.

동작 UpdatedAt
제목·본문·태그 저장 바꾼다
고정(핀) 토글 바꾼다
삭제 바꾼다
복사 (사용 횟수 +1, 최근 사용 시각) 바꾸지 않는다

복사는 하루에도 수십 번 일어난다. 이것까지 수정으로 치면 동기화할 때마다 거의 모든 스니펫이 "바뀐 것"이 되고, 양쪽 PC에서 같은 스니펫을 복사만 했는데도 충돌로 잡힌다. 사용 통계는 사용자의 의도적인 편집이 아니다. 그래서 RecordUseAsync는 UseCount와 LastUsedAt만 건드린다.

var affected = await context.Snippets
    .Where(s => s.Id == id)
    .ExecuteUpdateAsync(u => u
        .SetProperty(s => s.UseCount, s => s.UseCount + 1)
        .SetProperty(s => s.LastUsedAt, now), cancellationToken);

ExecuteUpdateAsync로 엔티티를 읽지 않고 UPDATE 한 줄로 끝낸다. 복사할 때마다 불리는 코드라 가벼운 편이 좋다.

3. 삭제는 소프트 삭제

한 PC에서 지운 스니펫을 행째 없애 버리면, 다른 PC는 "상대에게 없는 스니펫"을 보고 새로 생긴 것으로 착각해 되돌려 보낸다. 지운 사실 자체가 기록으로 남아야 전파할 수 있다. 그래서 IsDeleted = true로 표시하고 UpdatedAt을 갱신한다.

삭제된 행은 EF Core 전역 쿼리 필터(HasQueryFilter(s => !s.IsDeleted))로 모든 조회에서 빠진다. 일부러 볼 때만 IgnoreQueryFilters()를 쓴다. 삭제할 때는 태그 연결도 끊고, 더는 쓰이지 않는 태그를 정리하고, FTS 색인에서 뺀다.

동기화보다 먼저 쓸모가 생겼다: JSON 가져오기

Phase 6에서 만든 가져오기/내보내기는 원래 백업과 PC 이전용이다. 그런데 "다른 PC에서 내보낸 파일을 이 PC로 가져오기"는 사실 수동 동기화 한 번이다. 위의 세 결정이 그대로 충돌 처리 규칙이 됐다.

{
  "app": "Cliply",
  "version": 1,
  "exportedAt": "2026-10-04T12:00:00Z",
  "snippets": [
    {
      "id": "0b6c...",
      "title": "도커 컨테이너 로그 보기",
      "body": "docker logs -f --tail {{줄수:100}} {{컨테이너}}",
      "language": "docker",
      "tags": ["docker", "log"],
      "updatedAt": "..."
    }
  ]
}

가져오기 규칙은 이렇다.

경우 처리
같은 id가 없음 추가
같은 id가 있고 파일 쪽 updatedAt이 더 최신 파일 내용으로 갱신
같은 id가 있고 로컬이 같거나 더 최신 건너뜀
로컬에서 삭제됐지만 파일 쪽이 더 최신 되살림
제목·본문이 없거나 파일 안에서 id가 중복 건너뜀
id가 없음 새 GUID로 추가

결과는 "추가 N건 / 갱신 N건 / 건너뜀 N건"으로 보여 준다.

var existing = await context.Snippets.IgnoreQueryFilters()
    .Include(s => s.Tags)
    .FirstOrDefaultAsync(s => s.Id == id, cancellationToken);

if (existing is null)
{
    ... added++;
}
else if (updatedAt > existing.UpdatedAt)
{
    Apply(context, existing, item, updatedAt, now, tagCache);   // IsDeleted = false 포함
    updated++;
}
else
{
    skipped++;
}

IgnoreQueryFilters()로 삭제된 행까지 찾는 것이 핵심이다. 그렇지 않으면 삭제된 스니펫과 같은 Id를 "없는 것"으로 보고 INSERT 하다가 기본 키 충돌이 난다. 가져오기는 트랜잭션 하나로 처리하고, 끝나면 안 쓰는 태그를 정리하고 FTS를 전체 재색인한다.

잘못된 파일은 백업 전에 거른다

JSON이 깨졌거나, app이 Cliply가 아니거나, version이 다르면 아무것도 하지 않고 거부한다. 순서가 중요하다. 파일을 먼저 읽고 검사한 뒤에 백업하고, 그다음 가져온다. 잘못된 파일을 고를 때마다 백업 파일이 하나씩 쌓이지 않는다.

가져오기 전 자동 백업: VACUUM INTO

가져오기는 여러 행을 한꺼번에 바꾸므로, 그 전에 DB 전체를 백업한다. 위치는 %LOCALAPPDATA%\Cliply\backup\cliply-yyyyMMdd-HHmmss.db다.

DB 파일을 그냥 복사하는 방법은 쓰지 않았다. 앱이 연결을 열고 있는 동안의 파일 복사는 일관성을 보장하지 않고, 나중에 WAL 모드로 바꾸면 최근 변경이 -wal 파일에만 남아 있을 수도 있다. SQLite 3.27부터 있는 VACUUM INTO는 현재 DB의 일관된 스냅숏을 새 파일로 써 준다. 저널 방식이 바뀌어도 백업 코드는 그대로다.

await context.Database.ExecuteSqlRawAsync("VACUUM INTO {0}", [path], cancellationToken);

같은 초에 두 번 백업하면 -2, -3을 붙여 덮어쓰지 않는다. 테스트 이름 그대로 같은_시각에_두_번_백업해도_덮어쓰지_않는다.

샘플 스니펫은 고정 GUID로

첫 실행 때 빈 DB에 예시 스니펫 11개를 넣는다. 처음에는 실행할 때마다 새 GUID로 넣었는데, 테스트를 쓰다가 문제가 보였다. 내보내기 → DB 삭제 → 다시 실행(샘플이 새로 들어감) → 가져오기를 하면, 같은 샘플이 Id가 달라 두 벌이 된다.

그래서 샘플은 고정 GUID(c1191e00-0000-4000-8000-0000000000NN)와 고정 시각(2026-01-01 UTC)으로 넣는다. 가져오는 파일에 같은 샘플이 있으면 같은 Id로 인식되고, 사용자가 고친 적이 있다면 그쪽 updatedAt이 더 최신이라 갱신된다. 영문 화면을 넣은 뒤에는 영어 샘플을 …-8001-…로 구분했다.

EF Core 함정: 키를 채운 새 엔티티는 UPDATE 된다

새 태그를 저장할 때 한 번 막혔다. 태그 Id를 Guid.NewGuid()로 미리 채운 새 Tag를 스니펫의 Tags 컬렉션에 넣기만 했더니, EF Core가 INSERT가 아니라 UPDATE를 보냈다. 당연히 DB에는 그 행이 없으니 저장이 실패한다.

EF Core는 탐색 속성으로 발견한 엔티티의 키가 기본값이 아니면 "이미 DB에 있는 엔티티"로 본다. 키를 DB가 아니라 앱에서 만드는 GUID 설계에서는 흔히 만나는 함정이다. 해결은 명시적으로 Add 하는 것이다.

// 키를 미리 채운 새 엔터티는 명시적으로 Add 해야 INSERT 된다
tag = new Tag { Id = Guid.NewGuid(), Name = name, UpdatedAt = now };
context.Tags.Add(tag);

같은 실수를 반복하지 않으려고 이 내용을 설계 문서의 구현 메모 맨 위에 적어 두었다.

정리

  • 동기화를 나중에 할 계획이라도 Id(GUID)·UpdatedAt·소프트 삭제는 처음부터 넣는다. 지금은 거의 공짜다.
  • GUID와 시각은 문자열 형식을 하나로 고정한다. 고정 길이 UTC 문자열은 정렬도 맞는다.
  • 사용 통계(복사)는 수정으로 치지 않는다.
  • 가져오기 충돌은 "같은 Id면 더 최신 updatedAt" 하나로 정하고, 삭제된 행까지 찾는다.
  • 백업은 파일 복사 대신 VACUUM INTO. 잘못된 파일은 백업 전에 거른다.
  • 샘플 데이터도 고정 Id로 넣어야 가져오기에서 중복되지 않는다.

다음 글에서는 트레이 상주 앱의 마무리 — 단일 인스턴스, 설정 즉시 적용, 한/영 전환, 단일 exe 배포를 다루며 연재를 마친다.


Cliply 개발기

  1. 자주 쓰는 명령어를 단축키 한 번으로 — 「Cliply」 개발기
  2. SQLite FTS5 trigram으로 한글·부분 문자열 검색하기
  3. 어느 창에서든 단축키로 뜨는 WPF 검색 팝업
  4. {{이름:기본값}} 변수 치환 파서와 입력 창
  5. 동기화를 대비한 스키마 — GUID, UpdatedAt, 소프트 삭제와 JSON 가져오기 (이 글)
  6. 트레이 상주 앱 마무리 — 단일 인스턴스, 한/영 전환, 70MB 단일 exe (10월 7일 공개)