"밥 줬어?" "아까 줬는데?" "나도 줬는데…"
가족이 여럿인 집이면 한 번쯤 겪는 일입니다. 반려동물은 두 번 먹고, 약은 두 번 들어가고, 누구 하나 잘못한 사람은 없습니다. 이걸 없애 보려고 만든 앱이 돌봄 체크 입니다. 지금 비공개 테스트 중입니다.
만들면서 가장 오래 붙든 것은 알림도 화면도 아니고 같은 순간에 두 사람이 누르는 경우 였습니다. 실제로 일어나는 일이고, 하필 이 앱이 없애려던 문제 그 자체이기 때문입니다.
화면에서 잠그면 될 줄 알았다
처음 생각은 단순했습니다. 누르면 단추를 잠그고, 이미 체크된 항목은 다시 못 누르게 하면 되지 않느냐는 것입니다.
안 됩니다. 잠금은 각자의 폰 안에서만 참이기 때문입니다.
엄마가 누른 순간부터 그 사실이 아빠 폰에 도착하기까지는 시간이 걸립니다. 서버로 올라가고, 다시 내려오고, 화면이 다시 그려져야 합니다. 빨라도 1초, 지하철이면 몇 초입니다. 그 사이에 아빠 폰은 아직 "아무도 안 했음"입니다. 아빠가 그때 누르면 잠금은 아무 일도 하지 않습니다.
더 나쁜 것은 이 실패가 평소에는 안 보인다는 점입니다. 혼자 시험하면 절대 재현되지 않고, 두 사람이 같은 항목을 같은 몇 초 안에 누를 때만 나타납니다. 그런데 그 상황이 정확히 밥 때입니다.
그래서 막는 자리를 표로 옮겼다
판정을 한 곳에서만 하기로 했습니다. 두 화면이 서로를 몰라도, 표는 하나입니다.
체크 기록 표에 이런 제약을 걸었습니다.
unique (task_id, check_date)
같은 할 일에 같은 날짜는 한 줄만 들어갑니다. 그리고 넣을 때는 이렇게 합니다.
insert into check_logs (task_id, family_id, checked_by, check_date)
values (t.id, t.family_id, auth.uid(), d)
on conflict (task_id, check_date) do nothing;
두 번째로 들어온 요청은 조용히 아무것도 하지 않습니다. 둘이 정말 같은 밀리초에 도착해도 데이터베이스가 순서를 세우므로, 한 줄만 남는 것이 보장됩니다.
두 번째 사람에게 오류를 보여 주지 않는다
여기서 화면 쪽 결정이 하나 더 필요했습니다. 늦게 누른 사람에게 무엇을 보여 줄 것인가.
오류로 처리하면 아빠 입장에서는 앱이 고장 난 것처럼 보입니다. 그래서 서버가 "이미 있었음"을 결과로 돌려주고, 화면은 이렇게 말합니다.
이미 아빠가 했어요 · 19:05
「이미 했다」는 실패가 아니라 알고 싶은 정보입니다. 애초에 이 앱이 하려던 말이 그것 이기도 합니다.
하루의 경계를 어디서 자를 것인가
여기서 한 번 데었습니다. 날짜를 서버 기준으로 자르면 어떻게 되는지 생각해 보십시오.
서버 표준시가 협정 세계시(UTC)라면, 한국에서 밤 열한 시에 누른 체크는 같은 날 오후 두 시로 기록됩니다. 날짜 자체는 같지만, 새벽 한 시에 누르면 전날 오후 네 시가 됩니다. 즉 자정을 넘겨 챙긴 밥이 어제 것이 되고, 아침에 일어나면 오늘 밥이 아직 안 된 것처럼 보입니다.
그래서 날짜는 처음부터 한국 시간으로 자릅니다.
check_date date not null default (now() at time zone 'Asia/Seoul')::date
하루의 경계는 서버가 정하는 것이 아니라 쓰는 사람이 사는 곳이 정합니다.
"하루 한 번"으로는 부족했다
여기까지 하고 나니 매주와 매월이 들어왔습니다. 목욕은 매주, 심장사상충과 구충은 매월입니다.
unique (task_id, check_date) 는 하루에 한 번만 막습니다. 매월 항목이라면 이달 5일에 한 번 하고 12일에 또 해도 날짜가 다르니 둘 다 들어갑니다.
그래서 판정을 "오늘 했나"에서 "이번 주기에 했나"로 바꿨습니다. 주기의 시작 날짜를 구하는 함수를 두고, 그 날짜 이후의 기록이 있는지 봅니다.
ps := period_start(t.repeat, t.repeat_day, d);
if exists (select 1 from check_logs l where l.task_id = t.id and l.check_date >= ps) then
-- 이미 이번 주기에 누가 했다
그런데 이건 unique 제약이 대신 막아 주지 못합니다. 조회하고 나서 넣는 두 단계이므로, 두 사람이 동시에 들어오면 둘 다 "아직 없음"을 보고 둘 다 넣습니다. 처음 문제가 모양만 바꿔서 돌아온 것입니다.
그래서 할 일 한 줄을 먼저 잠급니다.
select * into t from tasks where id = p_task for update; -- 동시 체크 직렬화
같은 할 일에 대한 요청은 이 줄에서 한 줄로 세워집니다. 먼저 들어온 쪽이 끝나야 다음이 조회를 시작하므로, 둘 다 "아직 없음"을 보는 일이 없어집니다.
여기서 배운 것은 이것입니다 — 제약으로 막을 수 있으면 제약으로 막고, 조회 뒤에 판단해야 하면 그 판단을 직렬화해야 합니다. 둘을 섞어 두면 어느 쪽으로도 안 막힙니다.
인터넷이 끊긴 자리에서 누르면
산책 중이거나 지하 주차장이면 인터넷이 없습니다. 그래도 누를 수 있어야 합니다.
끊겼을 때 누른 체크는 폰에 쌓아 두었다가 연결되면 보냅니다. 여기서 한 가지가 중요합니다 — 보낸 시각이 아니라 누른 시각을 같이 보내야 합니다.
const at = new Date().toISOString(); // 누른 순간
...
await supabase.rpc('check_task', { p_task: taskId, p_at: at });
나중에 보내면서 그때 시각으로 적으면 "7시에 줬다"가 "9시에 줬다"가 됩니다. 급여 시간을 보려고 만든 앱에서 급여 시간이 틀어지면 기록이 있으나 마나입니다.
잃지 않으려다 오히려 잃었다
이 부분에서 실수를 했습니다. 처음에는 이렇게 짰습니다.
네트워크 오류가 아니면 큐에서 버린다.
그럴듯해 보입니다. 지워진 항목이나 권한 없는 요청을 영원히 재시도하면 줄이 막히니까요.
그런데 앱을 다시 열 때 로그인 토큰 갱신이 늦어서 나는 인증 오류가 있었습니다. 이건 네트워크 오류가 아닙니다. 그래서 눌러 둔 체크가 아무 말 없이 버려졌습니다. 화면에는 오류도 안 뜹니다. 그냥 안 눌린 것처럼 보입니다.
지금은 기준을 뒤집었습니다. 다시 보내도 결과가 같은 오류만 버립니다.
export const isPermanentError = (e) =>
/forbidden|not_due|stale_check/.test(e.message) || e.code === '22P02';
지워진 항목, 권한 없음, 아직 오지 않은 일정, 너무 늦은 체크 — 이 넷은 다시 보내도 같은 답이 옵니다. 그 밖에는 전부 남겨 두고 다음에 다시 보냅니다.
「버려도 되는 실패」와 「나중에 되는 실패」를 가르지 않으면, 조용히 사라지는 쪽으로 기웁니다. 그리고 조용히 사라지는 실패는 사용자가 신고할 수조차 없습니다.
막힌 줄이 생기지 않게
마지막으로 한 가지. 서버는 24시간 넘게 늦은 체크를 거부합니다. 그런데 큐는 앞에서부터 순서대로 보내므로, 영원히 거부당하는 한 줄이 맨 앞에 있으면 뒤의 것이 전부 못 나갑니다.
그래서 클라이언트가 먼저 버립니다. 서버 기준보다 여유를 둔 23.5시간입니다.
const MAX_AGE_MS = 23.5 * 3600 * 1000;
경계를 양쪽에 똑같이 두면 시계가 조금만 어긋나도 막힙니다. 거부하는 쪽보다 버리는 쪽이 먼저여야 합니다.
아직 확인하지 못한 것
- 실제로 두 사람이 같은 순간에 누르는 상황은 코드와 데이터베이스 수준에서는 막았지만, 진짜 가족이 며칠 이상 쓰면서 어떤 순간에 겹치는지는 아직 못 봤습니다. 비공개 테스트에서 보려는 것이 이것입니다.
- 오프라인 큐는 비행기 모드로 시험했습니다. 지하철처럼 끊겼다 붙었다 하는 자리는 아직 충분히 못 돌려 봤습니다.
- 알림이 실제로 얼마나 거슬리는지는 써 봐야 압니다. 밤에는 방해금지 시간을 두었지만 그 경계가 적절한지는 모릅니다.
정리
- 여러 사람이 같은 것을 누르는 앱에서 화면의 잠금은 막는 장치가 아닙니다. 각자의 화면 안에서만 참이고, 서로를 모르는 시간이 반드시 있습니다
- 제약으로 막을 수 있으면 제약으로 막습니다. 조회하고 나서 판단해야 하면 그 판단을 직렬화합니다
- 하루의 경계는 쓰는 사람이 사는 곳이 정합니다. 서버 표준시로 자르면 밤에 챙긴 일이 어제 것이 됩니다
- 나중에 보내는 기록은 누른 시각을 들고 가야 합니다
- 재시도 큐에서 버릴 것은 "다시 보내도 결과가 같은 오류" 뿐입니다. 그 선을 넓게 잡으면 기록이 조용히 사라집니다