Skip to main content
← 블로그

Record cache를 template에 — 빼는 것이 추가만큼이다

VauDium ·

Library 탭의 spinner를 지우려고 SQLite cache 패턴을 record에서 template으로 옮긴 하루. 그대로 복사하는 게 아니라 사진·assignee·current_sub_task·상태 필드들을 빼고, 두 가지 정책(사진은 lazy, owner만 캐시)에 맞춰 다시 그린 작업.

빼는 것이 추가만큼이다

Library 탭을 열 때마다 짧은 spinner가 떴어요. 템플릿은 자주 바뀌는 데이터가 아닌데, 매번 API 호출을 기다린 뒤에 첫 paint가 나옵니다.

Record 쪽은 이미 SQLite cache + 증분 sync 패턴이 박혀 있어요. 같은 걸 template에 적용하면 될 일입니다. 그대로 복사하면 될까 — 라는 게 오늘 처음 던진 질문이었어요.

두 가지 정책 먼저

복사 전에 두 가지를 정해야 했어요. 사진과 owner.

사진은 캐시 안 함. Template에는 곧 Material/Tool/Venue 사진이 붙을 예정입니다. Record 쪽은 사진이 적어서 이슈가 안 됐지만 template은 다릅니다. 사진 binary까지 sync 응답에 끌어오면 payload가 부풀고, 모바일 디스크가 빠르게 차요. 게다가 사진은 상세 화면에서만 필요한 데이터. List 뷰에선 안 보이고 어쩌다 detail 들어갈 때만 쓰이는 binary를 캐시해 두는 건 손해입니다.

결정: metadata만 cache, 사진은 서버 lazy. Detail 열 때 그때 받습니다.

Owner=나 인 것만 캐시. Record에는 owner OR assigned_to 둘 다 가능해요 (남이 나에게 assign한 record도 캐시). Template은 다릅니다. assigned_to 개념이 없고, 무엇보다 커뮤니티 Overview로 남의 template을 둘러볼 수 있는 구조. 그 둘러보는 template까지 캐시하면 cache가 “내 라이브러리”의 의미를 잃어요. 내 것이 아닌 데이터가 섞입니다.

결정: cache는 내 소유 only. Shared/community template은 API 직접 조회, cache 안 들름.

이 두 가지가 정해지자 그 다음은 record 패턴을 가져오면서 빼는 작업 이 핵심이 됐어요.

빼는 작업 — 서버

Record sync 엔드포인트는 단계가 많아요:

  1. owner OR assigned_to + parent_task_id=None + revised_at 필터
  2. preferences 머지 (is_focused, is_bucketed, is_pinned)
  3. project response 머지
  4. assignee 머지 — record가 assign된 사람의 닉네임 같이 내려줌
  5. current_sub_task 머지 — 진행 중인 sub-task 정보
  6. fallback — current_sub_task가 미계산이면 sub_task_links에서 다음 PENDING/STARTED 찾기
  7. Tombstone 기반 deleted_ids

Template 버전을 만들 때 4, 5, 6번이 다 빠집니다.

  • Assignee — template은 assign 개념 없음
  • current_sub_task — template은 실행 상태가 없음. “지금 어떤 단계에 있는지”가 없음
  • Fallback — 실행 상태가 없으니 fallback도 불필요

남은 건 owner 필터 + parent_task_id=None + preferences + project + tombstone. 코드가 절반 가까이 줄었어요.

@router.get("/sync")
async def sync_task_templates(...):
    conditions = [
        {"access.owner": achiever.id},   # owner only — assigned_to 없음
        {"parent_task_id": None},
    ]
    # cursor 페이지네이션 — record와 동일
    ...
    # preferences + project 머지
    # tombstone 기반 deleted_ids
    return {"templates": [...], "deleted_ids": [...], ...}

Tombstone는 그대로 차용. Template 삭제 시 task_template_tombstone_collectionowner_id + task_id + deleted_at을 박아둡니다. 다른 기기에서 삭제한 게 sync로 내려와 이 기기 cache에서도 빠지는 구조.

빼는 작업 — 스키마

Mobile의 record cache 테이블 컬럼은 15개:

id, json, state, parent_task_id, start_date, end_date, created_at,
is_focused, is_bucketed, project_id, title, point_color,
priority, difficulty, satisfaction

