바로가기
실험실

도돌이

커플 둘이 매일 여는 하나의 공용 공간

화면


01
홈
01

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

캘린더
02캘린더

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

라이브러리
03라이브러리

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

데이트 앨범
04데이트 앨범

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

피드
05피드

게시물과 24시간 스토리가 쌓인다

기술 스택


02
ExpoReact NativeTypeScriptSupabasePostgreSQLTanStack QueryVercelSentry

데이터와 API


03
네이버 지역검색 API

장소를 검색해 후보를 띄우고, 담은 곳만 우리 쪽에 저장한다

한국천문연구원 특일정보

임시공휴일과 선거일을 받아 달력에 빨간 날로 세운다

iTunes Search API

추천 곡의 앨범 이미지와 30초 미리듣기를 붙이고, 실제로 있는 곡인지 확인한다

네이버 지도

담은 장소와 하루 동선을 지도 위에 그린다

카카오 로그인

따로 가입시키지 않고 카카오 계정으로 바로 들어오게 한다

구상


04
일정을 둘이 같은 캘린더에서 잡는다
다녀온 하루가 사진·코스가 붙은 앨범 한 장으로 남는다
가보고 싶은 곳을 쟁여두고 다음 데이트 코스로 꺼내 쓴다
사진을 둘만 보는 곳에 올린다
앱스토어·플레이스토어에 올려서 우리 말고도 쓴다

기획


05

어떤 서비스인가요?

커플 둘이 캘린더·사진첩·기록을 한 공간에 같이 쌓는 앱이에요. "내 것을 상대에게 공유한다"가 아니라 처음부터 둘의 공간에 들어가서, 누가 넣든 상대 폰에 곧바로 뜹니다

홈에는 오늘 것 세 개(주제·추천곡·게임)가 있고, 셋 다 내가 먼저 해야 상대 답이 열려요. 다녀온 하루는 사진과 코스가 붙은 앨범 한 장으로 남습니다

어떤 계기로 만들게 됐나요?

일정도 데이트 계획도 사진도, 둘이 같이 쓸 곳이 없어서 늘 남의 앱을 빌려 썼어요

일정은 카톡에, 사진은 각자 갤러리에, 가보고 싶은 곳은 인스타 저장함에 흩어져 있었어요

주로 어떤 분이 쓰나요?

저와 연인 둘. 하루에 몇 번씩 짧게 들어와요

지금은 안 하기로 한 게 있나요?

수익화. 사용량 게이트는 가장 열심히 쓰는 소수에게만 청구서를 보내면서 총액은 얼마 안 되는 구조라 접었어요

실시간 동시 대결. 접속 타이밍이 안 맞으면 죽는 기능이라 비동기 점수 승부로 충분했어요

유저 플로우


06
  1. 01
    카카오로 들어와 상대를 초대한다

    먼저 들어온 쪽이 초대 코드를 받고, 링크로 넘깁니다.

    링크를 눌렀다코드 없이 바로 수락링크가 막혔다코드 열 자를 직접 입력
  2. 02
    사귄 날을 정하고 커플이 된다

    이때 D-day와 기념일이 한 번에 생성됩니다.

  3. 03
    홈에서 오늘의 주제를 고른다

    선택지 둘 중 탭 한 번. 3초면 끝나요.

    내가 골랐다상대 답이 열리고 댓글로 논쟁아직 안 골랐다상대 답은 가려진 채
  4. 04
    캘린더에 다음 데이트를 잡는다

    날짜를 고르고 장소를 검색해 붙입니다. 여러 날에 걸치면 막대로 그려져요.

  5. 05
    가보고 싶은 곳을 코스에 담는다

    쟁여둔 곳을 그날 앨범의 코스로 꺼내 순서를 매깁니다.

  6. 06
    다녀와서 사진을 올리고 되돌아본다

    앨범에 사진이 붙고, 피드와 스토리에도 남습니다.

    오래 남길 것게시물오늘만 보여줄 것24시간 스토리

구조


07
보내는 것되돌아오는 것

개발 과정


