Skip to main content
← 블로그

매월 25일은 달력 어디에 있는가 — 루틴 고스트와 실체화

VauDium·

루틴은 발화하는 날에야 할 일을 만든다. 그래서 달력엔 미래가 안 보였다. 발화 예정일을 반투명 고스트로 그리고, 탭하면 그 자리에서 진짜로 만드는 구조를 만들며 배운 것.

매월 25일은 달력 어디에 있는가 — 루틴 고스트와 실체화

매월 25일에 도는 정산 루틴을 등록하고 달력을 열었는데, 조용했습니다. 당연한 동작이었습니다. fecit의 루틴은 발화 시각이 되어야 할 일 문서를 만들거든요. 문서가 없으니 달력에 그릴 것도 없습니다. 그런데 사용자 입장에서 보면 이상하죠. 분명히 25일에 뭔가 하기로 했는데, 25일 칸이 비어 있으니까요.

고스트 — 아직 없는 것을 그리기

서버가 미리 문서를 만들어두는 방법은 일찍 접었습니다. 발화 로직(템플릿 복제, 우선순위 상속)을 건드리는 데다, “아직 일어나지 않은 일”을 실데이터로 박아두는 건 어색합니다. 대신 클라이언트가 루틴 정의(주기·날짜·만료일)로 발화 예정일을 계산해서, 달력에 반투명 고스트로 그리기로 했습니다.

처음엔 실물과 똑같이 그렸다가 바로 되돌렸습니다. 루틴을 지우면 미래의 고스트가 한꺼번에 사라지는데, 실물처럼 보이면 그 순간이 “문서가 증발했다”로 읽힙니다. 반투명이면 다르게 읽힙니다. 루틴이 그리는 예고니까, 루틴을 지우면 예고가 걷히는 게 자연스럽죠. 같은 동작인데 시각 문법 하나로 의미가 달라졌습니다.

실체화 — 열면 진짜가 된다

보이기만 하면 아쉽습니다. 다음 달 발표 준비를 미리 채워 넣고 싶을 수 있으니까요. 그래서 고스트를 탭하면 그 자리에서 진짜 할 일을 만들도록 했습니다. 발화 크론과 같은 생성 경로를 타서, 미리 열어도 발화된 것과 똑같은 문서가 나옵니다.

문제는 장부입니다. 미리 만들어놨는데 발화 시각에 크론이 또 만들면 이중 생성이죠. 답은 문 하나로 통일하는 것이었습니다. 루틴 문서에 소비된 발화일 목록을 두고, 크론이든 수동 실체화든 생성 직전에 같은 원자적 게이트를 통과하게 했습니다. 게이트를 먼저 통과한 쪽만 만들고, 진 쪽은 조용히 물러납니다. 만든 걸 지우면? 그날은 소비된 채로 남습니다. “그날은 안 하기로 했다”는 뜻이고, 지운 문서가 되살아나는 좀비도 없습니다.

함정 셋

Mongo의 null 배열. Pydantic 모델에 Optional[list] = None으로 필드를 추가하면 새 문서엔 null이 저장되고, $addToSet은 null 위에 동작하지 않습니다. 이 프로젝트에서 같은 원인으로 네 번째 터진 함정입니다. 이번엔 게이트 직전에 null을 빈 배열로 정규화하는 멱등 한 줄로 막았습니다.

네이티브 모달과 화면 전환의 충돌. 고스트를 탭하면 선택 시트가 뜨고, 실체화가 끝나면 상세로 이동합니다. 시트가 떠 있는 채로 네비게이트하면 시트가 사라졌다 튀어오르는 잔상이 생겼습니다. 시트 컴포넌트에 이미 있던 onClosed 콜백 — “모달이 실제로 언마운트된 직후” — 에 이동을 걸어 해결했습니다. 알고 보니 정확히 이 용도로 만들어진 콜백이었습니다.

미리 다 만들기의 비용. 처음엔 1년치 고스트를 한 번에 만들었습니다. 일 단위 루틴 하나가 365개의 가상 객체가 되고, 달력이 버벅였습니다. 지금은 달력이 할 일을 fetch하는 범위(현재 위치 ±12주)를 그대로 받아 그 창만큼만 생성하고, 스크롤로 범위가 이동하면 따라 만듭니다. 존재하는 고스트가 항상 창 크기로 고정되니, 루틴이 늘어도 스크롤을 아무리 멀리 굴려도 비용이 같습니다.

배운 것

생성 경로가 둘이면 문은 하나여야 합니다. 자동(크론)과 수동(실체화)이 각자 검사하면 언젠가 둘 다 통과합니다. 원자적 게이트 하나를 같이 쓰면 경합 자체가 성립하지 않습니다.

그리고 미리보기는 미리보기처럼 보여야 합니다. 실물과 구분되지 않는 미리보기는 편리해 보이지만, 삭제·수정 같은 경계 상황에서 사용자의 멘탈 모델을 깨뜨립니다. 반투명 하나가 그 경계를 지켜줬습니다.

fecit의 달력은 이제 미래를 조금 먼저 보여줍니다.