[Part 2] 설계편: 문서는 값만 갖는다
결론부터
- 구조는 양식이 갖고, 문서는 값만 갖는다. 양식이 자주 바뀌어도 DB를 고칠 일이 없었다.
- 그 대가로 양식이 바뀌면 기존 문서와 어긋난다. 원칙은 하나였다. 값을 조용히 버리지 않는다.
- 결과적으로 양식이 28종에서 40종으로 늘고 분류가 두 번 바뀌었는데, 기존 문서는 깨지지 않았다.
데이터 모델: 문서는 값만 갖는다
문서 하나는 칸 id별 값을 담은 객체 하나다.
// 문서 하나 = 칸 id → 값
values: Record<fieldId, FieldValue>칸 종류는 6개다.
| 종류 | 뜻 |
|---|---|
text | 한 줄 |
rich | 서식이 있는 본문 |
list | 목록 |
table | 표 |
date | 날짜 |
blocks | 자유 문서 |
서버에도 통째로 넣었다
서버(Supabase의 Postgres)에서도 문서를 표로 쪼개지 않고 data jsonb 한 칸에 통째로 넣었다.
양식은 앱 코드가 정의하고, 자주 바뀐다. 표로 쪼개 두면 양식을 고칠 때마다 DB도 고쳐야 한다. 그래서 서버가 꼭 알아야 하는 것(누구의 문서인지, 언제 고쳤는지)만 열로 뺐다.
이 결정은 나중에 휴지통을 만들 때 돌아왔다. deletedAt을 JSON 안에 넣으면 끝이었다. 컬럼 추가도, 마이그레이션도 필요 없었다.
"이 문서에서만" 고치는 것은 문서에 붙인다
양식은 여러 문서가 함께 쓴다. 그래서 특정 문서에서만 양식을 바꾸는 정보는 양식이 아니라 문서에 붙였다.
labels: 절, 항목, 열 이름deletedColumns/addedColumnsdeletedSections/addedSections
열을 지워도 행의 값은 지우지 않는다. 이력으로 되돌렸을 때 지운 열이 값과 함께 돌아와야 하기 때문이다.
양식 개정: 값을 조용히 버리지 않는다
문서가 값만 갖는 구조의 대가는 분명하다. 양식을 고치면 기존 문서와 어긋난다. 처리 규칙은 이렇게 정했다.
| 상황 | 처리 |
|---|---|
| 새로 생긴 자리 | 빈 값으로 만든다 |
| 종류가 바뀐 자리 | 옮길 수 있으면 옮긴다 (목록 ↔ 한 줄 ↔ 본문, 형식이 맞으면 날짜) |
| 옮길 수 없는 종류 변경 | 새 자리는 비우고, 옛 값은 …__이전값으로 남긴다 (표는 구조가 있어서 다른 종류와 오갈 수 없다) |
| 양식에서 사라진 자리 | 지우지 않는다. 값을 그대로 보여 주고, 지우는 건 사람이 눌러야 일어난다 |
어긋난 문서를 열면 편집기에 "양식이 개정됨" 패널이 떠서 무엇이 달라졌는지 먼저 보여 준다. 맞추기와 지우기 모두 직전 상태가 이력에 남아서 되돌릴 수 있다.
저장소 둘을 한 인터페이스로
로그인하지 않은 사람의 문서는 localStorage에, 로그인한 사람의 문서는 Supabase에 저장한다. 화면은 지금 어느 쪽에 저장하는지 모른다.
DocumentRepository인터페이스 하나에 구현체가 둘이다. 로그인 상태가 바뀌면 구현체만 갈아 끼운다.- 새 문서의 모양, 고친 뒤의 모양, 찾기, 정렬 같은 문서 규칙은 따로 뺐다. 한쪽 구현체에서만 고치면 로그인 여부에 따라 문서가 다르게 만들어지기 때문이다.
이 구조 덕을 본 곳이 휴지통이다. 지운 문서를 걸러 내는 조건을 matchesFilter 한 곳에만 넣었더니, 비회원 목록, 회원 목록, 검색, 건수가 모두 그 함수를 지나면서 한 번에 적용됐다.
로그인하면 브라우저에 있던 문서를 계정으로 전부 옮기고 브라우저에서는 지운다. 단, 옮기다 실패하면 지우지 않는다.
외부 프로젝트가 CI에서 문서를 밀어 넣을 수 있는 Edge Function도 있다. 사람이 로그인해서 부르는 게 아니라 프로젝트가 부르므로 API 키로 인증하고, 키에 어느 계정에 넣을지가 이미 정해져 있다. 같은 id면 갱신하고, 다른 사람 계정의 id면 409를 돌려준다.
같이 고치기: 칸 단위 3자 병합
같은 문서를 두 사람이 고치는 일은 흔하다. 한 사람은 배경을 쓰고, 다른 사람은 일정 표를 채운다. 그때마다 "다른 곳에서 고쳤습니다"를 띄우면 함께 쓰기가 성립하지 않는다.
그래서 저장은 내가 읽어 둔 판 위에만 한다. 그 사이 다른 사람이 저장했으면 세 판을 합친다.
base: 내가 읽었던 판theirs: 서버에 있는 지금 판mine: 내가 고친 판
기준은 base다. "내가 바꿨나"와 "저쪽이 바꿨나"를 따로 보고, 둘 다 바꿨는데 결과가 다를 때만 그 칸을 충돌로 본다. 그때만 사람에게 묻고, 어느 칸이 부딪혔는지 칸 이름으로 알려 준다.
문서 전체를 한 덩어리로 비교하지 않고 칸 하나하나를 따로 본다. 덩어리로 보면 사소한 차이에도 충돌이 난다.
덮어쓰기를 골라도 덮인 내용은 이력에 남긴다. 사람이 "덮어쓰기"를 눌렀다고 해서 그 내용을 잃어도 된다는 뜻은 아니다.
잃지 않기 위한 세 겹
① 이력: 문서 안에서 잃는 것
| 규칙 | 값 | 이유 |
|---|---|---|
| 묶음 간격 | 3분 | 자동 저장은 초 단위라, 전부 남기면 공간이 금방 찬다 |
| 문서당 상한 | 15개 | 되찾을 때 보는 건 방금 전 몇 판이다 |
| 전체 예산 | 1.2MB | 이력이 localStorage를 다 차지하지 않게 한다 |
되돌리기 직전 상태도 이력에 남는다. 그래서 되돌리기 자체를 되돌릴 수 있다.
나중에 "두 판 비교"를 추가했다. 절이 16개인 문서에서 어디가 달라졌는지 눈으로 찾아야 했기 때문이다. 비교할 때는 서식을 걷어내고 글자만 본다. 굵게 하나 바뀐 걸 "내용이 바뀜"으로 보여 주면, 진짜 바뀐 곳이 묻힌다.
② 휴지통: 문서째 잃는 것
여러 건을 한 번에 지우는 기능을 만든 직후, 실수 한 번으로 잃을 수 있는 양이 커졌다. 그래서 휴지통을 만들었다.
- 지운 문서는 목록, 검색, 건수에서 빠지지만 내용과 이력은 남는다.
- 30일이 지나면 사라진다. 되살리면 이력째 돌아온다.
- 기한이 지난 문서 청소는 휴지통을 열 때 한다. 따로 도는 청소 작업을 두지 않으려고 그렇게 했다.
③ 백업: 브라우저째 잃는 것
백업 파일을 되살릴 때 기본 동작은 합치기다. 같은 문서가 있으면 더 최근 것을 남긴다.
기본값을 덮어쓰기로 두면, 다른 사람의 백업 파일을 잘못 열었을 때 쓰던 문서가 사라진다. 문서를 지키려고 만든 기능이 문서를 지우는 길이 된다. 덮어쓰기는 따로 눌러야만 한다.
저장에 실패하면 "저장 실패"를 표시한다. 전에는 catch {}로 조용히 넘어갔다. 조용히 실패하면 사람은 저장된 줄 알고 창을 닫는다.
ID 추적
기획 문서는 ID로 서로를 가리킨다. FR-01(요구사항)은 TC-03(테스트 케이스)과, 다시 SCR-101(화면)과 이어진다. 지금까지는 사람이 두 문서를 나란히 놓고 눈으로 맞췄다.
양식이 표의 ID 열과 참조 열을 표시해 두면, 프로젝트 안의 문서를 훑어 색인을 만들 수 있다. 그러면 기계가 세 가지에 답한다.
- 이 ID는 어디서 정의됐나 (문서 사이 오가기)
- 없는 ID를 가리키고 있지 않나 (오타 잡기)
- 아무도 가리키지 않는 ID는 무엇인가 (빠진 테스트 찾기)
색인은 프로젝트 안에서만 뜻이 있다. 같은 FR-01이라도 다른 프로젝트면 다른 요구사항이다.
ID는 대소문자와 공백을 정리해서 비교한다(fr-01과 FR-01은 같다). 목록에는 문서에 적힌 그대로 보여 준다.
다음 편에서는 화면과 인쇄를 한 곳에서 정한 이유와, 브라우저에서 한글(HWPX)과 PDF 파일을 직접 조립한 과정을 정리한다.