날짜가 정해지기 전의 일정이 들어갈 곳이 없었다
DaySeed 에서 함께 쓰는 사람과 나누는 것은 지금까지 "날짜가 정해진 일정" 하나뿐이었습니다. 그런데 일정은 대부분 그 전 단계에서 시작합니다. 언젠가 가 보고 싶은 곳, 먹어 보고 싶은 것, 사고 싶은 것. 이런 건 날짜가 없으니 캘린더에 넣을 수 없었고, 카톡이나 메모앱에 흩어져 있다가 정작 일정을 잡을 때는 다시 찾지 못합니다. "다들 좋아?" 를 묻는 과정도 앱 밖 채팅에서 일어나고, 정해지고 나면 일정을 손으로 다시 입력해야 했습니다.
10월 19일 intent 에 적은 제품 결정은 이렇습니다. 진입은 별도 탭이 아니라 공유 캘린더 안, 아래쪽 일정 추가 버튼 왼쪽에 놓는 "♡ 위시 3" 알약입니다. 위시에는 제목·카테고리·메모와 함께 사진(최대 5장)·가격·링크·장소를 붙일 수 있습니다. 다른 멤버는 "좋아요" 또는 "다음에" 로 답하고, 작성자를 뺀 멤버가 전부 좋아요를 누르면 "일정으로 만들기" 가 열립니다. 누르면 제목·장소·메모·링크가 채워진 일정 폼이 뜨고, 날짜만 고르면 됩니다. 혼자 쓰는 사람은 승인 없이 바로 일정으로 만들 수 있는 개인 위시를 씁니다.
위시가 들어가기 전의 캘린더 월 화면(스토어 스크린샷)입니다. 위시 알약은 이 화면 오른쪽 아래 + 버튼 왼쪽에 붙습니다.
선물하기, 특정 멤버에게 숨기기, 링크를 붙이면 썸네일과 가격을 자동으로 가져오는 기능은 이번 범위에서 뺐습니다.
"전원"을 언제 기준으로 셀 것인가
spec 에서 규칙이 가장 많이 붙은 곳은 승인 판정입니다. 그룹은 2~4명이고 사람이 들어오고 나갑니다. 표를 받을 때의 멤버로 셀지, 지금 멤버로 셀지를 정해야 했습니다. spec 에서 정한 규칙은 이렇습니다. 승인자는 지금 그룹 멤버 중 작성자를 뺀 전원입니다. 새 멤버가 들어오면 그 사람도 승인자가 되니 이미 승인 완료였던 위시가 다시 대기로 돌아갈 수 있고, 나간 사람의 표는 세지 않습니다.
이 판정을 앱이 아니라 Postgres 함수 하나에 두었습니다.
create or replace function public.wish_is_approved(p_wish uuid)
returns boolean
language sql
stable
security definer
set search_path = public
as $$
select case
when w.group_id is null then true
else not exists (
select 1 from public.group_members gm
where gm.group_id = w.group_id
and gm.user_id <> w.user_id
and not exists (
select 1 from public.wish_votes v
where v.wish_id = w.id and v.user_id = gm.user_id and v.vote = 'yes'
)
)
end
from public.wishes w
where w.id = p_wish;
$$;
표를 세지 않고 "지금 멤버 중 좋아요가 없는 사람이 있는가" 를 묻습니다. 나간 사람의 표는 멤버 목록과 맞물리지 않아 저절로 빠지고, 작성자 혼자 남은 그룹은 그대로 승인입니다. 목록 요약, 일정 연결 RPC link_wish_event, 승인 알림 트리거가 모두 이 함수를 부릅니다.
알림은 한 번만 보냅니다. 3명 그룹에서 한 사람이 좋아요와 다음에를 오가면 승인 완료와 대기가 반복될 수 있는데, 그때마다 작성자에게 "모두 좋대요" 가 가면 곤란합니다. 그래서 위시에 approved_notified_at 을 두고, 투표 트리거는 이 값이 비어 있을 때만 판정을 돌린 뒤 기록하고 푸시합니다.
select * into w from public.wishes where id = NEW.wish_id for update;
if w.id is null or w.group_id is null or w.approved_notified_at is not null then
return NEW;
end if;
if not public.wish_is_approved(w.id) then
return NEW;
end if;
update public.wishes set approved_notified_at = now() where id = w.id;
이 컬럼과 일정 연결 event_id 는 컬럼 권한으로 막아 앱이 직접 쓰지 못하고, 연결은 승인을 확인하는 RPC 를 거쳐야만 바뀝니다. 승인 뒤에 작성자가 위시를 고치면 표를 초기화할지도 정해야 했는데, PO 결정은 "표 유지" 였습니다. 기록에 그 이유는 따로 남아 있지 않습니다.
구버전 앱과 OTA 범위 지키기
목표는 새 바이너리 없이 DB 와 runtime 2.1.2 OTA 로 내보내는 것이었고, 여기서 제약이 두 개 생겼습니다.
하나는 히스토리입니다. 위시 활동(올림·모두 좋아요·댓글·일정으로 만듦)은 기존 일정 히스토리 테이블에 action 4종을 더해 쌓습니다. 그런데 2.1.1 이하 앱은 모르는 action 을 받으면 화면이 어긋날 수 있습니다. 그래서 기존 list_event_activity 는 위시 행을 빼고 돌려주게 두고, 위시를 아는 앱용으로 list_activity_v2 를 새로 만들었습니다. 앱은 v2 를 먼저 부르고, 마이그레이션 전이면 v1 로 물러납니다.
// list_activity_v2 (wishlist migration 20261020000000) adds wish rows and
// wish_id; before that migration is live, fall back to the v1 RPC.
let { data, error } = await supabase.rpc('list_activity_v2', { p_group_id: groupId, p_limit: limit });
if (error) {
({ data, error } = await supabase.rpc('list_event_activity', { p_group_id: groupId, p_limit: limit }));
}
다른 하나는 사진 권한입니다. 처음에는 사진 보관함 권한을 먼저 요청했는데, 시뮬레이터에서 확인하다 보니 이 권한 요청을 띄우면 2.1.x 바이너리에 들어 있는 설명 문구가 나옵니다. 그 문구는 "사진 한 장을 보내고 저장하지 않습니다" 라는, 사진으로 일정을 만드는 기능에 맞춘 내용이라 사진을 서버에 저장하는 위시와는 반대 말이 됩니다. 문구는 바이너리에 있어서 OTA 로는 못 바꿉니다. 그래서 권한 요청을 빼고 시스템 사진 선택기만 쓰기로 했습니다. 선택기는 앱 밖에서 돌고 사용자가 고른 사진만 넘겨주니 권한이 없어도 됩니다. 바이너리 문구는 2.1.3 에서 고치기로 커밋에 적어 두었습니다.
댓글도 같은 이유로 길을 바꿨습니다. spec 에서는 일정 화면의 댓글 로직을 공용으로 빼낼 계획이었지만, plan 의 T3 에는 "일정 화면 댓글은 그대로 두고(OTA 회귀 위험)" 같은 동작의 CommentThread 를 새로 만들어 위시가 쓰는 쪽으로 바뀐 기록이 있습니다.
시뮬레이터에서 잡은 것과 출시 기록
plan 의 T10 기록에 따르면 별도 시뮬레이터(iPhone 17, iOS 26.5)에서 리뷰어 계정과 운영 DB 로 개인 위시(사진 3장) → 일정으로 만들기 → 연결, 임시 그룹에서 좋아요 → 모두 좋아요(서버에 1회 기록), 반응·댓글·히스토리 4종, 삭제 시 사진 파일까지 삭제되는지를 확인했습니다. 그 전에 DB 는 로컬 PG14 에서 시나리오 11개로 리허설했습니다. 멤버가 합류하면 승인이 풀리는지, 연결 일정을 지우면 위시가 다시 진행 중으로 보이는지, v1 히스토리에 위시 행이 없는지, 비멤버의 투표와 event_id 직접 수정이 거부되는지가 그 안에 들어 있습니다.
시뮬레이터에서 나온 버그 중 하나는 가격 입력이었습니다. 입력하는 동안 천 단위 쉼표를 다시 넣었더니 커서가 밀려서 "98000" 을 치면 "980,005" 가 됐습니다. 입력 중에는 숫자만 받고 쉼표는 입력을 마칠 때 붙이도록 바꿨습니다.
// Digits only while typing — reformatting with commas mid-edit
// moves the caret and scrambles the next keystrokes ("98000" →
// "980,005"). Separators come back on blur.
onChangeText={(v) => {
touch();
setPriceText(v.replace(/[^\d]/g, ''));
}}
onFocus={() => setPriceText((p) => p.replace(/[^\d]/g, ''))}
사진 업로드에도 한 군데 손을 댔습니다. 업로드 본문을 정확한 길이의 ArrayBuffer 로 잘라 넘기게 바꿨는데, 코드 주석에는 iOS 에서 fetch(file://) 로 만든 Blob 이 비어서 도착한다는 이유가 적혀 있습니다. 업로드 전에는 긴 변 1600px, JPEG 0.75 로 다시 인코딩해 EXIF 위치 정보를 지웁니다. 사진을 서버에 저장하는 첫 기능이라 개인정보처리방침에 위시 사진 문장을 넣었고, App Store 개인정보 항목에 "사진 또는 비디오" 를 "사용자의 신원에 연결됨" 으로 게시했습니다.
출시는 같은 날입니다. 운영 DB 에 마이그레이션을 올린 뒤 롤백 DO 블록으로 RLS 를 다시 확인했고, OTA runtime 2.1.2 그룹 96d6d6cd 로 앱을 내보냈습니다. intent 에 적어 둔 판정 지표(위시 생성 수, 승인 완료 비율, 위시에서 일정으로 넘어간 수 등)의 수치는 아직 기록에 없습니다. 남은 일은 바이너리의 사진 권한 문구 수정(2.1.3)입니다. 앱이 중간에 멈춰 Storage 에 남는 사진 파일을 매일 정리하는 크론도 spec 에 "plan 에서 정한다" 고 적혀 있는데, plan 에는 아직 그 항목이 없습니다.
DaySeed 는 매일의 습관을 한 번의 탭으로 기록하고 캘린더를 가장 가까운 사람과 함께 쓰는 iOS 앱입니다. App Store 에서 받기