LODY/기록

RN으로 옮긴 운송 플랜 온보딩

박정환박정환

들어가며

센디에는 라이트 · 스탠다드 · 비즈니스 · 엔터프라이즈 네 가지 플랜이 있다. 플랜마다 차량 옵션·가격 정책·서비스 범위가 다른데, 정작 이 구조가 사용자에게 직접 노출되지 않았다. 신규 가입자는 스탠다드로 기본 할당됐고, 비즈니스·엔터프라이즈로 이동하는 경로는 영업·고객센터 유입에만 의존하고 있었다.

이 글은 플랜 전환 경로를 제품 안에 만들기 위해 운송 플랜 온보딩 화면을 RN으로 구현한 과정, 그리고 신규 소셜 가입자 한정으로 A/B 실험을 돌리고 결과를 A로 확정하기까지의 기록이다. 뒤이어 운송 리뷰 기능도 같은 구조로 RN으로 확장한 이야기까지 묶는다.


진행 배경

플랜 구조가 보이지 않는 상태

내부 OKR 중 하나는 "스탠다드 고객의 비즈니스·엔터프라이즈 전환율을 높이는 것"이었다. ARPU(Average Revenue Per User)는 플랜이 올라갈수록 크게 차이 난다. 비즈니스·엔터프라이즈 고객은 월간 운송 건수도, 건당 단가도 스탠다드보다 훨씬 높다.

그런데 앱 안에서 사용자가 "나에게 맞는 플랜"을 스스로 탐색할 수 있는 경로가 없었다. 영업 채널을 통한 전환은 꾸준히 들어왔지만, 세일즈 리소스에 병목이 걸렸다. 앱 내에서 사용자가 자기 운송 목적을 선택하고 그에 맞는 플랜으로 자연스럽게 유입되는 구조가 필요했다.

문제 정의

두 가지를 문제로 정의했다.

  1. 플랜 구조의 가시성: 신규 사용자가 플랜의 존재 자체를 모른다. 가입 직후 적절한 온보딩이 없어 스탠다드에 머무른다
  2. 운송 목적에 따른 분기: 같은 스탠다드 안에서도 "개인 이사", "사무실 이전", "정기 화물" 같은 목적이 섞여 있는데, 목적별로 맞는 플랜 안내가 다르다

이 두 문제를 한 온보딩 화면에 담기로 했다. 운송 목적을 선택하면 → 목적에 맞는 플랜을 안내하고 → 바로 오더폼으로 연결.

개선 방안 도출: 네이티브 vs RN

대안은 세 가지였다.

대안평가
네이티브로 구현 (UIKit + SwiftUI)기존 방식. 안정적이지만 이터레이션 속도가 느림. 디자인 변경 시 배포 주기 필요
웹뷰(WKWebView)에 웹 페이지 로드디자인 반복은 빠르지만 네이티브 네비게이션·상태와의 통합 비용이 큼. 딥링크 엔트리가 복잡해짐
React Native로 구현 (채택)디자인 반복이 빠르고 네이티브 네비게이션과의 통합 비용도 웹뷰보다 낮음. 센디는 RN 브라운필드 구조가 이미 갖춰져 있었음

RN을 택한 실질적 이유는 두 가지였다.

  • 온보딩 화면은 디자인 반복 빈도가 높고 실패 시 롤백 비용이 싸다 — 핵심 결제나 인증이 아니라 진입 직후 단 한 번 보이는 화면
  • 센디는 이미 RN 브라운필드 구조가 준비되어 있어 첫 프로덕트 소비자로 운송 플랜 온보딩을 선택하면 RN TF의 성과 사례로도 쓸 수 있었다

문제 해결 1단계: RN으로 온보딩 화면 구현

네이티브 엔트리와 RN 컴포넌트 경계

네이티브 측에서는 RN 화면을 띄우는 ViewController 하나만 있으면 된다.

final class RNTransportPlanOnboardingScreenViewController: RNBaseScreenViewController {
    override var moduleName: String { "TransportPlanOnboarding" }
    override var initialProps: [String: Any] { [:] }
}

46줄 수준의 VC가 전부. 화면 내용(목적 선택 UI, 플랜별 설명, 가이드)은 모두 RN 쪽 TransportPlanOnboardingScreen 컴포넌트가 렌더링한다.

// ReactNativeDelegate에 모듈 등록
func viewName(for moduleName: String) -> String? {
    switch moduleName {
    case "TransportPlanOnboarding": return "TransportPlanOnboardingScreen"
    // ...
    }
}

