설치하고 나면 어느 여행이었는지 모른다
Joiny 는 함께 갈 사람을 초대해서 쓰는 앱이라 초대 링크가 사람을 데려오는 핵심 경로인데, iPhone 에서는 이 길이 중간에 끊겨 있었습니다. 앱이 없는 사람이 카카오톡으로 받은 링크를 열면 초대 페이지가 뜨고, App Store 에서 설치합니다. 그런데 설치한 앱을 처음 열면 어느 여행에 초대받았는지 모릅니다. 초대 페이지는 "설치한 뒤 받은 링크를 다시 눌러 주세요"라고만 안내했습니다.
Android 는 Play 스토어 링크의 설치 정보(install referrer)에 초대 토큰을 실어 첫 실행 때 읽기 때문에 이미 이어집니다. iOS 에는 이에 맞는 공식 방법이 없습니다.
어제 intent 에 적은 목표는, 앱을 지운 iPhone 에서 링크를 한 번만 누르고 설치·로그인까지 가면 그 여행의 멤버가 되어 있는 것입니다.
스토어 스크린샷 '일행과 함께 짜는 일정'. 초대를 이어받은 사람이 로그인 뒤 도착하는 곳이 이런 여행의 일정 탭입니다.
웹에서 복사하고, 앱에서 붙여 넣는다
고른 방법은 클립보드를 다리로 쓰는 것입니다. 초대 페이지에서 "App Store에서 받기"를 누르면 초대 링크 전체를 클립보드에 복사한 뒤 App Store 로 보냅니다. 웹 쪽 코드는 이 블로그 저장소에 있습니다.
async function copyText(text: string): Promise<boolean> {
if (window.isSecureContext && navigator.clipboard?.writeText) {
try {
await Promise.race([
navigator.clipboard.writeText(text),
new Promise((_, reject) => setTimeout(() => reject(new Error('timeout')), WRITE_TIMEOUT_MS)),
]);
return true;
} catch {
// 아래로
}
}
return copyWithTextarea(text);
}
spec 에 정리한 조건은 이렇습니다. writeText 는 https 와 사용자 동작(누름) 안에서 시작한 호출이 필요해서, await 뒤가 아니라 누름 처리기 안에서 곧바로 시작합니다. 600ms 안에 끝나지 않거나 거부되면 화면 밖 textarea 로 execCommand('copy') 를 씁니다. 둘 다 실패하면 App Store 로 보내지 않고 "링크 복사" 버튼과 예전의 "다시 눌러 주세요" 안내를 보여 줍니다. 그 문구는 이제 이 실패 경우에만 남습니다.
앱 쪽에서는 로그인 화면에 "받은 초대로 들어가기" 카드를 띄웁니다. 카드가 뜨는 조건은 순수 함수 하나로 모았습니다.
export function shouldOfferHandoff(c: HandoffConditions): boolean {
return c.platform === 'ios' && c.pasteButtonAvailable && !c.done && !c.hasPending && c.hasContent;
}
iOS 16 이상, 이 설치에서 아직 끝내지 않음, Universal Link 로 들어온 기다리는 초대 없음, 클립보드에 붙여 넣을 것 있음. "끝냄"은 "괜찮아요"·유효한 붙여넣기·로그인 완료 때 기록하고 다시 묻지 않습니다.
클립보드를 읽지 않고 버튼만 보여 주기
제일 신경 쓴 것은 권한 창입니다. iOS 16 부터 앱이 클립보드 내용을 읽으면 "붙여넣기 허용" 창이 뜨는데, intent 의 제약에 "클립보드 내용을 읽기 전에 권한 창을 띄우지 않는다"를 적어 두었습니다.
spec 의 답은 has* 와 get* 를 나누는 것이었습니다. expo-clipboard 57.0.2 의 iOS 구현에서 hasUrlAsync·hasStringAsync 는 UIPasteboard 의 has* 속성이라 내용을 읽지 않고, 창도 띄우지 않습니다. 앱은 get* 를 아예 부르지 않고, 내용은 사용자가 시스템 붙여넣기 버튼(UIPasteControl, expo 의 ClipboardPasteButton)을 눌렀을 때만 받습니다. 권한을 묻지 않고 붙여 넣도록 시스템이 허락한 버튼입니다.
/** 붙여 넣을 것이 있는지만 본다. URL 형식이 아니어도(웹의 복사는 일반 글자다) 글자가 있으면 true. */
async function clipboardHasContent(): Promise<boolean> {
try {
if (await Clipboard.hasUrlAsync()) return true;
return await Clipboard.hasStringAsync();
} catch {
return false;
}
}
hasUrlAsync 만 보지 않는 이유도 spec 에 있습니다. 웹의 writeText 와 execCommand('copy') 는 일반 글자로 쓰기 때문에 hasURLs 가 false 일 수 있습니다. 그러면 앱이 아는 것은 "초대 링크가 있다"가 아니라 "붙여 넣을 글자가 있다"까지입니다. 카드 문구를 "링크를 복사해 왔다면 붙여 넣어 주세요"로 쓴 것은 이 한계 때문이고, 상관없는 글자가 있는 새 사용자에게 카드가 한 번 보이는 것은 spec 이 받아들인 엣지 케이스입니다.
붙여 넣은 글자에서는 parseInviteText 가 초대 링크를 찾습니다. 카카오톡 메시지 전체를 붙여 넣어도 찾고, 아니면 "초대 링크가 아니에요"를 보여 주고 글자는 버립니다. 이벤트에도 내용·토큰은 싣지 않고 유효했는지(valid)만 보냅니다.
버린 길도 intent 에 있습니다. 서버에서 IP·기기 정보로 같은 사람을 짐작해 잇는 방식은 정확도가 낮고 처리방침에 걸려서 뺐습니다. Branch 같은 외부 딥링크 서비스도 비범위인데, 그쪽은 뺀 이유가 기록에 남아 있지 않습니다.
스토어 심사 없이 낸 근거, 확인한 것과 남은 것
OTA 로 낼 수 있느냐는 붙여넣기 버튼의 네이티브 뷰가 스토어의 1.1.0 바이너리에 있느냐에 달려 있었습니다(spec 의 T1). expo-clipboard 는 초대 기능 때부터 57.0.2 하나로 들어 있었습니다. 로컬 1.1.0 개발 빌드의 dylib 에서 ClipboardPasteButton 심볼도 찾았습니다. 다만 스토어 1.1.0 바이너리 자체를 열어 본 것은 아니라고 spec 에 적어 두었고, 운영 1.1.0 실기기에서 버튼이 그려지는지 한 번 보는 것을 출시 조건으로 걸었습니다.
마침 1.2.0 이 심사 중이어서 OTA 는 두 runtime 에 따로 내야 했습니다. 오늘 출시 기록 커밋의 intent 목록에는 OTA 2fb04ffe(1.2.0)와 b4960494(1.1.0), 웹 분석용 환경 변수 설정이 적혀 있고, 1.2.0 은 Android 가 11월 2일, iOS 가 오늘 11월 3일 승인으로 기록돼 있습니다.
plan 의 진행 기록에 남은 검증은 이렇습니다.
- 앱
lint·typecheck통과, jest 33개 묶음 278개 통과. 이번에parseInviteText6개,shouldOfferHandoff·끝냄 표시 7개를 더했습니다. - 웹
npm run lint·npm run build통과. 카카오톡 아이폰 UA 로 로컬 초대 페이지를 열어 새 안내 문구가 나오는 것을 봤습니다. - iPhone 17 시뮬레이터(iOS 26.5)에 1.1.0 개발 클라이언트를 새로 설치하고
simctl pbcopy로 로컬 초대 링크를 넣은 뒤 첫 실행. 카드가 뜨고 권한 창은 없었으며, "붙여넣기"를 누르면 초대 미리보기로 넘어갔고, 닫고 돌아오면 카드는 다시 뜨지 않았습니다.
남은 것은 꽤 됩니다. 무효한 글자 붙여넣기와 로그인 뒤 자동 참여(via=clipboard)는 시뮬레이터에서 보지 않았습니다. 시뮬레이터로는 App Store 설치를 재현할 수 없어서, 앱을 지운 iPhone 으로 처음부터 끝까지 가는 인수 시나리오 1~4는 plan 에서 아직 체크되지 않았습니다. 카카오톡 인앱 브라우저에서 복사 뒤 location.href 이동이 App Store 앱을 바로 여는지도 열린 확인으로 남아 있고, 웹 App Store 페이지가 뜨면 execCommand 먼저 + 기본 링크 이동으로 바꾸기로 해 두었습니다. 이어받기가 실제로 얼마나 일어나는지는 웹의 스토어 버튼 이벤트부터 invite_joined{via=clipboard} 까지 개수로만 비교합니다. 웹과 앱의 사용자를 연결하지 않기 때문입니다.
Joiny 는 함께 갈 사람을 초대해 같이 여행 일정을 짜고, 지도로 동선을 보고, 비용을 정산하는 여행 플래너입니다. iOS·Android 에서 무료로 쓸 수 있습니다.