chatmu · .scratch/unread-badge

안 읽음 — 무엇이 언제 발동하나

배지가 오르내리는 일은 거의 다 화면 안에서 끝난다. 서버로 올라가는 것은 읽음 커서 하나뿐이고, 그것도 10초에 한 번이다. 이 문서는 그 셋 — 올라가는 것, 내려오는 것, 화면에서 끝나는 것 — 이 각각 언제 발동하는지를 적는다.

↑ REST
브라우저가 백엔드에 말한다. 이 문서에 딱 하나 있다 — PUT /rooms/:id/read
↓ 푸시·응답
백엔드가 브라우저로 민다. 소켓은 받기 전용이다 (ADR 0003)
· 화면 안
왕복이 없다. 배지 +1·−1 이 여기 산다

트레이스 1한 번 훑기 — 보고 있는 방에 말이 쌓인다

Alice 는 탭 A 로 10번 방을 보고 있고, 은 같은 계정이지만 아무 방도 안 보고 있다. Bob 이 말한다.

시각탭 A (10번 방)백엔드
방을 연다
t=0
↑ GET/rooms/10/messages · /rooms서버가 준 unreadCount 와 커서를 그대로 안다 — 화면이 짐작하지 않는다
· 상태맨 아래에 붙어 있다 followBottom — 스크롤 지킴이가 이미 아는 값이다
Bob 이 101 을 쓴다
t=1
↓ 소켓message.created(101) — 탭 A 와 폰 둘 다 받는다소켓은 내가 속한 모든 방을 듣는다
· 탭 A타임라인에 붙이고 커서 후보 = 101
· 폰안 보는 방 → 배지 +1
↑ PUT/rooms/10/read { 101 }스로틀 창이 비어 있으므로 첫 발은 즉시
↓ 응답{ unreadCount: 0 } — 탭 A 가 즉시 맞는다
↓ 소켓read.changed { roomId: 10, unreadCount: 0 }폰이 배지를 0 으로. GET 을 한 번도 안 한다
Bob 이 두 번 더 쓴다 — 스로틀 창 안이다
t=3
↓ 소켓message.created(102)
· 탭 A커서 후보 = 102 — 안 보낸다
t=5
↓ 소켓message.created(103)
· 탭 A커서 후보 = 103 — 예약값만 갈아끼운다
창이 끝난다
t=11
↑ PUT/rooms/10/read { 103 }한 번. 102 는 안 보낸다서버가 GREATEST 로 받으므로 103 하나가 앞의 둘을 포함한다
↓ 소켓read.changed { roomId: 10, unreadCount: 0 }
중간 값을 버려도 되는 것이 이 설계의 주춧돌이다. 커서가 뒤로 안 가기 때문에 103 하나가 101·102 를 포함한다 — 그 성질이 없으면 값을 건너뛰는 어떤 방식도 못 쓴다.

트레이스 2위로 올라가 읽는 중이면 커서가 멈춘다

같은 방을 보고 있어도 맨 아래에 붙어 있지 않으면 읽는 중이 아니다. 그때는 새 말이 와도 커서가 안 움직이고, 대신 배지가 오른다.

t=12
· 탭 A위로 스크롤해 과거를 읽는다 → followBottom = false
t=13
↓ 소켓message.created(104)
· 탭 A타임라인에는 붙는다 (같은 방이니까)그러나 커서는 안 움직인다 → 배지 +1. PUT 도 없다
t=18
· 탭 A다시 맨 아래로 내려온다 → 커서 후보 = 104
↑ PUT/rooms/10/read { 104 }

「맨 아래에 붙어 있나」는 lib/useScrollKeeper.ts 가 이미 아는 값이다. 새 상태를 만들 자리가 아니다.

트레이스 3떠날 때 — 남은 것을 보낸다

t=20
· 탭 A탭을 닫는다 / 다른 탭으로 옮긴다pagehide · visibilitychange: hidden
↑ flushfetch(…, { keepalive: true })응답을 안 기다린다 — 문서가 사라져도 요청은 살아남는다
navigator.sendBeacon 은 못 쓴다. 이 앱의 인증은 Authorization: Bearer 인데 sendBeacon커스텀 헤더를 못 붙인다. 조용히 401 이 되고, 하필 떠나는 순간이라 아무도 못 본다.

안 하면 최대 10초어치 읽음이 날아간다 — 다음에 열었을 때 이미 읽은 것이 안 읽음으로 뜬다.

트리거 — 이것만 보면 된다

트리거 커서 배지 서버로
방을 연다서버 값을 받아 안다서버 값GET 만
message.created — 보는 방 + 맨 아래전진0 유지⏳ 스로틀
message.created — 보는 방 + 위에서 읽는 중그대로+1
message.created다른 방그대로+1
message.updated지움, 커서보다 뒤그대로−1
message.updated수정그대로그대로
내가 쓴 것 (어느 경우든)전진안 센다⏳ 스로틀
스로틀 창 만료↑ PUT 한 번
맨 아래로 내려온다전진0 으로⏳ 스로틀
탭 숨김 · 닫힘↑ flush
PUT 응답 도착서버 값으로 맞춤서버 값으로 맞춤
read.changed 도착 (다른 탭·기기)그 숫자로
재연결서버 값서버 값GET

즉시 안 보내고 스로틀 창에 맡긴다는 뜻이다. 창이 비어 있으면 첫 발은 즉시 나간다.

마지막화면의 셈은 정본이 아니다

배지의 +1·−1왕복을 아끼는 최적화일 뿐이다. 끊겨 있던 동안 놓친 이벤트, 두 탭의 경쟁, 상한(99+) 근처의 셈 — 어긋날 길은 많다.