Template 버전은 무엇을 남길지 가 아니라 무엇을 뺄지 의 문제였어요.

  • state — template은 실행 상태가 없음. 빼기.
  • start_date / end_date — template에도 있지만 library tab에서 날짜 범위 query를 안 함. 빼기.
  • is_bucketed — template은 버킷리스트 개념 없음. 빼기.
  • priority / difficulty / satisfaction — record-only 평가 필드. 빼기.

남은 9개:

id, json, parent_task_id, project_id, title, point_color,
is_focused, is_pinned, created_at

인덱스도 그 위에서 필요한 것만 — parent, project, pinned, created.

Cache as source of truth — 아직 안 함

여기서 자연스러운 다음 단계: Library 탭의 filter / sort / pagination도 cache 위에서 돌리자. 그렇게 하면 클라이언트는 sync로 받은 cache만 보면 되고, API gather는 사라집니다. 깔끔한 end-state.

근데 욕심을 거뒀어요. 그 전환은 추가로 필요한 일이 적지 않습니다:

  • Filter 로직 클라이언트 이전 (keyword search, point colors, project filter)
  • Sort 로직 클라이언트 이전 (created_at, generated_count, pinned-first)
  • Pagination을 SQL OFFSET / LIMIT으로
  • dbVersionAtom 변경 시 displayed list 자동 refresh — Library 탭이 켜져 있는 동안 sync가 새 template을 가져오면 그 자리에서 보여 주는 reactivity
  • 마지막이 특히 어려워요 — 무한 스크롤로 이미 50개를 보고 있는데 sync로 추가/삭제가 일어나면 무엇을 보이고 무엇을 가리고 어떻게 reconcile할지가 작지 않은 문제

그래서 이번 PR은 warm-start only 로 좁혔어요:

  1. Sync는 백그라운드로 돌면서 cache를 owner template 풀세트로 유지
  2. Library 탭 cold start 시 cache에서 첫 12개를 즉시 hydrate (spinner 없음)
  3. 동시에 API gather로 fresh 데이터 fetch — 응답 오면 atom 교체

콜드 스타트 첫 paint 시간이 0이 됩니다. 사용자가 보기엔 library가 즉시 떠 있고, 잠시 후 fresh 데이터로 매끄럽게 교체. Spinner는 사라졌어요.

Filter가 active일 때는 cache hydrate 건너뛰고 API만. 이번 단계에선 cache가 전체 owner 풀세트 가정이라 filtered subset과 안 맞아요. Filter 위 cache 활용은 다음 PR.

CRUD도 mutation을 cache에 직접 박지 않습니다. 현재 흐름: 사용자가 template 생성 → API 성공 → SSE 이벤트 → sync 트리거 → cache 갱신. 길이는 길지만 SSE가 빠르게 동작하므로 몇 백 ms 안에 cache가 fresh해져요. 이게 체감 문제로 올라오면 그때 direct mutation 추가하기로.

회고

패턴 차용은 그대로 복사 가 아닙니다. 차용 가치를 결정하는 건 어디를 빼고 어디를 다시 그릴까 예요.

Record cache 패턴은 이미 검증된 좋은 모양이었어요. SQLite + syncAt 워터마크 + tombstone + 증분 cursor + preferences/project 머지. Template으로 옮기면서 그 뼈대 는 보존하되, 살은 통째로 들어내야 했습니다 — assignee, current_sub_task, fallback, state, start / end date, is_bucketed, priority / difficulty / satisfaction. 일곱 줄이에요.

들어낸 코드만큼이 template은 record가 아니다 라는 선언입니다. 두 도메인은 비슷해 보여도 다릅니다. 같은 추상화가 둘 다에 잘 맞는 건 운이 좋을 때고, 보통은 차용 시점에 어디가 다른지 다시 보지 않으면 잘못된 패턴이 그대로 흘러갑니다.

그리고 scope. Cache as source of truth 가 깔끔한 end-state이지만 그 한 발은 큰 발이에요. Reactivity, filter, pagination — 다 같이 풀어야 일관성이 잡힙니다. 한 PR에 다 넣으면 PR이 부풀고 risk가 커져요. Warm-start only 로 가서 사용자가 즉시 보는 데서 오는 가치만 먼저 챙기고, 나머지는 다음 일로.

오늘은 빼는 작업이 추가만큼 일이라는 것, 그리고 깔끔한 end-state를 한 번에 가지 않아도 된다는 것 을 다시 본 하루였어요.