필터를 열고, 그 안에서 움직인다 — Table 저장 필터 개선기
저장 필터에 검색·공유·편집을 붙였더니 화면이 난장판이 됐다. 원인은 기능 수가 아니라 '지금 어떤 필터를 쓰고 있는가'를 값 비교로 판정한 모델에 있었다. 포커스 모델로 갈아엎으며 배운 것.
필터를 열고, 그 안에서 움직인다 — Table 저장 필터 개선기
fecit 데스크톱의 Table 뷰에는 저장 필터가 있습니다. 상태·키워드·우선순위·담당자 같은 조건을 조합해 이름을 붙여 두고, 칩 한 번으로 꺼내 쓰는 기능이죠. 여기에 네 가지를 더하는 걸로 이번 작업이 시작됐습니다. 저장 필터 이름 검색, 필터를 남에게 보내는 공유 링크, 저장 필터의 이름·조건 편집, 그리고 지금 걸려 있는 조건의 표시.
네 기능을 다 붙였습니다. 그리고 화면을 보는 순간 알았습니다. 난장판이었습니다.
값으로 판정하면 정체성이 사라진다
원인은 기능 수에 있지 않았습니다. 첫 구현에서 “지금 어떤 저장 필터가 활성인가”의 판정이 이랬습니다.
현재 조건을 직렬화한 JSON이 저장 필터의 JSON과 완전히 일치하면 그 필터가 활성.
값 비교는 구현하기엔 제일 쉽습니다. 그런데 사용자가 필터를 적용하고 조건을 하나만 바꾸면, 그 순간 일치가 깨지면서 연결이 끊깁니다. 화면상으로는 방금까지 쓰던 필터의 칩이 조용히 꺼지고, 지금 조건은 어디에도 소속되지 않은 고아가 됩니다. 그러니 “수정한 조건을 그 필터에 다시 저장”이라는 자연스러운 동작이 성립할 자리가 없고, 결국 “현재 필터로 덮어쓰기”라는 별개 메뉴가 목록 구석에 생겼습니다. 조건 칩마다 개별 해제 버튼과 클릭 편집이 붙고, 활성 표시는 우연의 일치에 기대고… 기능 하나하나는 말이 되는데 전체가 문장이 되지 않았습니다.
UI가 난장판이면 대체로 UI 밑의 모델이 난장판입니다.
포커스 모델
그래서 판정을 값에서 정체성으로 바꿨습니다. 필터 칩을 클릭하면 그 필터가 열립니다. 조건이 적용되는 건 물론이고, “지금 이 필터 안에 있다”는 상태가 ID로 기록됩니다. 이후 컬럼 헤더 메뉴로 조건을 아무리 바꿔도 소속은 유지됩니다. 열려 있는 필터의 작업 사본을 편집하고 있는 것이니까요. 반영하고 싶으면 칩의 ⋯ 메뉴에서 업데이트를 누르고, 칩을 다시 클릭하면 필터가 닫히며 기본 상태로 돌아갑니다.
열고 → 그 안에서 움직이고 → 저장하거나 닫는다. 이제 한 문장이 됩니다.
재미있는 건 모델을 바꾸자 UI가 스스로 줄어든 점입니다.
- “덮어쓰기” 메뉴가 사라졌습니다. 열려 있는 필터가 있으니 “업데이트”가 자연스러운 첫 번째 액션이 됩니다.
- 변경됨 표시(●)와 인라인 저장/되돌리기 버튼을 지웠습니다. 처음엔 수정되면 칩 옆에 버튼이 튀어나오게 했는데, 바가 계속 흔들렸습니다. 수정하고 저장하는 흐름이 명확하니 별도의 더티 표시 없이 ⋯ 안의 업데이트 하나로 충분했습니다.
- 조건 칩은 결국 통째로 사라졌습니다. 처음엔 조건마다 해제 버튼이 달린 칩을 나열했고, 다음엔 표시 전용으로 줄였다가, 마지막엔 제거했습니다. 열려 있는 필터는 칩이 조건 묶음을 대표하고, 걸려 있는 조건은 해당 컬럼 헤더의 필터 아이콘으로 이미 보입니다. “현재 조건의 표시”라는 처음 요구는 남았지만, 그 답이 별도의 칩 줄일 필요는 없었습니다.
액션은 ⋯ 팝오버 한 곳으로 모였습니다. 업데이트, 이름 변경, 보관, 링크 복사, 삭제. 바에 있는 칩이든 목록의 행이든 같은 메뉴가 열립니다.
핀에서 보관으로
첫 구현은 즐겨찾기 방식이었습니다. 저장 필터 중 핀을 꽂은 것만 바에 노출하는 구조였죠. 뒤집었습니다. 저장하면 즉시 바에 칩이 생기고, 숨기고 싶은 것만 보관합니다. 방금 만든 필터가 어디 갔는지 목록을 뒤지는 일이 없어야 하니까요. fecit의 꼬리표가 이미 쓰는 ACTIVE/ARCHIVED 문법이라 앱 안에서 새로 배울 것도 없습니다. 저장 진입점도 목록 안에서 바 위의 “+ 현재 필터 저장” 버튼으로 꺼냈습니다.
공유는 주소다
필터 공유는 서버에 공유 객체를 만들지 않았습니다. 조건 전체를 base64url로 직렬화해 URL 파라미터에 실었습니다.
fecit.vaudium.net/table?scope=<프로젝트>&filter=<직렬화된 조건>
받는 쪽은 링크를 열면 워크스페이스 스코프가 전환되고 같은 조건이 그대로 적용됩니다. 서버에 상태가 없으니 만료도 정리도 없습니다.
여기서 한 발 더 갔습니다. fecit 웹은 “주소창 복붙 = 지금 보는 화면”을 원칙으로 상세 문서와 스코프를 이미 URL에 동기화하고 있었는데, 이번에 적용 중인 필터도 상시 동기화했습니다. 조건을 바꾸면 주소창이 따라오고, 기본값으로 돌아가면 파라미터가 사라집니다. 그래서 공유 버튼을 찾을 필요도 없이, 주소창을 복사하면 그게 곧 필터 공유입니다.
서버도 같이 — 임베디드 배열과의 이별
저장 필터는 원래 프로젝트 문서 안의 임베디드 배열이었습니다. 멤버들이 필터를 계속 만들면 프로젝트 문서가 한없이 커지는 구조죠. 이번에 자체 컬렉션으로 분리했습니다. 앞서 마일스톤을 분리할 때 만든 전례를 그대로 따랐습니다. 임베드 시절의 key를 새 문서의 _id로 승격해서 클라이언트가 들고 있는 식별자가 전부 그대로 유효하고, 응답 shape도 유지되어 클라이언트는 한 줄도 고치지 않았습니다. 백필 스크립트는 몇 번을 돌려도 안전하게(idempotent) 짜서 실서비스에 적용했습니다.
부산물도 하나 있었습니다. 마일스톤의 “프로젝트당 50개” 하드캡은 임베디드 배열 시절 문서 크기를 유계로 묶기 위한 장치였는데, 컬렉션으로 분리된 지금은 지킬 것이 없는 규칙이었습니다. 제거했습니다. 제약을 들어낼 때는 그 제약이 원래 무엇을 지키고 있었는지부터 확인하게 됩니다.
배운 것
“지금 무엇을 쓰고 있는가”를 값 비교로 판정하면 정체성이 사라집니다. 정체성이 없으면 수정·저장·공유 같은 동작이 전부 갈 곳을 잃고, 그 공백을 메우려는 보조 UI가 하나씩 늘면서 화면이 난장판이 됩니다. 판정을 ID 기반 포커스로 바꾸는 몇 줄이 기능 네 개를 붙일 때보다 화면을 훨씬 많이 정리했습니다.
이 모델은 iPad의 Table 뷰에도 같은 문법으로 옮겼습니다. 칩 탭, ⋯ 팝오버, 보관, 링크 복사까지 대칭이고, 터치라서 다른 건 칩 드래그 대신 목록의 ↑ 버튼 하나뿐입니다.