Skip to main content
← 블로그

칩 하나 붙이려다 리비전을 달다 — 프로젝트 실시간성의 하루

VauDium·

목록 아이템의 프로젝트 칩에 별 하나 붙이는 일로 시작했습니다. 저녁에는 프로젝트와 마일스톤에 리비전이 생기고, 태스크 캐시를 통째로 다시 쓰던 구조가 렌더 시 조인으로 바뀌어 있었습니다. 그 사이의 발견들을 순서대로 적습니다.

칩 하나 붙이려다 리비전을 달다 — 프로젝트 실시간성의 하루

페칫의 할 일 목록에는 소속 프로젝트를 알려주는 작은 칩이 있습니다. 회색 바탕에 약어 세 글자. 오늘 아침의 요청은 이랬습니다.

“리스트 아이템에 프로젝트 칩에 프로젝트 아이콘 붙여 볼까?”

앱에서 프로젝트를 뜻하는 글리프는 이미 정해져 있었습니다. 워크스페이스 스위처와 프로젝트 목록 행이 쓰는 오각별. 그걸 칩 앞에 10px로 놓으면 끝나는 일이었어요. 끝나지 않았습니다.

같은 10px인데 크기가 다르다

별을 붙이자마자 칩 양옆에 여백이 보였습니다. 오각별 SVG는 24 박스 안에서 몸체가 60%만 차지합니다. 할 일에 쓰는 4망성은 83%에 선 두께까지 있어 박스를 꽉 채우고요. 같은 width={10}이어도 하나는 6px, 하나는 9px로 그려집니다.

SVG를 다시 그려 통일하는 게 정석이지만, 오각별은 32개 파일 45곳에서 쓰이고 있었습니다. 다 1.4배 커지는 걸 감수하거나 45곳의 크기를 손으로 0.72배 하거나. 둘 다 별 하나를 위한 값으론 컸습니다. 칩 안에서만 SVG의 빈 여백만큼 음수 마진을 줬습니다. 이 결정은 뒤에 마일스톤 깃발에서 한 번 더 반복됩니다. 깃발은 박스를 거의 채워서 같은 10px이면 별보다 커 보였고, 계산상 7.7px이 맞아 8px로 갔습니다.

칩이 하나 더 생기자 서버가 필요해졌다

“마일스톤을 보여주는 것까지 이어서 가 볼까?”

프로젝트 칩 옆에 깃발과 마일스톤 제목. 문제는 태스크 응답에 마일스톤 id만 있고 제목이 없다는 것이었습니다. 클라이언트가 프로젝트별 마일스톤을 가져오는 건 “나” 스코프에선 상한 없는 fetch라 금지된 패턴이었고, 남는 길은 서버가 태스크 응답에 마일스톤 요약을 실어 주는 것이었습니다. 프로젝트 약어를 실어 주던 것과 같은 읽기 시점 조인 하나를 더 붙였습니다.

여기까지는 계획대로였습니다. 로컬 서버를 켜고 앱을 열었는데 칩이 안 떴습니다. 상세 응답과 목록 응답이 다른 경로로 조립되고 있었고, 목록 쪽 일괄 병합 여섯 군데에 새 조인이 빠져 있었던 겁니다. 채우고 나니 떴습니다.

프로젝트를 바꿔도 칩이 안 바뀐다

“마일스톤 색깔은 프로젝트랑 다르게 따로 있을 건데 이것도 마일스톤 변경에 따라서 변경이 가능한가?”

가능해야 했습니다. 확인해 보니 두 겹의 문제가 있었어요.

첫째, 모바일 목록 훅에는 “태스크 revision이 같으면 옛 객체를 재사용”하는 가드가 있습니다. 재조회 때마다 전 행을 새 객체로 바꾸면 리렌더가 폭주하니 필요한 가드죠. 그런데 프로젝트 약어나 마일스톤 제목은 태스크 문서를 건드리지 않습니다. revision이 안 오릅니다. 재조회를 해도 가드가 옛 객체를 돌려주고, 칩은 영원히 옛 값이었습니다. 지난 3월에 “프로젝트 변경 시 즉시 반영”으로 만든 경로가 7월에 이 가드가 들어오면서 조용히 죽어 있었던 거예요.