08
  1. 01

    "공유"가 아니라 처음부터 둘의 것으로

    제일 먼저 정한 게 데이터 소유 단위였어요. 흔한 방식은 각자 계정에 데이터를 두고 상대에게 공유 권한을 주는 건데, 그러면 화면마다 "이건 누구 것인가"를 계속 따져야 하고 헤어질 때 누구 것을 지우냐는 문제가 남아요. 그래서 couples 를 최상위에 두고 모든 테이블이 couple_id 를 갖게 했습니다. 한 사람은 커플 하나에만 속하고(couple_members.user_id unique), RLS 정책은 전부 public.my_couple_id() 라는 함수 하나를 술어로 씁니다. 정책을 42개 쓰면서도 규칙이 하나라 헷갈릴 게 없었어요.

    로그인은 카카오만 뒀습니다. 커플앱은 상대를 끌어와야 시작되는데, 이메일 회원가입을 시키면 그 자리에서 절반이 떨어져요. 초대는 코드 열 자를 발급하고 서버 트랜잭션(claim-invite)이 수락·연결·기념일 생성을 한 번에 처리하게 했습니다.

    붙인 것
    Supabase카카오 로그인
  2. 02

    데이트를 앨범으로 뒤집었어요

    처음엔 그냥 "데이트 기록" 목록이었는데 밋밋했어요. 음악 메타포로 갈아탔습니다 — 장소가 트랙, 하루가 앨범, 시간순으로 늘어선 우리 데이트가 플레이리스트. 앨범에는 자켓(커버 사진)이 있고 지난 날짜는 "발매"된 상태가 됩니다. 이름만 바꾼 게 아니라 tracks 에 상태 컬럼을 아예 두지 않고 date < 오늘(KST) 로만 판정하게 만들어서, 자정에 상태를 갱신하는 배치가 필요 없어졌어요.

    같이 폐기한 것도 있어요. PRD에 있던 "싱글(기념일)" 개념을 버렸습니다. 기념일은 캘린더의 일이고, 앨범과 싱글을 나란히 두면 사용자가 둘의 차이를 배워야 하는데 배울 가치가 없었어요.

  3. 03

    매일 열 이유가 하나도 없었어요

    한 달쯤 쓰다 보니 문제가 뚜렷해졌습니다. 매일 바뀌는 게 없었어요. 데이트는 주 1회, 캘린더는 한 달에 몇 칸, 앨범은 일주일에 한 번 늘어요. 그래서 홈에 무엇을 두든 지루했고, 캘린더를 크게 키우는 건 빈 화면을 크게 만드는 것일 뿐이었어요.

    매일 생기는 공유 콘텐츠를 만들기로 하고 후보를 몇 개 검토했어요. 오늘의 한 곡이나 낙서는 매일 리추얼로는 성립하는데 곡을 떠올리거나 그림을 그리는 부담이 있고, 이상형 월드컵은 매일 새 후보 이미지를 어디서 가져올지에 답이 없었어요. 남은 게 투표였습니다. 텍스트 한 줄에 선택지 둘, 탭 한 번이면 끝나요.

    핵심 훅은 하나로 통일했습니다 — 내가 해야 상대 답이 열린다. 이걸 나중에 붙인 오늘의 게임과 추천곡에도 그대로 적용했고, 취향의 문제가 아니라 서버가 강제하는 규칙으로 만들었어요 (has_played RLS + submit_game_round RPC). 클라이언트에서 가리면 개발자 도구로 뚫리니까요.

    주제는 세 번 갈아엎었어요. 200개로 시작해 400개까지 늘렸다가, 읽어보니 "전 애인", "결혼관" 같은 지뢰가 섞여 있어서 관계 심문형을 통째로 폐기하고 가상 소재 320개로 다시 짰습니다. 재밌자고 만든 게 싸움이 되면 안 되니까요.

    붙인 것
    iTunes Search API
  4. 04

    인스타를 기준으로 삼았어요

    스토리와 피드를 붙이면서 UI를 어디까지 다듬을지 애매했는데, 결국 인스타그램과 나란히 놓고 어긋나는 걸 하나씩 고치는 방식으로 갔습니다. 사람들이 이미 손에 익힌 동작이 있어서, 비슷하게 만들면 설명이 필요 없고 조금만 달라도 어색해요.

    그래서 커밋이 유난히 많이 쌓인 구간입니다. 사진을 9:16 캔버스에 꽉 채우기, 핀치 축소가 실제로 남게 하기, 텍스트 입력을 전체화면으로 띄우기, 치는 자리와 놓일 자리를 일치시키기, 꾹 누르면 정지… 스토리 편집기 하나에 스무 개 넘는 커밋이 들어갔어요. 편집 결과는 레이어를 따로 저장하지 않고 화면을 통째로 구워서(합성) 한 장으로 올립니다. 웹에서는 canvas로 같은 걸 하게 만들어서 네이티브·웹이 같은 결과를 내게 했어요.

    여기서 얻은 건 코드 재사용이었어요. 스토리 편집기의 크롭 제스처(StoryCanvas·cropToCanvas)를 게시물 업로드 크롭에 그대로 다시 썼고, 프레임 비율은 postFrameRatio 하나로 묶어서 업로드 크롭과 피드 표시가 같은 값을 보게 했습니다. 한쪽만 바꾸면 어긋나던 걸 끊은 거예요.

  5. 05

    청구서가 무서워지기 시작했어요

    사람들에게 열어볼 생각을 하니 서버 비용이 걸렸습니다. 요금표를 뜯어보니 이미지 변환이 문제였어요. 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 트리거가 강제하는 바이트 총량으로 갈아탔습니다. 한도에 닿아도 기존 사진은 지우지 않고 열람도 계속 되게 했어요 — 새 업로드만 막습니다. 이미 올린 추억이 사라지는 앱은 쓸 이유가 없으니까요.

  6. 06

    웹으로도 열고, 남에게 보여줄 길을 냈어요

    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 하나뿐입니다. 로그인 뒤로는 평범한 사용자와 완전히 같은 경로(진입 가드 → 홈)를 타요. 화면마다 데모 분기를 심으면 데모에서만 깨지는 화면이 생기는데, 포트폴리오에서 그건 최악의 실패니까요.

    붙인 것
    Vercel

