Skip to main content
← 블로그

할 일 목록 위에 떠 있는 작은 빨간 줄

VauDium ·

‘지금 뭐 해야 하지?’에 답하는 한 칸을 만들면서 짚어 본 것들 — 분 단위 타이머, 데스크톱 포팅, 그리고 그 사이에 발견한 같은 모양의 버그 두 개.

할 일 목록 위에 떠 있는 작은 빨간 줄

오늘은 할 일 목록 맨 위에 작은 줄 하나를 추가했습니다. **“지금 진행 중”**이라고 적힌, 캘린더의 현재 시각 인디케이터와 같은 빨간색 줄. 그 아래에는 시작 시간이 지나고 종료 시간은 아직 안 지난 태스크들이 붙어 있습니다.

기능 자체는 한 줄로 설명되지만, 만들면서 결정해야 했던 게 몇 개 있었습니다.

정렬 옵션 vs 별도 섹션

처음엔 두 가지가 떠올랐습니다. 정렬 옵션 하나(ACTIVE_NOW)를 추가해서 활성 태스크가 위로 올라오게 하거나, 아예 별도 섹션으로 분리하거나.

정렬은 깔끔합니다. SQL 한 줄이면 끝나고, 기존 정렬 UI에 자연스럽게 어울립니다. 하지만 “지금 뭐 해야 하지?”라는 질문에 답하는 표면이 정렬 메뉴 안쪽에 숨어 있는 건 약합니다. 사용자가 정렬을 바꾸지 않으면 보이지 않습니다.

별도 섹션은 시각적으로 더 강합니다. 헤더가 있고, 색이 있고, 항상 위에 있습니다. 그 대신 만들 게 더 많죠.

후자로 갔습니다. Fecit의 할 일 탭은 backlog 성격이라, 시간 기반의 강조를 거기 끼워넣는 게 살짝 결을 흐리는 부분이 있긴 합니다. 그래도 “지금 마감 닥친 거 먼저 보이기”의 가치가 그 흐림보다 컸습니다.

분 단위 타이머는 하나면 됩니다

활성 여부는 시간이 지나면 바뀝니다. 5분 후에 종료되는 태스크는 5분 후에 더 이상 활성이 아니니까요. 그러니 어딘가에서 분마다 한 번씩은 다시 평가해 줘야 합니다.

처음엔 할 일 탭에 setInterval(60_000)를 하나 더 박을까 했습니다. 그런데 이미 DailyTimelineView(하루 타임라인의 빨간 줄)도 같은 걸 굴리고 있었습니다. 같은 일을 두 군데에서 따로 하는 셈이죠.

전역 atom 하나로 합쳤습니다. nowMinuteAtom + useNowMinuteTicker. 루트 레이아웃에서 한 번 마운트되고, 다음 분 경계까지 정렬한 다음 매 분 한 번씩 갱신합니다. 할 일 탭과 캘린더 양쪽이 이 atom을 구독합니다. 서버에 다시 fetch 하지는 않습니다 — 이미 로드된 데이터의 boundary를 다시 그려 줄 뿐.

SQL은 한 번, JS는 매 분

활성 태스크가 페이지 1에 안 떠 있으면 의미가 없습니다. 그래서 정렬은 서버/SQL 단에서 처리합니다.

ORDER BY (start_date <= ? AND end_date >= ? AND state IN (1,2)) DESC,
         created_at DESC

쿼리 시점의 “지금”으로 활성 여부를 계산하고, 활성인 것을 항상 위로 올립니다. 페이지네이션과 자연스럽게 어울립니다.

JS는 그렇게 받아온 데이터에서 다시 한번 분류합니다. 분 tick이 지나면 useMemo가 재계산되고, 이미 끝난 태스크는 active 섹션에서 자연스럽게 빠져나옵니다. SQL은 쿼리 시점의 스냅샷, JS는 그걸 시간에 따라 다듬는 역할.

hideDatedTasks와의 충돌

Fecit엔 “날짜 설정된 태스크 숨기기” 옵션이 있습니다. backlog 성격을 강조하고 싶은 사용자가 켜는 토글이죠. 그런데 active 섹션의 후보는 정의상 모두 날짜가 있는 태스크입니다. 이 토글이 켜져 있으면 active 섹션이 항상 비게 되는 모순이 생깁니다.

사용자 의도를 다시 짚어보면 답이 나옵니다. “날짜 설정된 거 숨기기”는 예정된 일정이 backlog를 가리지 말라는 뜻이지, 지금 일어나고 있는 걸 숨기라는 뜻은 아닙니다. 그래서 SQL에서 active 태스크는 hideDatedTasks 필터의 예외로 두기로 했습니다.

WHERE ... AND (
    (start_date IS NULL OR end_date IS NULL)  -- 원래 의미
    OR (활성인 태스크)                          -- 예외
)

사용자에게 선택권을

여기까지 만들고 보니, 한 가지 결정이 남아 있었습니다. 이 섹션을 모든 사용자에게 강제할 것인가.

어떤 사람은 backlog의 시간성에서 의도적으로 거리를 두려고 합니다. “지금 뭐 해야 하지?”에 자동으로 답해 주는 게 도움이 아니라 방해가 될 수 있습니다.

showActiveNow 라는 preference 필드를 새로 만들었습니다. 기본은 true(켜짐). 모바일/데스크톱 설정 화면에 토글이 들어갔고, 끄면 SQL 정렬·hideDatedTasks 예외·JS 분류 모두 자동으로 비활성화됩니다. 서버는 클라이언트가 보내는 show_active_now 플래그를 보고 동작합니다.

기본을 ON으로 한 건, “이게 디폴트 경험이어야 한다”고 보기 때문입니다. 다만 끌 수 있게 둡니다.