둘째, 더 근본적인 것. “우리 Project에 revision이 있나?” — 없었습니다. revisionrevised_at도. 태스크는 세 겹(문서 revision, 필드별 revision, 갱신 시각)을 갖고 있는데 프로젝트만 빠져 있었습니다. 그래서 태스크 sync가 프로젝트 변경을 못 따라오고, 프로젝트 목록 병합은 늦은 응답을 순서 가드로만 막고, 두 기기의 동시 편집은 마지막 쓰기가 이깁니다. 전부 한 뿌리였습니다.

리비전 세 겹, 그리고 CAS

프로젝트와 마일스톤에 태스크와 같은 세 겹을 달았습니다. 프로젝트 문서를 건드리는 쓰기 18곳 전부가 $inc revision과 갱신 시각을 남기고, 제목·약어·설명·작문 7개는 클라이언트가 마지막으로 본 필드 revision을 보내면 서버가 비교해 어긋나면 409를 돌려줍니다.

클라이언트 쪽은 태스크의 문법을 그대로 옮겼습니다. 409가 오면 그 필드는 “충돌” 상태가 되고, 입력하던 텍스트는 그대로 두고, 자동 저장은 통과하지 않고, 필드 아래 한 줄 바가 붙습니다. [덮어쓰기]는 최신 revision으로 다시 저장, [최신 가져오기]는 내 텍스트를 보관함에 남기고 서버 값으로. 처음엔 보관함 없이 갔다가 “프로젝트에도 보관함 기능을 넣자”는 말에 재진입 시 바가 다시 붙는 것까지 붙였습니다. 마일스톤은 저장 티켓이 필드 묶음이라 revision 맵으로 받고, 모바일에서는 화면을 나가며 저장하던 것을 필드별로 쪼개 409를 필드 단위로 받게 했습니다.

“캐시를 전부 다시 쓰는 건 비효율적이잖아?”

여기까지 하고 나니 구조가 눈에 들어왔습니다. 태스크 문서마다 프로젝트와 마일스톤 정보가 복제돼 있고, 원본이 바뀌면 그 프로젝트의 태스크 행을 전부 다시 씁니다. 태스크 500개짜리 프로젝트의 색을 바꾸면 SQLite 쓰기 500번. 서버가 저사양이라 서버 부하를 줄이자는 원칙은 있었지만, 클라이언트도 같은 실수를 하고 있었던 거죠.

정규화했습니다. 태스크 행은 projectIdmilestoneId만 보고, 프로젝트는 프로젝트 atom에서, 마일스톤은 새로 만든 조회 테이블에서 렌더할 때 찾습니다. 서버가 실어 준 임베드는 폴백으로만 남겼습니다. 원본이 바뀌면 테이블 한 칸이 바뀌고, 그 칸을 보는 행 전부가 따라옵니다. 태스크 수와 무관합니다.

실시간도 같은 결로 정리됐습니다. 마일스톤 라우트는 지금까지 변경 이벤트를 아예 내지 않고 있었습니다. 프로젝트 변경과 같은 project_updated를 내게 하고, 모바일에는 데스크톱에만 있던 “이벤트 받으면 그 프로젝트 하나만 재조회” 훅을 옮겨 심었습니다. 변경 1건의 비용은 단건 GET 한 번과 Map 갱신입니다.

목록만 고쳤더니 상세가 남았다

“테스크에서 마일스톤으로 들어가서 색깔을 변경하고 밖으로 나왔을 때 바로 반영이 안 되네?”

목록 칩은 조인을 하고 있었는데, 태스크 상세는 마일스톤 목록을 진입할 때 한 번 받아 그 안에서 찾고 있었습니다. 같은 병입니다. 상세의 마일스톤 행도 조회 테이블의 최신값을 덧씌우게 했고, 상세가 받아온 목록은 테이블에 넣어 두 화면이 같은 값을 보게 했습니다. 모바일 상세 네 곳과 데스크톱 상세 하나.

폴백은 폴백대로 필요하다

