Skip to main content
← 블로그

할 일의 변경을 템플릿에 반영하기

VauDium ·

템플릿은 미래의 할 일을 위한 형이지만, 같은 템플릿으로 할 일을 여러 번 만들다 보면 템플릿이 못 따라잡는 순간이 옵니다. 그 학습을 어디서, 어떻게 받을지 — push에서 pull로 옮긴 하루.

할 일의 변경을 템플릿에 반영하기

Fecit에서 템플릿은 “이 일을 다음에 또 할 때 이렇게 시작하자” 는 형입니다. 거기서 할 일이 만들어지고, 할 일은 실제 그 자리에서 일어난 일을 담습니다.

그런데 같은 템플릿으로 할 일을 여러 번 만들다 보면 템플릿이 못 따라잡는 순간이 옵니다. “운동” 템플릿의 설명엔 “맨몸 30분” 이라 적혀 있는데, 최근 할 일 다섯 개에선 모두 “맨몸 + 푸시업 50개” 가 추가돼 있는 식이죠. 사용자는 할 일 단계에서 매번 같은 보강을 하고 있고, 템플릿은 옛날 모습 그대로입니다.

이 학습을 템플릿에 다시 흘려넣는 길이 필요했습니다. 오늘 작업은 그 길을 만드는 일이었습니다.

처음 만들었던 흐름

원래는 할 일을 저장하는 순간 “이 변경을 템플릿에도 반영할까요?” 모달이 뜨는 구조였습니다. 할 일이 템플릿과 달라지면 그 자리에서 즉시 묻는 거죠.

써 보니 거슬렸습니다. 운동을 막 마치고 할 일을 닫는 순간에 갑자기 “템플릿 갱신할래?” 가 떠오릅니다. 결정 모드가 아닌데 결정을 요구당하는 느낌이었어요. 게다가 한 번 dismiss 하면 그 신호는 사라집니다. “맨몸 + 푸시업” 이라는 변경이 다섯 번 반복돼도, 다섯 번 다 따로 묻고 다섯 번 다 무시되면 끝.

신호가 누적되지 않는다는 게 가장 큰 문제였습니다. 한 번의 할 일이 우연한 일탈인지, 다섯 번 반복된 체계적 갱신인지 구분할 방법이 없었어요.

서버가 누적하고, 템플릿이 노출한다

방향을 뒤집었습니다. push (할 일 저장 시 즉시 묻기) 가 아니라 pull (템플릿 볼 때 쌓인 제안 확인하기) 로.

서버 쪽에서 할 일이 템플릿과 다른 지점을 감지하면 pending_improve_suggestion 으로 누적합니다. 같은 의미의 변경은 dedupe되고, 결국 템플릿 document에 PENDING 상태의 제안들이 embed돼 있습니다.

클라이언트는 템플릿을 가져올 때 그 제안들을 type별로 묶어, 해당 필드의 라벨 옆에 [improve] 아이콘을 띄웁니다. 시간 제안이 있으면 시간 picker 라벨 옆에, 설명 편집 제안이 있으면 설명 라벨 옆에. 사용자 시선이 어차피 그 필드에 가 있을 때만 노출되는 셈이에요.

const pendingSuggestionsByType = useMemo(() => {
    const map = new Map<string, TaskTemplateImproveSuggestionModel[]>();
    for (const s of taskTemplate?.pendingImproveSuggestions ?? []) {
        if (s.status !== "pending" || !s.type) continue;
        map.set(s.type, [...(map.get(s.type) ?? []), s]);
    }
    return map;
}, [taskTemplate?.pendingImproveSuggestions]);

별도 inbox 화면을 만들지 않은 건 의도적이었습니다. 알림함 같은 자리는 지나가다 처리하는 공간이고, 거기 들어간 제안들은 빠르게 dismiss될 가능성이 큽니다. 템플릿 자체에 붙어 있어야 그 템플릿을 의식하는 순간에만 결정하게 됩니다.

모달이 아니라 페이지

[improve] 아이콘을 누르면 검토 공간이 열립니다. 처음에는 bottom sheet으로 띄울 생각이었어요. 가볍게 올라왔다가 swipe-down으로 닫히는 형태.

그런데 sheet의 기본 닫힘 동작이 dismiss로 읽힌다는 점이 걸렸습니다. 모달은 “확인하고 닫는다” 가 자연스러운데, improve 제안은 “확인하고 결정한다” 가 본질입니다. 화면 일부에 떠 있고 바깥을 탭하면 사라지는 구조는 결정을 가볍게 만듭니다.

그래서 전용 페이지로 갔습니다. improve-task-template-duration, improve-task-template-description — 두 화면 다 stack에 정식 등록된 페이지입니다. 들어가는 순간 “이 자리에 잠깐 머무르며 결정한다” 는 신호가 됩니다.

종류마다 다른 화면

시간 제안과 설명 제안은 결이 다릅니다. 시간은 “60분 → 45분” 같은 단일 값 변경이라 카드 하나면 충분합니다. 현재값과 제안값을 나란히 보여주고 apply/dismiss 버튼 두 개.

설명은 다릅니다. 줄 단위 편집이라 보통 여러 hunk가 같이 들어옵니다. 통째로 받기엔 부담이고, 일부만 받고 싶을 때가 더 많아요. 그래서 GitHub PR 스타일로 만들었습니다 — 빨간 카드(이전)와 초록 카드(이후)를 monospace로 쌓고, hunk별로 apply/dismiss를 따로 결정합니다.

- 맨몸 30분
+ 맨몸 + 푸시업 50개 30분

DANGER50 / SUCCESS50 배경, 라인 앞에 −/+ 마커, 한 chunk만 받고 다음 chunk는 거부하는 게 자연스러운 흐름이 됐습니다.

신호 종류가 늘면 같은 패턴으로 페이지를 하나 더 추가합니다. improve-task-template-???.tsx. 한 mega-modal에 분기를 쌓지 않고 종류마다 파일을 분리한 건, 각 신호의 화면이 사실 공유할 게 별로 없기 때문이에요.

옛 흐름 들어내기

이 흐름을 새로 깐 뒤, 옛 “할 일 → 템플릿 apply 모달” 을 그대로 두는 선택지가 있었습니다. “두 시스템 공존, 사용자가 알아서 선택” 식으로요.

들어냈습니다. 같은 의도를 두 군데서 다르게 처리하는 건 사용자한테도, 코드한테도 부담이에요. acceptImprove 토글, improveSuggestion atom, 할 일 저장 후 모달 띄우는 hasDifference 감지, applyToTemplate 액션 — 모두 삭제. 440줄 정도가 빠져나갔습니다.

회고

같은 기능이 push 모드냐 pull 모드냐로 인상이 이렇게 달라지는 게 흥미로웠어요. 받는 정보의 양은 거의 같은데, “강요받는다”“내가 보러 간다” 사이의 거리는 큽니다.

pending_improve_suggestion 이라는 모델 하나가 그 거리를 만듭니다. 할 일이 만들어질 때 즉시 사용자한테 닿는 게 아니라, 잠깐 서버에 머무르며 동료들(같은 신호의 다른 할 일들)을 기다리고, 어느 정도 모이면 템플릿 자리에 작게 표시됩니다. 신호의 원천수신 시점을 분리한 거예요.

다음에 신호 한 종류 더 얹게 되면 — 예를 들어 “이 템플릿의 default 시간대” 같은 — 같은 자리에 페이지 하나 더 추가하면 끝납니다. 길은 깔린 셈입니다.