🐝매일 한입
개발일지📖 13분 읽기

Utakoto — 첫 미션 결과의 가입 버튼을 '다음에 배울 곡' 세 곡으로 바꾼 이유

첫날 둘째 곡을 시도하는 사람이 더 잘 돌아온다는 점검 결과를 따라, 첫 미션 결과 화면에 추천 3곡을 넣고 가입 버튼을 뺐습니다. 게스트 곡 한도는 서버가 1곡·3곡 군으로 나눠 주고, 알림 안내는 거절 48시간 뒤 한 번만 더 묻게 했습니다.

📖 13분 읽기
#Utakoto#리텐션#A/B 테스트#알림 권한#React Native#OTA#1인개발

첫 미션은 끝냈는데, 둘째 곡으로 가는 길이 없었다

Utakoto 를 처음 연 사람은 곡 하나를 골라 첫 줄을 배우는 "첫 미션"을 합니다. 10월 30일에 10월 설치분을 놓고 리텐션을 다시 봤는데, intent 에 적힌 결론은 제 예상과 조금 달랐습니다. 첫 미션을 끝냈는지 아닌지는 다음 날 돌아오는 것과 거의 상관이 없었습니다. 크게 갈린 건 그날 둘째 곡을 시도했느냐였습니다. 둘째 곡을 담지 않은 게스트는 일주일 안에 잘 돌아오지 않았고, 곡 한도에 막혔을 때 가입한 사람과 거기서 나간 사람의 복귀도 크게 벌어졌습니다.

그런데 게스트 한도가 화면 코드에 1곡으로 박혀 있었고(libraryCount >= 1), 첫 미션 결과 화면에는 "노래 보러 가기", "한 줄 더", 그리고 게스트용 가입 버튼만 있었습니다. 이 가입 버튼은 거의 눌리지 않았고, 곡 미리보기에서 한도에 막혔을 때 나오는 "가입하고 담기"는 훨씬 많이 눌렸습니다. intent 에는 이 차이를 한 줄로 적어 두었습니다. 가입 의사는 "곡을 더 원할 때" 생긴다.

그래서 이번 변경은 세 갈래입니다. 게스트 한도를 서버가 정하게 해서 1곡과 3곡을 나눠 보는 것, 첫 미션 결과 화면에서 바로 다음 곡을 고를 수 있게 하는 것, 그리고 알림 사전 안내를 한 번 거절한 사람에게 딱 한 번 더 묻는 것입니다. 앞의 둘은 day0-second-song, 마지막은 optin-reach 라는 intent 로 따로 적었고, 커밋은 하나(fbe89b6)로 나갔습니다.

무엇을 바꿨나

먼저 한도입니다. 곡 목록 응답에 guestSongLimit 필드가 새로 붙고, 앱은 그 값으로 미리보기 버튼·안내 문구·코스 완주 화면을 그립니다. 앱 쪽 함수는 "지금 가입 안내를 그려야 하나"만 답합니다.

export const GUEST_SONG_LIMIT_FALLBACK = 1;

export function guestSongLimit(data: Pick<SongsResponse, 'guestSongLimit'> | null | undefined): number {
  const n = data?.guestSongLimit;
  return typeof n === 'number' && n >= 1 ? n : GUEST_SONG_LIMIT_FALLBACK;
}

/** 게스트가 한도에 닿아 다음 곡을 담으려면 가입해야 하는지. 회원은 늘 false. */
export function guestAtLimit(
  isAnonymous: boolean,
  data: Pick<SongsResponse, 'guestSongLimit' | 'songs'> | null | undefined
): boolean {
  if (!isAnonymous) return false;
  return (data?.songs.length ?? 0) >= guestSongLimit(data);
}

필드가 없거나 0 같은 이상한 값이 오면 1곡으로 읽습니다. 옛 서버와 목록 로딩 전이 그 경우입니다. 그 게스트가 어느 군인지(guest_song_limit 1 또는 3)는 분석 도구에 이벤트 공통 속성과 사람 속성으로 남깁니다. 가입 뒤에도 지우지 않습니다. 가입 이후 이벤트도 군별로 갈라 봐야 하기 때문입니다.

다음은 첫 미션 결과 화면입니다. "내일은" 카드 아래에 "다음에 배울 곡" 세 곡이 들어갔습니다. 홈의 "내 레벨 추천"과 같은 목록에서 이미 담은 곡을 빼고 앞 3곡을 씁니다. 곡을 누르면 화면을 떠나지 않고 그 자리에서 미리보기 시트가 열리고, 거기서 바로 담을 수 있습니다. 게스트 한도나 포인트 판정은 기존 미리보기 시트와 서버가 그대로 맡습니다.

Utakoto 첫 미션 결과 화면, 카세트 캐릭터와 1번째 줄 완료 문구, 내일은 카드 아래에 다음에 배울 곡 세 곡의 썸네일과 제목이 있고 그 아래 노래 보러 가기와 한 줄 더 버튼이 있다 intent 폴더에 함께 들어 있는 첫 미션 결과 화면 캡처입니다. "다음에 배울 곡" 줄이 새로 들어간 자리이고, 게스트 가입 버튼은 이 화면에서 빠졌습니다.

자리를 내려고 이 화면의 카세트 캐릭터를 폭 150에서 104로 줄이고 화면을 ScrollView 로 감쌌습니다. 가입 버튼은 첫 미션에서만 뺐습니다.

          {isMission ? (
            <View className="mt-6 w-full">
              <NextSongRow onPick={(s, position) => setPreview({ songId: s.songId, position })} />
            </View>
          ) : null}
          ...
            {isAnonymous && !isMission ? <JoinCta screen="course_result" /> : null}

