생산성 도구를 이것저것 써봤습니다.
일정은 Google Calendar에, 할 일은 다른 앱에, 그 일정에 필요한 메모는 또 다른 곳에 있었죠.
회의 하나 확인하려고 캘린더를 열고, 관련 내용을 찾으려고 메모 앱을 다시 여는 식입니다.
한곳에 모으면 편하겠다고 생각했습니다.
그래서 기능이 많은 도구도 써봤는데, 이번에는 데이터베이스, 속성, 템플릿, 뷰부터 익혀야 했습니다.
잘 쓰면 좋은 도구라는 건 알겠는데, 저는 그냥 앱을 열고 일정이나 메모를 바로 적고 싶었거든요.
어느 순간부터 도구를 쓰는 시간보다 도구를 관리하는 시간이 더 드는 것 같았습니다.
그렇게 만들기 시작한 게 PlanMini입니다.
일정, 할 일, 메모를 한곳에서 쓰고 서로 연결할 수 있는 개인 플래너입니다.
새로운 관리 방법을 배우거나 작업 공간을 한참 세팅하지 않아도 쓸 수 있게 만들고 싶었습니다.
화면에서 할 일은 비교적 명확했습니다.
문제는 같은 데이터를 여러 기기에서 쓰기 시작하면서 나왔습니다.
처음부터 사용자의 일정과 메모를 제가 운영하는 데이터베이스에 보관하고 싶지는 않았습니다. 작은 개인 생산성 앱을 만들려던 건데, 데이터 저장에 백업, 서버 운영, 장애 대응까지 생각하니 부담이 컸거든요.
그렇다고 한 기기에서만 쓰는 앱을 만들 생각도 없었습니다. 회사에서 적은 메모를 집에서도 보고 싶고, 웹에서 바꾼 일정이 데스크톱 앱에도 나와야 하니까요.
그래서 이미 쓰고 있던 Google 서비스를 활용하기로 했습니다.
일정은 Google Calendar에 둡니다.
메모와 나머지 앱 데이터는 Google Drive에 저장합니다.
PlanMini에서는 이 데이터를 함께 보고 연결하기 편하게 만듭니다.
이 정도면 구조도 꽤 단순해질 것 같았습니다. 저장할 곳이 있고, 다른 기기에서도 같은 데이터를 읽을 수 있으니까요.
그런데 두 기기가 동시에 수정하면 어떻게 될까요?
동기화를 처음 생각하면 흐름은 단순합니다. 최신 데이터를 내려받고, 수정하고, 다시 올립니다.
한 기기에서만 수정한다면 크게 복잡할 게 없죠.
이제 같은 일정을 두 기기에서 열어봅시다.
데스크톱 앱에서는 제목을 바꿉니다. 다른 기기에서는 장소를 바꿉니다. 두 기기 모두 자신이 읽었던 일정에 수정을 반영해서 올리면, 나중에 올라온 데이터가 앞선 수정을 덮어쓸 수 있습니다.
제목과 장소를 각각 바꿨을 뿐인데 둘 중 하나를 잃게 되는 겁니다.
그럼 그냥 나중에 저장한 내용을 남기면 되는 거 아닐까요? 저장 순서는 정할 수 있습니다.
하지만 그 시간이 사용자의 의도까지 알려주지는 않습니다.
나중에 저장한 기기가 다른 쪽의 변경을 봤다는 보장도 없고요.
여기서부터 데이터 종류에 따라 처리 방식을 나누게 됐습니다.
일정은 필드별로 비교하고, 메모는 수정 이력이 갈라졌는지를 봅니다.
Google Calendar는 일정마다 ETag를 제공합니다. PlanMini에서는 이 값을 버전 식별자처럼 사용합니다.
일정을 수정해서 보낼 때, 처음 읽었던 ETag를 If-Match 헤더에 넣습니다.
그사이 다른 기기가 일정을 바꿨다면 Google Calendar가 412 Precondition Failed를 돌려줍니다.
제가 수정의 기준으로 삼았던 버전이 더 이상 최신이 아니라는 뜻입니다.
이때 최신 ETag만 받아서 다시 저장하면 될 것 같지만, 그러면 다른 기기의 수정을 덮어쓸 수 있습니다.
그래서 저장부터 재시도하지 않고 세 가지 버전을 비교합니다.
base: 마지막으로 동기화에 성공했을 때의 일정
local: 현재 기기에서 수정한 일정
remote: Google Calendar에 있는 최신 일정
현재 기기의 제목과 서버의 제목이 다르다고 해봅시다. 이 둘만 비교하면 값이 다르다는 사실만 알 수 있습니다.
내가 바꾼 건지, 다른 기기가 바꾼 건지, 양쪽이 모두 바꾼 건지는 알 수 없죠.
그래서 수정 전 기준인 base가 필요합니다.
예를 들어 처음 일정이 이랬다고 해봅시다.
제목: 주간 회의
장소: 회의실 A
현재 기기는 제목을 "프로젝트 회의"로 바꿨고, 다른 기기는 장소를 "회의실 B"로 바꿨습니다.
기준 버전과 각각 비교하면 수정한 필드가 겹치지 않습니다. 제목과 장소를 모두 반영할 수 있죠.
반대로 양쪽이 제목을 서로 다르게 바꿨다면 어떨까요? 이 경우에는 앱이 하나를 고르지 않고 사용자에게 무엇을 남길지 물어봅니다. 같은 필드를 바꿨더라도 결과가 같다면 별도로 해결할 충돌은 없고요.
같은 일정을 수정했다는 사실보다, 어느 필드를 어떻게 수정했는지가 중요했습니다.
물론 모든 필드를 이렇게 간단하게 다룰 수는 없습니다. 반복 규칙과 관련된 동시 수정처럼 더 조심해야 하는 경우에는 자동 병합을 멈추도록 했습니다.
일정에는 날짜와 시간만 필요한 게 아닙니다. 회의에는 회의록이 있고, 촬영 일정에는 체크리스트가 있고, 프로젝트 마감에는 참고 자료가 있을 수 있죠.
그래서 메모를 일정에 직접 연결할 수 있게 했습니다. 일정은 Google Calendar에 있고 메모는 따로 저장하지만, 앱 안에서는 함께 볼 수 있게 만든 겁니다.
그런데 메모의 충돌은 일정과 조금 다릅니다. 제목과 장소처럼 필드가 나뉜 일정은 각각 비교할 수 있지만, 메모 본문을 두 기기에서 고쳤다면 어디까지 자동으로 합쳐야 할까요?
문장 단위로 합칠 수도 있겠지만, 두 수정이 사용자의 의도에 맞게 이어지는지까지 앱이 판단하기는 어렵습니다.
그래서 메모는 수정 이력을 기준으로 두 버전을 보존하는 쪽을 택했습니다.
메모를 수정할 때마다 operation을 하나씩 만듭니다. 여기에는 다음과 같은 정보가 들어갑니다.
operationId: 변경의 고유 ID
parentOperationIds: 이번 수정의 바탕이 된 변경들의 ID
deviceId: 수정한 기기를 구분하는 ID
payloadSha256: 내용 검증에 사용하는 해시
이 중 충돌을 이해하는 데 중요한 건 parentOperationIds입니다.
이번 수정이 어떤 변경을 보고 만들어졌는지 기록하는 값이죠.
순서대로 수정했다면 A를 보고 B를 만들고, B를 본 다음 C를 만듭니다. 이력이 한 줄로 이어집니다.
그런데 두 기기가 모두 A를 읽은 상태에서, 서로의 변경을 보기 전에 각각 수정하면 어떻게 될까요?
한쪽은 A를 바탕으로 B를 만들고, 다른 쪽은 A를 바탕으로 C를 만듭니다. B와 C는 둘 다 A에서 출발했지만 서로의 수정을 반영하지 않았습니다. 이력이 갈라진 겁니다.
순서대로 수정한 경우
A → B → C
↑
B의 내용을 보고 C를 작성
-----------------------------------------------------------------------------------------
두 기기에서 각각 수정하면, A에서 이력이 두 갈래로 나뉩니다
A ─┬→ B (기기 1에서 수정)
└→ C (기기 2에서 수정)
B와 C는 모두 A를 보고 수정했습니다.
서로의 변경을 보지 못했으므로 두 버전을 모두 남깁니다.여기서 B와 C의 생성 시간을 비교해 하나를 남기지는 않습니다.
몇 초 늦게 저장했다는 이유만으로 다른 기기에서 작성한 내용을 없애고 싶지는 않았습니다.
그래서 두 버전을 모두 남깁니다.
사용자가 충돌을 해결하면 B와 C를 모두 연결한 새 변경 이력을 만듭니다
이 해결 이력도 다른 기기에 전달합니다.
그러면 다른 기기에서도 B와 C가 따로 남아 있는 상태와, 두 변경을 확인한 뒤 해결한 상태를 구분할 수 있습니다.
메모 본문을 문장 단위로 자동 병합하는 방식은 아닙니다.
A ─┬→ B ─┐
└→ C ─┴→ D (사용자가 충돌을 해결한 버전)
D는 B와 C를 모두 확인하고 해결했다는 이력을 남깁니다.
해결한 내용과 이력이 다른 기기에도 전달됩니다.앱이 판단하기 어려운 경우에는 내용을 보존하고 사용자가 선택하게 하는 방식입니다.
할 일과 설정도 같은 이력 방식을 사용합니다. 다만 할 일은 아직 개별 항목 단위로 병합하지 않고, 전체 목록을 하나의 단위로 동기화합니다. 같은 방식을 쓴다고 모든 데이터의 병합 단위까지 같아지는 건 아니니까요.
백업 파일을 읽어서 현재 데이터를 교체하면 복구가 끝날 것 같습니다. 그런데 일주일 전 백업이라면요?
그 뒤에 적은 메모는 어떻게 해야 할까요?
백업에만 있는 메모를 되살리고 싶었던 건데, 현재 데이터까지 과거로 돌아가면 곤란합니다.
그래서 복구할 때도 백업과 현재 데이터를 항목별로 비교합니다.
백업에만 있는 항목은 복원합니다.
실제 내용이 같으면 현재 버전을 유지합니다.
같은 항목인데 내용이 다르면 양쪽을 보여주고 사용자가 고르게 합니다.
여기서 "내용이 같다"는 기준도 필요했습니다. 메모 내용은 그대로인데 저장 시간이나 파일을 구분하는 값만 달라질 수도 있으니까요.
그래서 이런 값은 빼고 실제 내용을 비교합니다. 날짜는 표시 방식이 달라도 같은 날짜인지 확인하고, 첨부 파일도 이름만 보는 게 아니라 파일 내용이 같은지 확인합니다.
사용자에게는 똑같은 메모인데 굳이 충돌이라고 보여주고 싶지는 않았습니다. 반대로 내용이 다르다면, 오래된 백업이라도 사용자가 원하는 쪽을 골라 복원할 수 있게 했습니다.
여기까지 만들면 끝일 것 같았는데, 복구 화면에도 시간이 흐릅니다.
사용자가 미리보기를 열고 어떤 내용을 남길지 고르는 동안 현재 데이터가 바뀔 수 있습니다.
그러면 처음 보여준 비교 결과는 이미 오래된 결과일 수 있죠.
그래서 미리보기를 보여줄 때 데이터가 어떤 상태였는지 기록해 둡니다. 복구 직전에 다시 확인해서 그사이 바뀐 내용이 있다면, 이전 선택을 바로 적용하지 않고 최신 내용으로 다시 비교하게 합니다.
적용 단계도 나눠서 처리하면 곤란합니다. 메모는 복원됐는데 업로드할 이력이 기록되지 않거나, 첨부 파일이 없는 상태로 복구가 끝나면 이후 동기화에서 또 문제가 생길 수 있으니까요.
그래서 로컬 복구는 하나의 IndexedDB 트랜잭션으로 묶었습니다. 복원할 내용과 이후 다른 기기에 반영할 변경 사항을 함께 저장합니다. 필요한 첨부 파일이 없는 등 중간에 문제가 생기면, 일부만 복원된 채로 남지 않도록 복구 전 상태로 되돌립니다.
다만 이 트랜잭션이 보호하는 범위는 현재 기기 안입니다. 다른 기기의 수정을 잠그지는 않습니다. 다른 기기에서 생긴 변경은 이후 동기화 과정에서 앞서 정한 충돌 규칙으로 처리합니다.
사용자 데이터를 저장할 서버를 직접 운영하지 않기로 하면서 운영 부담은 줄었습니다.
하지만 여러 기기에서 바뀐 내용을 어떻게 처리할지는 여전히 제가 정해야 했습니다.
어떤 변경은 자동으로 합쳐도 되는지, 어떤 경우에는 멈춰야 하는지, 사용자에게 무엇을 보여주고 선택하게 할지. 파일을 올리고 내려받는 것보다 이런 기준을 정하는 데 고민이 더 많이 들었습니다.
지금은 확실하게 판단하기 어려우면 두 버전을 남기는 쪽으로 만들고 있습니다.
앱이 알아서 하나를 없앤 뒤에 사용자가 사라진 내용을 찾게 되는 상황은 피하고 싶었으니까요.
다만 실제 두 기기에서 발생할 수 있는 Google 동기화 상황을 전부 검증한 것은 아닙니다.
직접 사용하면서 더 확인하고 손볼 부분이 남아 있습니다.
편하게 쓸 수 있는 개인 플래너를 만들고, 다른 사람들도 쓸 수 있는 서비스로 내놓고 싶어서 시작했습니다.
그런데 실제로 서비스하려니 화면에 보이는 기능 외에도 챙겨야 할 게 많더라고요. 동기화도 그중 하나였고,
생각보다 많은 시간을 쓰게 됐습니다.