데스크톱은 다른 길로

모바일은 SQLite 캐시를 로컬에서 직접 쿼리하니, ORDER BY를 바꿔치는 걸로 충분했습니다. 데스크톱은 다릅니다 — 매번 서버에 GET을 던지고, 페이지네이션은 서버 쪽에서 일어납니다.

서버의 find().sort()로는 “is_active_now”라는 계산된 컬럼으로 정렬하기가 어색합니다. 집계 파이프라인으로 가면 되지만, 기존 코드가 다 find() 기반이라 손이 큽니다.

대신 단순한 길을 택했습니다. fetch를 두 번 합니다.

  • only_active=true: 활성 태스크만 (페이지네이션 없이 한 번에)
  • exclude_active=true: 정규 페이지네이션, 활성 제외

데스크톱 클라이언트는 둘을 받아서 active 섹션은 위에, 정규 리스트는 아래에 그립니다. 서버 변경은 쿼리 빌더에 OR 조건 두 개를 추가한 정도로 끝났습니다.

여기서 한 가지 짚고 갈 부분 — 서버에서 매 분 fetch하지는 않습니다. fetch는 페이지 mount, 필터 변경, 수동 refresh, SSE 이벤트가 있을 때만. 분 tick은 이미 받아 둔 active 배열을 JS에서 다시 분류만 합니다. 1분짜리 폴링은 비싸고 의미도 약합니다.

그 사이에 발견한 같은 모양의 버그 두 개

기능 만드는 도중에 사용자한테서 두 번 같은 결의 보고가 들어왔습니다.

“꼬리표 상세 보기에 정보가 안 나와.” “프로젝트 상태가 바뀌지 않아.”

도메인은 다른데 증상이 비슷했습니다. 코드를 따라가니 정확히 같은 패턴이었습니다.

목록 화면이 필터/페이지네이션 리팩터를 거치면서 로컬 state로 옮겨갔는데, 그러면서 글로벌 atom 갱신이 끊겼습니다. 상세 화면은 여전히 atom에서 데이터를 찾고 있어서, atom이 비어 있으면 “상세에 표시할 게 없는” 상태가 되는 거죠. getProject(id) 같은 보조 fetch가 있긴 했는데, 그 응답을 updateItem(있는 ID만 업데이트)으로 받고 있어서, atom에 아예 없는 ID는 추가도 안 되고 있었습니다.

방향은 한 줄이었습니다 — 목록 화면이 받아온 데이터를 atom에 ID 기반 additive merge로 같이 채워주기. Labels와 Projects 양쪽 다 같은 식으로 고쳤습니다.

그런데 어제 한 번 같은 패턴(Labels)을 잡고도 오늘 또 같은 패턴(Projects)을 발견한 게 흥미롭습니다. 한 번 짚었다고 같은 종류가 다른 도메인에서 안 나타나는 건 아닙니다. 다음 번 비슷한 리팩터를 할 때 체크리스트에 넣어 둬야겠다 싶었습니다.

토스트 하나, 즉시 응답 하나

사이드 트랙 두 개 더.

태스크에 날짜를 넣고 만들면, hideDatedTasks가 켜진 사용자에겐 그 태스크가 리스트에서 사라진 것처럼 보입니다. 그래서 “달력에 등록되었습니다” 토스트가 떠서 사라진 곳을 알려줍니다. 그런데 캘린더 화면에서 태스크를 만들었다면 사용자는 이미 캘린더 컨텍스트입니다. 토스트는 군더더기죠. selectedDate prop이 있으면 — 즉 캘린더 진입이면 — 토스트를 스킵하도록 한 줄만 추가했습니다.

라벨 attach/detach도 손봤습니다. 원래는 API 응답을 await한 다음에 로컬 state를 갱신하고 있었습니다. 서버 round-trip 동안엔 UI가 멈춰 보이죠. Optimistic으로 바꿨습니다 — 로컬을 먼저 갱신하고, attach 시엔 임시 ID의 LabelLinkModel을 즉시 push, 응답이 오면 진짜 link로 교체. 실패하면 snapshot으로 롤백. 작은 변경인데 체감은 꽤 다릅니다.

색은 캘린더의 그 줄과 같은 색

마지막은 디자인 결정 하나.

처음엔 active 섹션 헤더를 PRIMARY500(브랜드 블루)로 했습니다. 너무 정중했습니다. 강조도 약하고요. 무채색 한 단계 밝은 톤도 시도해 봤지만, “지금”이라는 의미가 잘 안 잡혔습니다.

캘린더의 “현재 시각” 인디케이터가 DANGER500(빨간 계열)이라는 걸 떠올리고, 그 색에 맞췄습니다. “지금”끼리 색이 통일되니, 다른 화면에서도 같은 빨간 줄이 보이면 지금이라는 신호로 읽힙니다. 강렬하긴 한데, 강렬해야 하는 자리입니다.

별 아이콘을 왼쪽에, “지금 진행 중” 라벨을 그 옆에, 접기 화살표를 오른쪽에. DateSeparator와 같은 골격을 따랐습니다. 다른 흐름이지만 같은 모양이어야 한다는 결의 연장선.


하루를 묶어 보면, 큰 기능 하나(active 섹션) + 같은 모양의 버그 두 개 + 작은 polish 셋 정도였습니다. 작은 빨간 줄 하나가 들어가는 데 이렇게 결정이 많은 게 신기하기도 하고, 익숙하기도 합니다.

“지금 뭐 해야 하지?”는 자기 자신에게 자주 물어보는 질문입니다. 도구가 그 질문에 0.5초 만에 답해 줄 수 있다면, 그건 작지 않은 차이입니다.