마지막은 알림 안내입니다. Utakoto 는 OS 권한 다이얼로그를 바로 띄우지 않고, 먼저 앱 안 시트로 "내일 이 단어를 알려드릴까요?"라고 묻습니다. "예"를 누른 사람에게만 OS 다이얼로그를 넘깁니다. 문제는 이 시트를 한 번 닫은 사람에게 다시는 묻지 않았다는 점입니다. OS 다이얼로그는 쓰지도 않았는데 말입니다. 그리고 미션을 건너뛴 사람은 시트가 뜨는 순간 자체를 만나지 못했습니다. 그래서 저장값을 '1' 한 글자에서 몇 번 물었는지·수락했는지·언제 거절했는지를 담은 JSON 으로 바꾸고, 다시 물어도 되는지를 함수 하나로 판정합니다.

export const OPTIN_REASK_AFTER_MS = 48 * 60 * 60 * 1000;
export const OPTIN_MAX_ASKS = 2;

export function optInDue(record: OptInRecord | null, now: number): { due: boolean; ask: number } {
  if (!record) return { due: true, ask: 1 };
  const ask = record.asks + 1;
  if (record.accepted || record.asks >= OPTIN_MAX_ASKS || record.declinedAt == null) return { due: false, ask };
  return { due: now - record.declinedAt >= OPTIN_REASK_AFTER_MS, ask };
}

묻는 순간도 하나 늘렸습니다. 곡을 담고 바로 시작하지 않았을 때이고, 시트 제목은 "내일 이 곡 첫 줄, 알려드릴까요?"입니다.

왜 이렇게 했나

한도를 서버로 옮긴 건 intent 의 첫 제약 때문입니다. "서버가 한도를 판정한다. 옛 서버(필드 없음)면 1곡으로 그린다." 군을 나누는 것도, 담기를 막는 것(403)도 서버 일이고 앱은 표시만 맞춥니다. 앱이 잘못 읽어도 안내 문구가 틀릴 뿐 한도가 뚫리지는 않습니다. 기본값 1은 지금까지의 동작과 같아서, 서버가 새 필드를 주기 전에는 아무것도 달라지지 않습니다.

가입 버튼을 뺀 건 앞에 적은 두 버튼의 차이에서 나온 판단입니다. 가입 관문은 사라지지 않았습니다. 게스트가 한도를 넘는 곡을 미리보기에서 담으려 하면 "가입하고 담기"가 그대로 나옵니다. 가입을 권하는 자리를 결과 화면에서 "곡을 더 원하는 순간"으로 옮긴 것에 가깝습니다. 회원도 이 추천을 보고 첫 곡 무료를 바로 쓰게 되는데, intent 의 Open questions 에는 이것을 "의도한 결과로 본다"고 적어 두었습니다. 그리고 범위를 첫 미션으로 묶었습니다. 일반 코스 회차 결과 화면은 그대로이고, 가입 버튼도 거기에는 남아 있습니다.

알림은 "조르지 않는다"가 먼저였습니다. 총 2번까지, 두 번째는 첫 거절 48시간 뒤, 한 세션에 한 번. OS 다이얼로그는 여전히 앱 시트에서 "예"를 누른 사람에게만 뜹니다. iOS 는 그 다이얼로그를 앱 생애에 한 번만 띄울 수 있으니, 앞에서 거르는 원칙은 그대로 둔 것입니다. 애매한 경우는 전부 묻지 않는 쪽입니다. 이전 버전이 저장한 '1' 은 거절 시각이 없으니 다시 묻지 않고, 깨진 값이나 저장소 읽기 실패도 같습니다. 주석에 남긴 말로는 "조르는 것보다 낫다"입니다.

확인한 것과 남은 것

커밋에는 테스트 파일이 두 개 함께 들어갔습니다. guest-limit.test.ts 는 서버 값이 없거나 0일 때 1곡으로 읽는지, 3곡 군이 2곡까지는 더 담고 3곡에서 막히는지, 회원은 막지 않는지를 봅니다. optin-trigger.test.ts 는 48시간 경계, 두 번 거절·수락 뒤, 옛 '1' 과 깨진 값을 봅니다. 다만 이번 변경에는 plan.md 가 따로 없고, 이 테스트를 돌린 결과나 시뮬레이터 확인 항목은 기록에 남아 있지 않습니다.

남아 있는 건 출시 기록입니다. 같은 날 main 의 fbe89b6 으로 OTA be1bf600(runtime 1.11.0, iOS·Android)을 냈고, intent 목록(48ccbfd)에 그 발행이 적혀 있습니다. 같은 줄에 백엔드 쪽 운영 확인도 있습니다. 1곡 군은 한도가 1로 오고, 3곡 군은 담기 세 번이 성공한 뒤 네 번째가 403 으로 막혔다는 기록입니다.

판정일은 11월 20일로 적혀 있습니다. OTA 뒤 새 설치를 기준으로 첫날 둘째 곡 비율과 7일 안 복귀를 군별로 비교하고, 알림은 시트 노출 비율과 수락률을 봅니다. 두 번째 물음의 문구를 다르게 할지는 intent 에 열린 질문으로 남아 있고, 1곡과 3곡 중 어느 쪽이 나은지도 아직 모릅니다.

Utakoto 는 J-POP·애니송 가사로 일본어를 배우는 앱입니다. 가사마다 후리가나·한국어 발음·뜻이 붙고, 노래 속 단어가 JLPT 단어장으로 모여 복습할 수 있습니다. iOS·Android 에서 무료입니다.

📚 관련 글

💬 댓글