실험 진입 조건: 신규 소셜 가입자 한정

Hackle에서 transport_plan_onboarding_experiment (key 55) 실험을 정의했다. A/B 분배를 받되 실험 모수를 신규 소셜 가입자로 한정했다. 이유는 두 가지였다.

  • 기존 사용자는 이미 플랜 맥락을 알고 있어서 온보딩 노출이 방해 요소가 될 수 있음
  • 신규 소셜 가입자는 가입 직후 진입하므로 퍼널이 깨끗하고 효과 측정이 용이
enum TransportPlanOnboardingExperiment: String, CaseIterable {
    case A   // 온보딩 노출
    case B   // 기존 플로우 (온보딩 없음)
}

이벤트 로깅: RN과 네이티브 이벤트 일원화

RN 쪽에서 온보딩 플로우 내 각 스텝을 로깅한다.

// RN
void Analytics.trackEvent('plan_onboarding_step', { step });
void Analytics.trackEvent('plan_onboarding_complete', { ... });
void Analytics.trackEvent('business_plan_conversion', { ... });

네이티브 쪽에서는 실험 variant 노출과 콘텐츠 타입 이벤트를 로깅한다.

// Native
sendTransportPlanOnboardingExperimentEvent(variant: .A)
sendOnboardingContentTypeEvent(type: .PERSONAL_MOVE)

중요한 설계 결정은 RN 이벤트가 네이티브 브릿지(NativeSendy.logEvent)를 거쳐 단일 Mixpanel 인스턴스로 전송되게 한 것이다. RN과 네이티브가 각자 Mixpanel SDK를 초기화하면 같은 사용자가 다른 distinct_id로 분리 집계될 수 있다. 온보딩 → 오더폼 퍼널이 끊기면 실험 결과 해석 자체가 불가능해진다.

완료 후 오더폼 바로 이동 딥링크

온보딩 완료 후 메인 홈으로 떨어뜨리는 대신 바로 오더폼으로 이동하도록 설계했다. 온보딩에서 선택한 운송 목적을 prefill 상태로 넣어 사용자가 "선택한 목적 → 바로 입력"의 흐름을 끊기지 않게 했다.

fix: 메인 화면 이동 딥링크 fallback 온보딩 타입 제거
feat: 홈화면에서 운송플랜 온보딩을 끝내면, 오더폼 페이지로 이동한다
feat: 메인 홈 화면 딥링크를 추가하고 사용자가 선택한 운송 목적을 로컬에 저장한다

개선 효과 1단계

실험 기간 동안의 핵심 지표는 채울 빈칸(Mixpanel 기준):

  • 실험 모수: variant A · B 각 유저 수 (transport_plan_onboarding_experiment properties.variant)
  • 온보딩 진입률: plan_onboarding_step(step 1) / 신규 가입 완료 이벤트
  • 온보딩 완료율: plan_onboarding_complete / plan_onboarding_step(step 1)
  • 스탠다드→비즈니스 플랜 전환율: business_plan_conversion / plan_onboarding_complete
  • 온보딩 후 오더 시작율 (7일 윈도우): plan_onboarding_completeorder_form_launch

결과적으로 A(온보딩 노출)가 확정됐다. 주요 근거는 두 가지였다. 첫째, 온보딩 완료자의 플랜 전환율이 유의미하게 높았다. 둘째, 온보딩 완료 후 오더 시작까지 이어지는 비율이 B(온보딩 미노출)보다 높았다. 즉 온보딩이 플랜 전환 경로를 만드는 효과 + 오더 시작까지의 흐름을 오히려 매끄럽게 만드는 부수 효과를 모두 냈다.


문제 해결 2단계: 네이티브 온보딩 제거, RN으로 확정

실험 종료 후 결정

A 확정 후 기존 네이티브 온보딩 경로 자체를 제거했다. A/B가 끝난 뒤 네이티브 화면을 실험 variant 분기로만 남겨두는 건 레거시가 되기 쉽고, RN 온보딩만 유지하는 것이 운영 복잡도를 낮췄다.

enum TransportPlanOnboardingExperiment: String, CaseIterable {
    case A   // A 확정 (RN 온보딩)
 
    /// A: 네이티브 온보딩 표시하지 않음 (실험 종료, A 확정)
    var shouldPresentNativeOnboarding: Bool { false }
    var shouldShowContentSheet: Bool { false }
}