시행착오


09
증상카카오 로그인 버튼이 배경색 없이 글자만 떠 있었어요. 스타일 값은 분명히 맞는데 화면에만 안 나왔습니다.
시도처음엔 색 토큰이나 순서 문제라고 보고 스타일 쪽을 뒤졌어요. 그다음엔 RN 0.86과 Metro의 가상 파일시스템 충돌을 의심해서 `forceWriteFileSystem` 로 우회 패치까지 넣었는데, 버튼은 그대로였습니다.
결론원인은 NativeWind v4였어요. `jsxImportSource: 'nativewind'` 가 **모든 JSX**를 css-interop으로 감싸는데, 그 과정에서 `Pressable` 의 함수형 `style`(`({pressed}) => …`)이 그대로 버려졌습니다. 에러도 경고도 없었어요. 그런데 정작 `className` 사용처가 프로젝트에 0개였습니다 — 쓰지도 않는 라이브러리가 렌더링을 망가뜨리고 있던 거예요. NativeWind를 제거하고 RN `style` + 토큰으로 통일했고, 덤으로 Metro 패치 우회도 필요 없어졌습니다.
베타를 열기 전에 요금을 계산해 보니 이상했어요. Supabase Pro의 이미지 변환 무료분이 "origin image 100장"인데, 앱은 모든 썸네일을 서버 변환으로 뽑고 있었습니다.
쿼터를 DB 트리거로 강제해 뒀는데, 한도에 도달한 커플이 업로드를 시도하면 DB 행은 안 생기는데 스토리지 용량은 늘어났습니다.
피드에서 사진에 손을 얹고 위아래로 밀면 화면이 스크롤되지 않았어요. 사진 바깥 여백에서 시작하면 정상이었습니다.
마크 𝄆 의 아래 점이 떨어져 나와 워드마크 "dodori"의 i 위에 얹히는 모션을 만들려 했는데, 점이 안착할 자리에 이미 글꼴이 그린 점이 있었어요.
매일 도는 `daily-release` 와 월 1회 `sync-holidays` 가 실행은 되는데 아무 일도 일어나지 않았어요.

남은 것


10
  • 동영상 업로드가 기기에서 미검증이에요.react-native-compressor × RN 0.86 실동작과 웹 패스를 확인해야 코드가 끝났다고 할 수 있습니다.
  • 웹 푸시가 코드만 끝난 상태예요.VAPID 키 발급과 Vault 설정, 기기 2대 검증이 남았습니다.
  • 스토어 배포를 못 했어요.iOS는 맥에서 로컬 빌드해야 하고 Apple Developer Program 등록이 필요합니다.
  • 계정 삭제 기능이 없어요.앱스토어가 요구하므로 곧 필요하고, 그때 스토리지 고아 정리도 같이 붙여야 합니다.
  • 주제 풀이 317개라 반년치예요.그 안에 보충하지 않으면 오늘의 주제가 죽습니다.
  • 테스트가 lib/ 순수 함수에만 있어요.날짜·D-day·기념일·막대 기하는 덮여 있지만 화면과 쿼리는 손으로 확인합니다.