시작은 일정이었어요. 둘이 같이 보는 캘린더 하나를 먼저 세우고, 커플에게 꼭 필요한 기능만 골라 뽑아 붙이기로 했습니다. 일정을 붙이고 나니 데이트 계획을 짜는 자리가 자연스레 따라왔고, 다녀온 하루를 남기는 아카이빙도 필요해졌어요. 매일 들어올 이유는 그다음 문제였는데, 오늘의 주제·게임·스토리가 거기서 나왔습니다.

오늘의 주제·추천곡·게임이 하루치로 깔립니다

둘의 일정과 기념일을 한 달로 봅니다

가보고 싶은 곳을 쟁여두는 자리입니다

다녀온 하루가 사진·코스가 붙은 한 장으로 남습니다

게시물과 24시간 스토리가 쌓입니다
장소를 검색해 후보를 띄우고, 담은 곳만 우리 쪽에 저장한다
추천 곡의 앨범 이미지와 30초 미리듣기를 붙이고, 실제로 있는 곡인지 확인한다
어떤 서비스인가요?
커플앱입니다. 일정,데이트 계획,SNS(스토리,피드) 등 필요한 있으면 좋을법한 기능들을 다 넣어봤어요.
어떤 계기로 만들게 됐나요?
각자 약속이 생기면 카톡으로 알려 주는데, 그걸 그냥 기억하거나 제 캘린더에 옮겨 적거나 상대가 한 달치 캘린더를 캡처해 보내야 했어요. 어느 쪽도 불편했습니다
먼저 들어온 쪽이 초대 코드를 받고, 링크로 넘깁니다.
이때 D-day와 기념일이 한 번에 생성됩니다.
선택지 둘 중 탭 한 번. 3초면 끝나요.
날짜를 고르고 장소를 검색해 붙입니다. 여러 날에 걸치면 막대로 그려져요.
쟁여둔 곳을 그날 앨범의 코스로 꺼내 순서를 매깁니다.
앨범에 사진이 붙고, 피드와 스토리에도 남습니다.
시작은 일정이었어요. 둘이 같이 보는 캘린더 하나를 먼저 세우고, 커플에게 꼭 필요한 기능만 골라 뽑아 붙이기로 했습니다. 일정을 붙이고 나니 데이트 계획을 짜는 자리가 자연스레 따라왔고, 다녀온 하루를 남기는 아카이빙도 필요해졌어요. 매일 들어올 이유는 그다음 문제였는데, 오늘의 주제·게임·스토리가 거기서 나왔습니다.
제일 먼저 정한 게 데이터 소유 단위였어요. 흔한 방식은 각자 계정에 데이터를 두고 상대에게
공유 권한을 주는 건데, 그러면 화면마다 "이건 누구 것인가"를 계속 따져야 하고 헤어질 때
누구 것을 지우냐는 문제가 남아요. 그래서 couples 를 최상위에 두고 모든 테이블이
couple_id 를 갖게 했습니다. 한 사람은 커플 하나에만 속하고(couple_members.user_id unique),
RLS 정책은 전부 public.my_couple_id() 라는 함수 하나를 술어로 씁니다. 정책을 42개 쓰면서도
규칙이 하나라 헷갈릴 게 없었어요.
로그인은 카카오만 뒀습니다. 커플앱은 상대를 끌어와야 시작되는데, 이메일 회원가입을 시키면
그 자리에서 절반이 떨어져요. 초대는 코드 열 자를 발급하고 서버 트랜잭션(claim-invite)이
수락·연결·기념일 생성을 한 번에 처리하게 했습니다.
처음엔 그냥 "데이트 기록" 목록이었는데 밋밋했어요. 음악 메타포로 갈아탔습니다 —
장소가 트랙, 하루가 앨범, 시간순으로 늘어선 우리 데이트가 플레이리스트. 앨범에는
자켓(커버 사진)이 있고 지난 날짜는 "발매"된 상태가 됩니다. 이름만 바꾼 게 아니라
tracks 에 상태 컬럼을 아예 두지 않고 date < 오늘(KST) 로만 판정하게 만들어서,
자정에 상태를 갱신하는 배치가 필요 없어졌어요.
같이 폐기한 것도 있어요. PRD에 있던 "싱글(기념일)" 개념을 버렸습니다. 기념일은 캘린더의 일이고, 앨범과 싱글을 나란히 두면 사용자가 둘의 차이를 배워야 하는데 배울 가치가 없었어요.
한 달쯤 쓰다 보니 문제가 뚜렷해졌습니다. 매일 바뀌는 게 없었어요. 데이트는 주 1회, 캘린더는 한 달에 몇 칸, 앨범은 일주일에 한 번 늘어요. 그래서 홈에 무엇을 두든 지루했고, 캘린더를 크게 키우는 건 빈 화면을 크게 만드는 것일 뿐이었어요.
매일 생기는 공유 콘텐츠를 만들기로 하고 후보를 몇 개 검토했어요. 오늘의 한 곡이나 낙서는 매일 리추얼로는 성립하는데 곡을 떠올리거나 그림을 그리는 부담이 있고, 이상형 월드컵은 매일 새 후보 이미지를 어디서 가져올지에 답이 없었어요. 남은 게 투표였습니다. 텍스트 한 줄에 선택지 둘, 탭 한 번이면 끝나요.
핵심 훅은 하나로 통일했습니다 — 내가 해야 상대 답이 열린다. 이걸 나중에 붙인 오늘의
게임과 추천곡에도 그대로 적용했고, 취향의 문제가 아니라 서버가 강제하는 규칙으로 만들었어요
(has_played RLS + submit_game_round RPC). 클라이언트에서 가리면 개발자 도구로 뚫리니까요.
주제는 세 번 갈아엎었어요. 200개로 시작해 400개까지 늘렸다가, 읽어보니 "전 애인", "결혼관" 같은 지뢰가 섞여 있어서 관계 심문형을 통째로 폐기하고 가상 소재 320개로 다시 짰습니다. 재밌자고 만든 게 싸움이 되면 안 되니까요.
여기서 안 하기로 한 것도 정해졌어요. 실시간 동시 대결은 접속 타이밍이 안 맞으면 죽는 기능이라 비동기 점수 승부로 충분했고, 수익화는 사용량 게이트로 가면 가장 열심히 쓰는 소수에게만 청구서를 보내면서 총액은 얼마 안 되는 구조라 접었습니다.
스토리와 피드를 붙이면서 UI를 어디까지 다듬을지 애매했는데, 결국 인스타그램과 나란히 놓고 어긋나는 걸 하나씩 고치는 방식으로 갔습니다. 사람들이 이미 손에 익힌 동작이 있어서, 비슷하게 만들면 설명이 필요 없고 조금만 달라도 어색해요.
그래서 커밋이 유난히 많이 쌓인 구간입니다. 사진을 9:16 캔버스에 꽉 채우기, 핀치 축소가 실제로 남게 하기, 텍스트 입력을 전체화면으로 띄우기, 치는 자리와 놓일 자리를 일치시키기, 꾹 누르면 정지… 스토리 편집기 하나에 스무 개 넘는 커밋이 들어갔어요. 편집 결과는 레이어를 따로 저장하지 않고 화면을 통째로 구워서(합성) 한 장으로 올립니다. 웹에서는 canvas로 같은 걸 하게 만들어서 네이티브·웹이 같은 결과를 내게 했어요.
여기서 얻은 건 코드 재사용이었어요. 스토리 편집기의 크롭 제스처(StoryCanvas·cropToCanvas)를
게시물 업로드 크롭에 그대로 다시 썼고, 프레임 비율은 postFrameRatio 하나로 묶어서 업로드
크롭과 피드 표시가 같은 값을 보게 했습니다. 한쪽만 바꾸면 어긋나던 걸 끊은 거예요.
사람들에게 열어볼 생각을 하니 서버 비용이 걸렸습니다. 요금표를 뜯어보니 이미지 변환이
문제였어요. Supabase Pro의 이미지 변환 무료분은 원본 100장뿐인데, 저는 썸네일을
createSignedUrl(..., { transform }) 로 서버에서 뽑고 있었어요. Spend Cap을 켜면 베타
첫 주에 앱 전체 썸네일이 깨지고, 끄면 사진 10만 장에 월 $500이 나오는 구조였습니다.
"고정 상한"과 서버 변환은 애초에 양립할 수 없었어요.
그래서 변환을 걷어내고 업로드할 때 기기에서 렌디션 2종(1080·360)을 만들어 함께 올리는
방식으로 바꿨습니다. 보관 최대본도 2048에서 1080으로 낮췄어요. 서명 URL은 24시간 캐시하고
업로드 시 Cache-Control: 31536000 를 박았습니다 — 경로가 uuid라 같은 주소의 내용이
바뀌지 않으니 1년 캐시가 안전해요. 첫 진입에 왕복 40건이던 서명 요청은 배치로 묶어 2건이 됐습니다.
한 번 더 밀어붙인 건 쿼터 쪽이에요. 처음엔 "커플당 사진 100장"으로 세고 결제 유도 지점으로
쓰려 했는데, 수익화를 접으면서 성격이 바뀌었습니다. 게다가 동영상을 붙이자 장수로는 셀 수가
없어졌어요 — 15초 영상 하나가 사진 27장이거든요. 그래서 장수를 버리고
couples.storage_quota_bytes(무료 200MB)와 photos.bytes 합계를 insert 트리거가 강제하는
바이트 총량으로 갈아탔습니다. 한도에 닿아도 기존 사진은 지우지 않고 열람도 계속 되게 했어요
— 새 업로드만 막습니다. 이미 올린 추억이 사라지는 앱은 쓸 이유가 없으니까요.
iOS 빌드는 맥이 있어야 하고 심사도 기다려야 해서, React Native Web으로 웹을 먼저 열었습니다.
Vercel에 붙이고, 홈 화면에 추가하면 앱처럼 뜨는 PWA 셸을 씌웠어요. 그러자 플랫폼 차이가
줄줄이 튀어나왔습니다 — Alert 가 웹에서 안 뜨고(전부 dialog 헬퍼로 교체), 안전영역을
useSafeAreaInsets 로 재면 여백이 생겼다 없어졌다 하고(CSS env() 로 교체),
사파리가 핀치를 가져가고, Keyboard.metrics 는 웹에 아예 없고요.
알림도 웹 푸시로 붙였는데 여기서 구조를 한 번 고민했어요. 알림 행 생성을 클라이언트가 하면 앱이 죽어 있을 때 유실되고 배지 숫자가 곧 틀어집니다. 그래서 Postgres 트리거를 단일 진실로 두고, 발송은 Edge Function을 늘리지 않고 Vercel Function이 하게 했어요. DB에서 워커를 깨우는 신호(pg_net)는 실패해도 되는 것으로 설계해서, Supabase를 떠나면 cron 폴링으로 갈아끼우면 되게 벤더 락인을 한 군데에 가둬 뒀습니다.
마지막으로 붙인 게 포트폴리오용 둘러보기예요. 커플앱은 로그인하고 상대까지 연결해야
아무것도 안 보이는데, 그건 남에게 보여줄 수 있는 상태가 아니에요. 그래서 /demo 로 들어오면
가상 커플 계정으로 자동 로그인되고, 데이터는 서버가 매일 새벽 4시에 되돌립니다. 여기서
규칙을 하나 지켰어요 — 데모를 아는 파일은 demo.tsx 하나뿐입니다. 로그인 뒤로는 평범한
사용자와 완전히 같은 경로(진입 가드 → 홈)를 타요. 화면마다 데모 분기를 심으면 데모에서만
깨지는 화면이 생기는데, 포트폴리오에서 그건 최악의 실패니까요.