여행을 만들고 나서 멈추는 사람들
Joiny 를 출시하고 한 달이 지난 11월 2일, 다음에 무엇을 할지 순서를 다시 정했습니다. 운영 DB 와 PostHog 를 데모·리뷰 계정을 빼고 집계해 보니, 사람이 가장 많이 빠지는 곳은 여행을 만든 직후, 일정을 채우는 단계였습니다. 만들어진 여행 가운데 일정 항목이 하나도 없는 여행이 더 많았습니다. 그래서 그 전까지 1순위였던 "보기 전용 공유 링크"를 미루고, 빈 일정 화면 다듬기와 퍼널 측정을 0번으로 올렸습니다.
빈 여행을 처음 연 사람이 보는 화면은 솔직히 깨져 보였습니다. intent 에 적은 문제는 이랬습니다.
- 시트를 반쯤 올리면 목록이 보이는 높이가 iPhone 15 Pro 기준 약 243pt 인데 빈 Day 카드는 약 290pt 라, 아래가 잘리고 그 위를 페이드가 덮었습니다.
- 머리의 "+ 추가", 카드의 "+ 첫 일정 추가하기", "AI로 채우기" 세 버튼이 같은 일을 다퉜습니다.
- 상태 바의 시계가 지도 지명과 겹쳐 읽혔고, 장소가 0개인 지도에는 장소를 넣을 수 있다는 단서가 없었습니다.
더 근본적인 문제도 있었습니다. 위 집계도 DB 를 직접 조회해서 얻은 것입니다. 여행 생성·초대·합류 이벤트가 없어서 어느 단계에서 빠지는지 볼 수 없었고, 바로 뒤에 할 작업들이 효과가 있었는지 판단할 근거도 없었습니다.
상자를 걷어내고, 넘칠 때만 흐리게
빈 Day 는 상자를 없애고 문구와 버튼 줄만 남겼습니다. "첫 일정 추가하기"가 주 버튼, "AI로 채우기"가 보조 버튼이고, Day 가 비어 있으면 머리의 "+ 추가"는 숨깁니다. AI 를 못 쓰는 빌드에서는 주 버튼 하나만 둡니다.
{empty ? (
<View className="mx-5 items-center gap-2">
<Text variant="sub" tone="mid" className="text-center">
{t('trips.schedule.empty')}
</Text>
{onAiFill ? (
<View className="flex-row gap-2 self-stretch">
<Button label={t('trips.schedule.addFirst')} size="md" onPress={onAdd} className="flex-1" />
<Button label={t('ai.fillDay')} size="md" variant="outline" onPress={onAiFill} className="flex-1" />
</View>
) : (
<Button label={t('trips.schedule.addFirst')} size="md" onPress={onAdd} />
)}
</View>
) : (
이 모양은 spec 에서 높이 예산을 먼저 계산해 놓고 정했습니다. 빈 Day 전체가 164pt 이고, 가장 작은 지원 기기인 iPhone SE 에서 반쯤 올린 시트가 보여 주는 높이는 172pt 라 8pt 가 남습니다. 빈 Day 의 아래 여백을 pb-0 으로 줄인 것도 그래서입니다. 항목이 있는 Day 는 끌어서 순서를 바꿀 때 자동 스크롤 여유가 필요해 pb-8 을 그대로 둡니다.
iPhone SE 시뮬레이터에서 찍은 빈 Day 의 절반 높이 시트. Day 칩 부제도 0개인 날은 날짜만, 1개인 날은 "11.4 수 · 1개"로 나옵니다.
페이드는 목록 내용이 보이는 높이보다 길 때만 그리게 바꿨습니다. 내용 높이를 재는 방법에서 한 번 갈렸습니다. ScrollView 의 onContentSizeChange 가 먼저 떠오르지만, spec 에 그 값에는 paddingBottom 이 들어간다고 적어 두었습니다. 넘침 여부로 아래 여백을 12 와 0 사이에서 바꾸면, 여백이 바뀌어 크기가 바뀌고 다시 넘침 판정이 바뀌는 되먹임이 생깁니다. 그래서 children 을 감싼 View 의 onLayout 으로 여백을 뺀 높이만 쟀습니다.
const [contentHeight, setContentHeight] = useState(0);
const overflow = contentHeight > 0 && contentHeight > bodyHeight + 1;
// ...
contentContainerStyle={{ paddingBottom: overflow ? 12 : 0 }}>
<View onLayout={(e) => setContentHeight(e.nativeEvent.layout.height)}>{children}</View>
</Animated.ScrollView>
// ...
{overflow && <BottomFade />}
상태 바 흐림은 지도 맨 위에 바탕색 그라데이션(불투명도 0.8 → 0.7 → 0)을 까는 StatusScrim 입니다. 아래 페이드에 쓰던 react-native-svg 를 그대로 써서 새 의존성은 없습니다.
안내 문구 하나를 쓰려고 코드를 먼저 봤다
빈 지도 안내 칩은 intent 에서 "지도를 길게 눌러 장소를 추가해요"를 예로 들었지만, 실제 제스처는 열린 질문(T1)으로 남겼습니다. spec 에서 지도 컴포넌트를 보니 길게 누르기 처리가 아예 없었습니다. 구글 지도의 장소 아이콘을 눌러야 장소 카드가 떠서 "Day N에 추가"를 고를 수 있습니다. 그래서 문구는 "지도의 장소를 눌러 Day 1에 추가해요"가 됐고, 전체·보관함을 보고 있을 때는 "…보관함에 추가해요"로 넣는 곳을 그대로 말합니다.
이 칩은 화면 읽기에서 숨깁니다. 거기서는 지도가 숨겨져 있어서, 그 사용자는 할 수 없는 동작을 안내하는 셈이 되기 때문입니다. 터치도 받지 않아 칩 밑의 지도 장소를 그대로 누를 수 있습니다. 움직임은 투명도만 쓰고, 시트 스프링이 거의 멈춘 250ms 뒤에 나타납니다.
Day 칩 높이는 디자인 문서의 규격(32)과 달리 48 이었는데, 줄이지 않고 문서에 "두 줄 Day 칩 48"을 더했습니다. 왜 48 을 지켰는지는 기록에 남아 있지 않습니다.
다크 모드의 상태 바 흐림과, 항목이 2개라 다 들어가서 페이드가 없는 Day. 항목이 생기면 머리의 "+ 추가"가 다시 보입니다.
사람이 아니라 기기 단위로 센다
퍼널 이벤트는 앱에 6개를 넣었습니다.
trip_created: { city_count: number; day_count: number; status: 'upcoming' | 'ongoing' | 'past'; source: 'manual' | 'import' };
/** 항목 1개에 1번, AI 적용은 1번. `is_first_in_trip`: 넣기 직전 캐시가 비었는지(캐시가 없으면 false) */
schedule_item_added: { is_first_in_trip: boolean; has_place: boolean; source: ItemSource };
/** iOS는 고른 앱(activityType), Android는 null이고 닫아도 sharedAction이라 많게 센다 */
invite_shared: { channel: string | null; member_count: number };
invite_link_copied: { member_count: number };
/** 합류 1회에 1번(이미 멤버면 보내지 않는다). member_count는 합류 뒤 */
invite_joined: { via: 'link' | 'pending' | 'referrer'; member_count: number };
expense_added: { is_receipt: boolean; currency_is_settlement: boolean | null; via: 'form' | 'draft' };
출시 때 정한 계측 규칙대로 성공한 뒤에만 보내고, 제목·장소명·이름·메모·id 는 싣지 않으며, PostHog 의 identify(사람 식별)는 하지 않습니다.
이 선택의 대가도 spec 에 적어 두었습니다. invite_joined 는 초대받은 사람의 기기에서 생기는데, identify 가 없으니 보낸 사람의 퍼널과 이어지지 않습니다. 그래서 퍼널은 "설치 → 여행 생성 → 첫 항목 → 초대 보냄"까지만 잇고, 합류는 주별 추세로 따로 봅니다. Android 공유 시트가 닫아도 "공유함"을 돌려줘 invite_shared 를 많게 세는 것도 알고 들어간 한계입니다.
확인한 것과 남은 것
plan 의 진행 기록에 남은 검증은 lint·typecheck 통과, jest 31개 묶음 251개 통과(칩 부제 테스트 3개 추가)입니다. 시뮬레이터는 iOS 26.5 개발 빌드로 iPhone SE 3세대와 iPhone 17 Pro 에서 라이트·다크를 찍었고, 빈 Day 의 버튼 둘이 다 보이고 지명이 빽빽한 곳에서도 시계가 읽혔다고 적혀 있습니다. SE 의 항목 2개 Day 에 페이드가 생긴 것은 내용이 172pt 를 넘어서 맞는 동작으로 기록했습니다.
지도에서 장소를 넣으면 칩이 사라지는지는 반만 봤습니다. 로컬 장소 함수에 구글 키가 없어 추가가 실패해서, DB 로 좌표 있는 항목을 넣고 다시 열어 확인했습니다. Pro Max, Android, 숙박 중 줄이 있는 빈 Day 는 못 봤습니다.
배포는 JS 변경이라 OTA 로 1.1.0 에 먼저 나갔고, 1.2.0 스토어 출시에도 "빈 일정 화면 개선"이 들어갔습니다. 다만 초대 페이지 웹 이벤트와 PostHog 대시보드는 intent 목록에 남은 일로 적혀 있고, 이벤트 도착을 확인한 기록은 없습니다. 이번 다듬기의 효과는 그 퍼널이 숫자를 보여 줘야 말할 수 있습니다.
Joiny 는 함께 갈 사람을 초대해 같이 여행 일정을 짜고, 지도로 동선을 보고, 비용을 정산하는 여행 플래너입니다. iOS·Android 에서 무료로 쓸 수 있습니다.