프로젝트 atom은 활성 프로젝트만 담습니다. 완료된 프로젝트의 태스크는 조회 테이블에 프로젝트가 없어서 서버 임베드로 그려집니다. 임베드를 나중에 서버에서 빼려면 이 구멍부터 메워야 했습니다. 조회 테이블에 없는 id는 단건 조회로 채우는 경로를 만들었습니다. 같은 id의 동시 요청은 하나로 합치고, 404는 5분 부정 캐시로 막고, 세션 안에서 id당 GET 한 번. 완료 프로젝트의 태스크가 목록에 100개 있어도 그 프로젝트 GET은 한 번입니다.

임베드를 실제로 빼는 것과 변경 통지의 멤버 목록 캐시는 한 달 뒤 얘기입니다. 구 앱이 남아 있는 동안은 임베드가 그 앱의 유일한 정보원이니까요.

그리고 별은 은하가 됐다, 두 번 만에

저녁에 다시 아침의 별로 돌아왔습니다. “프로젝트 아이콘을 은하로 해 볼까?” 할 일이 별 하나라면 프로젝트는 별들의 집합이니까요. 다만 별을 여러 개 흩뿌리는 표현은 커뮤니티 아이콘이 이미 쓰고 있어서 한 덩어리 실루엣이어야 했습니다.

손으로 그리지 않고 로그 나선을 수식으로 뽑았습니다. 핵에서 두 팔이 감겨 나가고, 팔은 핵 근처에서 굵다가 바깥으로 갈수록 가늘어집니다. 세 번을 고쳤는데 전부 핵과 팔 사이의 작은 구멍 때문이었고, 바깥 모양은 그대로 둔 채 팔 시작에 같은 굵기의 짧은 앞 구간을 핵 속으로 이어 붙이는 것으로 끝났습니다. 31곳을 바꾸고 커밋까지 했습니다.

그리고 실물에서 보니 별로였습니다. 비교 페이지의 96px에서 은하였던 것이 칩의 8px에서는 흐릿한 얼룩이었어요. 되돌렸습니다. 원인은 둘이었습니다. 새 글리프가 박스를 꽉 채우니 오각별과 같은 크기로 보이라고 숫자를 0.7배로 줄였던 것, 그리고 바깥으로 갈수록 가늘어지던 꼬리. 옆에서 본 은하도 한 번 그려 봤는데 아이콘 크기에서는 비행접시로 읽혔습니다.

두 번째 시도는 반대로 갔습니다. 크기 숫자는 원래대로 두어 은하가 박스를 꽉 채우게 하고, 팔은 1.0바퀴만 감되 꼬리를 두껍게. Me 탭의 목록 행에서만 살짝 줄였습니다. 이번엔 실물에서도 은하였습니다.

배운 것

갱신 시점을 모르면 실시간은 없습니다. 이벤트를 아무리 뿌려도, 받는 쪽이 “이게 내가 가진 것보다 새로운가”를 판단할 근거가 없으면 덮어쓸지 말지를 정할 수 없습니다. 프로젝트에 revision이 없었다는 건 프로젝트가 언제 바뀌었는지 서버도 클라이언트도 모른다는 뜻이었고, 오늘 고친 대부분의 증상이 거기서 나왔습니다.

복제는 쓰기 비용을 읽는 쪽으로 옮긴 것뿐입니다. 응답에 조인된 값을 실어 두면 화면 코드는 편하지만, 원본이 바뀔 때마다 복제본을 찾아 다시 써야 합니다. 서버는 id와 revision을 주고 클라이언트가 조립하는 쪽이 저사양 서버에도, 기기 안 캐시에도 맞았습니다.

가드는 무엇을 막는지 적어 둬야 합니다. “revision이 같으면 재사용”은 옳은 가드였지만, revision을 안 올리는 값이 뒤에 추가될 때마다 그 값은 조용히 얼어붙었습니다. 오늘은 그 가드가 보는 항목을 명시적으로 줄이고, 나머지는 조인으로 빼는 것으로 정리했습니다.

별 하나 붙이는 일이었습니다. 그 별은 이제 은하입니다.