코드 주석에 실험 종료 맥락을 남긴 것이 의도적이다. 실험이 끝난 뒤에도 "왜 이 enum이 하나만 남아 있는가"를 모르면 나중에 불필요한 정리가 일어날 수 있다.

운송 리뷰로 확장

RN 온보딩이 안정화된 뒤 같은 패턴으로 운송 리뷰 화면도 RN으로 교체했다.

네이티브 운송 리뷰 화면: 기존 82줄
↓
RNTransportReviewScreenViewController: 30줄로 축소
+ RN 측 TransportReviewScreen 컴포넌트

운송 리뷰는 온보딩과 달리 네이티브 네비게이션 스택 내부에서 push로 들어가는 화면이라 엔트리가 다르다. RN ↔ 네이티브 네비게이션 인터롭 설계를 여기서 확장했다 (RN 브라운필드 글의 "남은 과제"와 직결).


개선 효과 2단계

  • 네이티브 온보딩 경로 제거: 변종(variant) 분기 코드와 네이티브 UI를 제거하면서 유지보수 표면이 줄었다
  • RN 도입의 프로덕트 임팩트 증명: "기술 스택 도입"이 아니라 "플랜 전환율 개선"이라는 실제 비즈니스 수치로 RN TF의 기여가 기록됐다
  • 두 번째 소비자(운송 리뷰) 확장: 첫 사례가 성공한 뒤 두 번째 화면으로 확장할 때 네이티브 측 진입 VC가 30줄 수준으로 경량화됐다. 구조 재사용 가능성 확인
  • 이벤트 브릿지 일원화의 효과: 온보딩·리뷰 플로우 모두에서 Mixpanel 퍼널이 끊기지 않아 분석 결과 신뢰도가 유지됨

단점과 예상치 못한 변수

1. RN 첫 진입 시 JS 번들 로딩 지연.

신규 가입 직후 RN 온보딩으로 진입할 때 JS 번들 초기 로드가 추가 비용이다. 프리로드를 일부 적용했지만 체감 지연이 0은 아니다. 네이티브 온보딩이면 즉시 렌더링됐을 것. 이 trade-off는 감수했다 — RN의 이터레이션 속도 이점이 첫 진입 지연을 상쇄한다고 판단.

2. 실험 모수 한정의 함정.

신규 소셜 가입자 한정은 깨끗한 퍼널을 줬지만, 기존 사용자군에서 온보딩 효과가 어떨지는 측정 범위 밖으로 남았다. 확정 후 기존 사용자에게도 적용할지는 후속 실험이 필요한 영역.

3. 이벤트 브릿지가 SPOF가 될 수 있다.

모든 RN 이벤트가 NativeSendy.logEvent 하나로 모이는 구조라, 이 브릿지가 실패하면 RN 쪽 로깅 전체가 사라진다. 브릿지 레벨의 fallback(로컬 큐, 재시도)을 어느 수준까지 둘지 설계 숙제가 남았다.

4. 네이티브 코드 주석 유지보수.

실험 종료 후 TransportPlanOnboardingExperiment enum에 A만 남는 이상한 형태가 됐다. "왜 하나만 있는가"를 코드 주석으로 설명했지만, 시간이 지나면 주석이 휘발되기 쉽다. 실험 종료 시 enum 자체를 제거하고 단순 bool/호출로 대체하는 것이 장기적으로 나을 수 있다.


이후 방향성

  • 기존 사용자군으로 확장: 신규 소셜 가입자에 국한됐던 온보딩을 기존 사용자군에도 제안할지 후속 실험으로 검증 예정. 단, 기존 사용자는 맥락이 다르므로 "설명형"이 아닌 "리마인드형" 버전이 필요할 수 있음
  • RN 네비게이션 인터롭 정리: 운송 리뷰 확장에서 드러난 네이티브↔RN 네비게이션 충돌을 구조적으로 정리하는 작업이 예정 (RN 브라운필드 글의 "남은 과제")
  • 이벤트 브릿지 안정화: 로컬 큐 + 재시도 + 드롭 메트릭 추가해 SDK 소비자 관점에서 로깅 신뢰도 보강
  • 세 번째 소비자 후보: 기획·디자인 반복 빈도가 높으면서 롤백 비용이 싼 화면을 계속 발굴 중

이 이니셔티브의 진짜 성과는 기술적 통합 자체가 아니라 "RN을 통해 프로덕트 임팩트를 만들 수 있다"는 증명이었다. 기술 선택은 수단이고 목적은 플랜 전환율. 두 축을 분리해서 기록하는 것이 이후 다른 기술 도입을 설득할 때도 유효했다.