앱 배포 없이 바뀌는 모달 웹뷰를 설계한 이야기

최초 도입은 앱 배포가 필요했다. 그 이후를 웹 배포만으로 바꾸는 것이 목표였다.

모바일 앱의 특정 WebView에서 사용자가 조건을 만족하면, 다음 홈 진입 시 모달을 보여줘야 했다.

문제는 모달의 문구, 이미지, CTA가 비즈니스 요구에 따라 자주 바뀐다는 점이었다. 네이티브 UI로 구현하면 작은 변경에도 Android와 iOS 앱을 수정하고 QA와 앱 배포를 거쳐야 했다. 배포가 끝나더라도 모든 사용자가 새 버전을 바로 설치하는 것은 아니었다.

목표는 최초 기능 도입 이후 모달의 UI와 종류를 웹 배포만으로 변경할 수 있게 만드는 것이었다. 이를 위해 홈 화면 위에 별도의 WebView를 모달 형태로 표시하는 구조를 적용했다. 이 글에서는 이 구조를 ’모달 웹뷰’라고 부른다.

어려웠던 것은 네이티브 UI를 웹으로 옮기는 작업 자체가 아니었다. 무엇을 어디까지 Web의 책임으로 만들고, 서로 다른 생명주기를 가진 Native와 Web 사이의 경계를 어떻게 유지할 것인지가 주요 설계 과제였다.

구분 내용
문제 자주 바뀌는 모달 UI가 앱 배포 주기에 묶여 있었다
제약 원래 WebView가 닫히거나 앱이 종료된 뒤에도 모달 상태가 남아야 했다
책임 경계 Native는 홈과 WebView 생명주기, Web은 모달 데이터와 UI를 담당했다
데이터 전달 같은 origin의 두 WebView가 localStorage를 단일 슬롯으로 사용했다
성능 접근 모달 경로의 공통 초기화를 걷어내 Slow 4G 기준 2.1초를 1.1초로 줄였다
결과 Native의 별도 상태 저장이나 모달 설정 API 없이 MVP를 배포했다

뒤로가기가 아니라 다음 홈 진입이 트리거였다

처음 요구사항만 보면 특정 WebView → 뒤로가기 → 홈 이동 → 모달 노출 흐름으로 이해하기 쉬웠다.

하지만 뒤로가기는 사용자가 홈에 도달하는 여러 경로 중 하나였다. 사용자는 조건을 만족한 뒤 바로 뒤로가기를 누를 수도 있었지만, 앱을 종료했다가 나중에 다시 실행할 수도 있었다. 이 경우에도 다음 홈 진입 시 모달을 보여줘야 했다.

그래서 요구사항을 다시 정의했다.

사용자가 뒤로가기를 누르면 모달을 보여준다.

가 아니라,

특정 조건을 만족한 사용자에게 다음 홈 진입 시 모달을 보여준다.

였다.

이 정의에 따라 모달 데이터는 원래 WebView의 생명주기보다 오래 유지되어야 했다. React state나 JavaScript 전역 변수처럼 WebView가 종료될 때 함께 사라지는 상태로는 요구사항을 충족할 수 없었다.

여기서 살아남아야 하는 것은 두 가지였다. 모달을 렌더링하는 데 필요한 데이터, 그리고 이 사용자가 조건을 충족했다는 사실 자체다.

앞의 것은 Web이 localStorage에 남겼다. 뒤의 것은 Native가 따로 저장하지 않았다. 홈 화면이 GTM 이벤트를 구독하고 있다가, 해당 이벤트를 받으면 모달 웹뷰를 여는 구조였다.

그래서 앱을 껐다 켜도 동작이 달라지지 않는다. 홈 화면은 언제나 이벤트를 기다리고 있고, 재실행 이후에 이벤트가 도착해도 그 시점의 홈에서 모달을 연다. “이 사용자는 모달 대상”이라는 플래그를 Native가 직접 관리할 필요가 없었다.

대신 트리거의 durability는 이벤트 파이프라인에 위임된다. 이벤트가 유실되면 모달도 유실되고, 그 실패는 Web 코드가 관측할 수 없는 곳에서 일어난다. 이 모달은 결제나 계약 상태처럼 반드시 전달되어야 하는 데이터가 아니었기 때문에 이 위임을 받아들였다. 뒤에 나오는 저장소 정책도 같은 전제 위에 서 있다.

반대쪽 극단인 즉시 노출 경로는 따로 확인했다. 조건을 충족한 직후 바로 뒤로가기를 눌러 홈에 도달하는 경우에도 테스트에서 체감할 만한 지연은 없었다.

모달의 책임을 어디에 둘 것인가

모달의 실제 UI를 웹으로 만드는 것만으로는 앱 배포 의존성을 충분히 줄일 수 없었다. 모달의 구체적인 데이터와 종류를 Native 또는 Backend가 알고 있다면, 새로운 모달을 추가할 때 다른 시스템의 contract도 함께 변경될 수 있기 때문이다.

Native가 모달 데이터를 소유하는 방법

첫 번째로 검토한 방법은 웹에서 Native에 모달 데이터를 전달하는 것이었다.

typescript
bridge.setModal({
  title: '...',
  description: '...',
  imageUrl: '...',
  cta: {
    label: '...',
    url: '...',
  },
})

Native가 이 값을 저장했다가 홈에 도달하면 새로운 모달 웹뷰에 다시 전달하는 방식이다. 기술적으로 구현하기 어려운 구조는 아니었다.

문제는 모달 요구사항이 늘어날수록 Native Bridge가 웹의 ViewModel을 알게 된다는 점이었다. 처음에는 titledescription만 필요하더라도 이후에는 이미지, 보조 CTA, 배지, 안내 문구처럼 필드가 늘어난다.

typescript
type ModalPayload = {
  title: string
  description: string
  imageUrl?: string
  primaryCta?: CTA
  secondaryCta?: CTA
  badge?: Badge
  disclaimer?: string
}

이 구조에서는 모달 UI가 바뀔 때마다 Web, Android, iOS가 함께 contract를 검토해야 한다. 모달을 WebView로 옮겼지만 ViewModel은 여전히 앱 배포 주기에 영향을 받는다. Native가 모달의 구체적인 데이터 구조를 소유하는 방식은 선택하지 않았다.

Backend API에서 모달을 조회하는 방법

두 번째 선택지는 모달 웹뷰가 열릴 때 Backend API에서 설정을 조회하는 방식이었다.

text
GET /modal
json
{
  "title": "...",
  "description": "...",
  "imageUrl": "...",
  "cta": {
    "label": "...",
    "url": "..."
  }
}

Backend가 노출할 모달을 결정하고 Web은 응답을 렌더링하는 구조다. 운영자가 모달을 직접 관리해야 하거나, 여러 채널에서 같은 설정을 사용하거나, 서버에서 사용자별 노출 조건을 판단해야 한다면 적절한 선택이 될 수 있다.

하지만 당시 MVP의 모달 데이터는 독립적인 도메인 데이터보다 UI 표현에 가까웠다. 문구, 이미지, CTA는 모달 UI와 함께 바뀌었고, 이를 위해 별도의 Backend API와 운영 체계를 추가할 필요는 없었다.

Backend API를 추가하면 다음 범위가 함께 생긴다.

  • 모달 설정 API
  • response schema
  • Backend와 Web의 버전 호환
  • API 오류와 fallback 정책
  • 모달 웹뷰가 열린 뒤의 추가 네트워크 요청
  • API 운영과 모니터링

첫 MVP에서 이 범위까지 만드는 것은 검증하려는 기능보다 시스템을 크게 만드는 일이었다. 그래서 별도의 모달 설정 API를 만들지 않고, 모달 UI와 표현 데이터를 Web이 함께 소유하도록 했다.

다만 이 결정이 모든 모달 데이터가 Web에 있어야 한다는 의미는 아니다. 계약 상태, 사용자 권한, 상품 가입 가능 여부처럼 서버가 보장해야 하는 도메인 판단이 있다면 여전히 Backend가 authoritative source가 되어야 한다. 이번 MVP에서 Web이 소유한 범위는 모달의 문구, 이미지, CTA와 같은 presentation data였다.

Native는 모달 종류가 아니라 capability를 제공했다

최종 책임 경계는 다음과 같았다.

영역 책임
Native 홈 진입, 모달 웹뷰 생성과 종료, 네이티브 내비게이션, load timeout과 오류 처리
Native 이벤트 계층 trackEvent 수신, GTM 전송과 수신, 모달 노출 기회 판단
Source WebView 모달 데이터 생성과 저장, 조건 충족 이벤트 전달
Modal WebView 데이터 읽기, 검증, 렌더링, cleanup

웹에서는 GTM 라이브러리를 직접 사용하지 않았다. 기존 Native Bridge의 trackEvent 메서드를 호출했다.

typescript
bridge.trackEvent({
  name: '...',
  parameters: {
    // ...
  },
})

GTM으로 이벤트를 보내고 수신하는 과정은 Native의 책임이었다. Web은 구체적인 이벤트 도구에 의존하지 않고, 특정 조건이 충족됐다는 사실만 Native에 전달했다.

모달 데이터는 이벤트를 보내기 전에 Web Storage에 먼저 기록하는 흐름으로 구성했다.

sequenceDiagram
    autonumber
    participant S as Source WebView
    participant L as localStorage
    participant N as Native
    participant M as Modal WebView

    Note over S,L: 저장이 이벤트보다 먼저
    S->>L: setItem(pending-modal, JSON)
    S->>N: bridge.trackEvent(...)
    Note over N: GTM 전송·수신, 모달 노출 기회 판단
    N->>M: 홈 위에 모달 웹뷰 생성
    M->>L: getItem(pending-modal)
    L-->>M: raw string
    M->>L: removeItem(pending-modal)
    M->>M: 파싱 · 런타임 검증
    M-->>N: 렌더 또는 종료 요청

저장이 먼저인 이유는 단순하다. 이벤트가 먼저 나가면 Native가 모달 웹뷰를 여는 시점에 읽을 값이 아직 없을 수 있다.

GTM 이벤트는 모달 데이터를 운반하는 수단이 아니었다. 구체적인 모달 데이터는 Web Storage에 남고, Native 이벤트 계층은 모달을 표시할 기회와 모달 웹뷰의 생명주기를 담당했다.

이 경계 덕분에 Native에는 LOAN_EXIT_MODAL, REFINANCE_MODAL, CREDIT_MODAL 같은 모달별 enum이 계속 늘어나지 않는다. Native가 제공하는 capability는 하나로 유지할 수 있었다.

홈 위에 모달 웹뷰를 표시하고 닫을 수 있다.

Native는 모달이라는 기능의 존재와 컨테이너 생명주기는 알지만, 모달 payload의 구조와 의미는 알지 않는다. 새로운 모달이 추가되어도 Native의 capability 자체는 바뀌지 않도록 경계를 잡았다.

서로 다른 WebView 사이에서 상태 전달하기

데이터를 작성하는 WebView와 모달을 표시하는 WebView는 동시에 존재하지 않았다. Source WebView → 조건 충족 → WebView 종료 → Home → Modal WebView 생성 순서로 진행되고, 그 사이에 앱 프로세스가 종료될 가능성도 있었다. 상태는 원래 WebView가 사라진 뒤에도 유지될 수 있어야 했다.

sessionStorage를 선택하지 않은 이유

sessionStorage는 page session과 browsing context에 묶인다. 별도의 WebView가 생성되고 앱 재실행까지 고려해야 하는 구조에서는 Source WebView와 Modal WebView 사이의 전달 수단으로 적합하지 않았다. 필요한 것은 특정 페이지 세션보다 오래 유지되는 저장소였다.

Native Storage를 선택하지 않은 이유

Native application storage에 JSON 문자열을 저장하는 방법도 검토했다.

typescript
bridge.setStorage(
  'pending-modal',
  JSON.stringify(modal),
)

Native가 JSON의 내용을 해석하지 않고 단순한 key-value storage로 취급한다면 데이터의 의미는 계속 Web이 소유할 수 있다. 아키텍처적으로 문제가 있는 방법은 아니었다.

다만 당시 앱에는 Web을 위한 범용 Native Storage capability가 없었다. 이 방식을 선택하면 Web Bridge contract 정의부터 Android와 iOS 각각의 저장 구현, read/write/remove 동작 통일, 앱 버전별 호환 처리, 플랫폼별 QA, 앱 배포까지 한 줄로 이어졌다.

향후 여러 기능에서 공통으로 사용할 요구가 확인됐다면 투자할 수 있는 범위였다. 하지만 첫 모달 MVP를 검증하는 시점에 범용 저장 capability부터 만드는 것은 작업 범위를 크게 만들었다.

반면 localStorage는 기존 WebView 기능에서 이미 사용하고 있었다. Android WebView의 DOM Storage API는 기본적으로 꺼져 있고 setDomStorageEnabled(true)로 켜야 하는데, 이미 쓰고 있다는 것은 그 설정이 두 플랫폼 모두에서 이미 켜져 있다는 뜻이기도 했다. 새로 확인할 전제가 하나 줄어든 셈이다.

localStorage를 선택한 이유

Source WebView와 Modal WebView의 document는 모두 example.com에서 제공했다.

text
Source WebView
https://example.com/...

        │ localStorage

┌─────────────────────────┐
│ pending-modal           │
│ "{ ...JSON... }"       │
└────────────┬────────────┘

             │ same origin

Modal WebView
https://example.com/modal

JavaScript와 이미지 같은 정적 asset은 cdn.example.com에서 제공했지만, 이는 localStorage의 origin을 바꾸지 않는다. 모달 document가 CDN의 JavaScript를 불러오더라도 마찬가지다.

html
<script src="https://cdn.example.com/modal/app.js"></script>

document origin과 asset origin의 분리

JavaScript는 example.com/modal document의 browsing context에서 실행된다. 따라서 코드가 접근하는 localStoragecdn.example.com가 아니라 example.com의 저장소다. 중요한 것은 asset URL이 아니라 document URL이었다. 두 document가 example.com이고 asset만 cdn.example.com인 구성에서, Web Storage는 example.com 하나만 본다.

같은 origin이라는 조건 위에, 두 WebView가 같은 persistent 저장 환경을 쓴다는 조건도 필요했다. iOS는 WKWebViewConfiguration이 기본으로 WKWebsiteDataStore.default()를 쓰기 때문에 여러 WKWebView가 같은 디스크 저장소를 공유한다. nonPersistent()나 식별자를 가진 별도 data store를 쓰면 공유되지 않는다. Android도 WebView Profile을 분리하면 같은 문제가 생긴다.

두 조건이 모두 성립했기 때문에, 모달 데이터는 Web에서 만들어지고 다른 WebView의 Web에서 소비될 수 있었다. Native Bridge를 통해 payload를 왕복하지 않으므로 Native가 데이터 구조를 알 필요도 없었다.

이 선택에는 다음 제약을 명시했다.

  • 두 WebView의 document가 같은 scheme, host, port를 사용해야 한다.
  • 동일한 persistent WebView data store 또는 profile을 사용해야 한다.
  • 저장 데이터는 민감한 인증 정보가 아니라 모달 표현에 필요한 값으로 제한한다.
  • 로그아웃이나 WebView 데이터 삭제 시 pending 데이터가 함께 사라질 수 있다.
  • 저장소 접근 실패가 홈 사용 실패로 이어져서는 안 된다.

향후 모달 웹뷰 document를 cdn.example.com이나 다른 서브도메인에서 직접 제공한다면 example.comlocalStorage를 공유할 수 없다. CORS를 설정하더라도 서로 다른 origin의 Web Storage가 하나로 합쳐지는 것은 아니다. 뒤에서 별도 앱을 검토할 때도 document origin 유지가 중요한 전제가 됐다.

localStorage 위에 단일 슬롯 프로토콜 만들기

localStorage를 선택했다고 해서 상태 전달 문제가 모두 해결되는 것은 아니었다.

모달은 일반적인 사용자 설정이나 장기 보관 데이터가 아니었다. 다음 홈 진입에서 한 번 소비하기 위한 짧은 수명의 데이터였다. 그래서 저장소를 여러 값을 보관하는 Queue가 아니라 pending-modal 키 하나짜리 단일 슬롯으로 사용했다.

typescript
localStorage.setItem(
  PENDING_MODAL_KEY,
  JSON.stringify(modal),
)

새로운 모달이 저장되면 기존 값을 덮어썼다. 제품 정책도 한 번에 하나의 모달만 보여주는 것이었다. 여러 조건이 충족됐더라도 홈에서 모달을 연속으로 보여주는 UX는 원하지 않았다. Modal A를 저장한 뒤 Modal B를 저장했다면 홈에서는 Modal B만 뜬다.

따라서 Queue, Priority, Ordering, Retry count 같은 상태를 추가하지 않고 single slot + last-write-wins로 제한했다. 이 정책은 writer가 사실상 하나이고, 마지막으로 기록된 모달이 가장 중요한 상황을 전제로 한다. 여러 기능이 동시에 값을 기록하거나 모달별 우선순위가 필요해진다면 별도의 정책이 필요하지만, 첫 MVP에서는 다루지 않았다.

저장 데이터는 모달을 바로 그릴 수 있는 형태였다

MVP에서 저장한 값은 당시 모달을 렌더링하는 데 필요한 title, image, CTA 등의 표현 데이터로 구성했다. 개념적으로는 다음과 같은 형태였다.

typescript
type PersistedModalV1 = {
  version: 1
  title: string
  description?: string
  imageUrl?: string
  cta: {
    label: string
    url: string
  }
  createdAt: number
  expiresAt: number
}

이 구조의 장점은 모달 웹뷰가 추가 API를 호출하지 않고 저장된 값만으로 UI를 렌더링할 수 있다는 점이다.

대신 저장된 데이터는 작성 시점의 UI snapshot이 된다. Web v1이 모달 데이터를 저장한 뒤 Web v2가 배포되더라도, 이미 저장된 title, image, CTA가 자동으로 새 값으로 바뀌지는 않는다.

이 구조에서 “웹 배포만으로 모달을 바꾼다”는 의미는 이렇게 좁혀진다.

  • 이후 생성되는 모달의 UI와 payload는 Web 배포로 변경할 수 있다.
  • Native는 변경된 모달 schema의 구체적인 의미를 알 필요가 없다.
  • 이미 저장된 pending 데이터는 만료되거나 소비될 때까지 작성 당시 값을 유지한다.

이미 저장된 모달까지 최신 표현으로 바꿔야 한다면 완성된 ViewModel 대신 kind + 최소 context만 저장하고, 최신 Web이 UI 모델을 만드는 방식이 더 적합하다. 첫 MVP에서는 추가 API 없이 바로 렌더링할 수 있는 단순한 snapshot 구조를 선택했다.

expiresAt을 둔 이유도 여기에 있다. snapshot은 오래될수록 틀려질 가능성이 커지므로, 만료되면 그리지 않는 편이 낫다.

TTL은 하루 단위로 잡았다. 앱을 껐다가 다음 날 다시 켜는 경로까지는 살리고, 그보다 오래 남아 있던 값은 문구나 이미지가 이미 바뀌었을 가능성이 커서 버리는 쪽이 안전하다고 봤다. createdAtexpiresAt은 기기 시계를 기준으로 하므로 사용자가 시간을 바꾸면 만료 판정이 흔들린다. 다만 그때 벌어지는 최악의 일이 모달 하나를 더 보거나 덜 보는 것이라, 서버 시각을 받아오는 보정은 넣지 않았다.

저장소에서 읽은 값은 unknown으로 취급했다

TypeScript 타입은 localStorage에 저장된 값을 보장하지 않는다. 다음 코드는 컴파일러에게만 타입을 알려줄 뿐, 실제 데이터가 올바르다는 사실을 확인하지 않는다.

typescript
const modal =
  JSON.parse(raw) as PersistedModalV1

저장된 값은 여러 이유로 예상과 다를 수 있다.

  • JSON이 깨져 있을 수 있다.
  • 이전 웹 버전이 저장한 값일 수 있다.
  • 필수 필드가 없을 수 있다.
  • 지원하지 않는 version일 수 있다.
  • 만료된 데이터일 수 있다.
  • 개발 또는 테스트 과정의 값이 남아 있을 수 있다.

따라서 persistence boundary에서 가져온 값은 unknown으로 취급하고, 파싱과 런타임 검증을 통과한 값만 UI에 전달했다.

flowchart LR
    A["localStorage raw string"] --> B{"JSON.parse"}
    B -- 실패 --> X
    B -- 성공 --> C["unknown"]
    C --> D{"구조 검증<br/>version · 필수 필드 · expiresAt"}
    D -- 실패 --> X
    D -- 통과 --> E["PersistedModal"]
    E --> F{"cta.url allowlist<br/>scheme · host"}
    F -- 실패 --> X
    F -- 통과 --> G["모달 렌더"]
    X["모달 포기<br/>웹뷰 닫고 홈 유지"]

    style F stroke-dasharray: 5 5

파싱 실패, 알 수 없는 버전, 필수 필드 누락, 만료된 데이터는 모달을 표시하지 않고 정리했다. 복잡한 migration 체계를 미리 만들지는 않았다. 첫 MVP에서는 작은 version envelope를 두고, 지원하지 않는 버전은 폐기하는 방식으로 호환 범위를 제한했다.

검증 범위를 지금 다시 보면 빠진 것이 하나 있다. cta.url은 구조 검증만으로 걸러지지 않는 필드다. url: stringjavascript:로 시작하는 값도, 임의의 외부 도메인도 통과시킨다. 저장소에서 꺼낸 문자열이 그대로 <a href>나 라우팅으로 들어가는 경로였으니, 스킴과 호스트를 allowlist로 제한하고 통과하지 못한 값은 CTA 없이 그리거나 모달을 버리는 규칙이 같은 자리에 있어야 했다. localStorage를 신뢰 경계로 다루기로 한 이상, 모양이 맞는지와 값이 안전한지를 같은 선에서 봤어야 맞다.

읽은 뒤에는 바로 삭제했다

모달 웹뷰는 pending 값을 읽으면 저장소에서 바로 삭제했다.

typescript
const raw =
  localStorage.getItem(PENDING_MODAL_KEY)
if (raw === null) {
  return null
}
localStorage.removeItem(PENDING_MODAL_KEY)
const modal = parsePendingModal(raw)

이 정책은 렌더링 도중 오류가 발생하면 모달이 사용자에게 보이지 않은 채 사라질 수 있다는 트레이드오프가 있다. 반대로 렌더링이나 사용자 닫기 이후까지 값을 남겨두면, 앱이 중간에 종료됐을 때 같은 모달이 다시 나타날 수 있다.

이 모달은 결제나 계약 상태처럼 반드시 전달되어야 하는 데이터가 아니었다. 중복 노출을 줄이고 홈 사용성을 유지하는 것이 더 중요했기 때문에 읽은 값을 먼저 제거하는 방향을 선택했다. 정확히 한 번 보여주는 전달 보장을 별도로 만들지는 않았다.

읽어온 값은 메모리에 들고 있으므로 리렌더는 견딘다. 다만 document 자체가 다시 로드되면 값은 이미 없다. Android에서 화면 회전이나 다크모드 전환으로 Activity가 재생성되거나, WebView가 메모리 회수 뒤 복구되는 경우가 여기에 해당한다. 이때 모달은 그냥 뜨지 않는다. 다음 절의 실패 정책과 같은 자리에 도착하는 셈이라 따로 다루지 않았다.

실패하면 모달을 포기했다

이 기능에서 우선순위가 더 높은 것은 홈을 정상적으로 사용하는 것이었다. 모달이 성공하면 표시하고, 실패하면 모달 웹뷰를 닫고 홈을 유지한다.

다음 상황에서는 모달을 표시하지 않고 종료할 수 있어야 했다.

  • localStorage 접근 실패
  • JSON 파싱 실패
  • 스키마 검증 실패
  • 알 수 없는 version
  • 만료된 데이터
  • 지원하지 않는 데이터
  • 모달 렌더링 오류
  • WebView document 로드 실패

Web 코드가 실행된 뒤 발생하는 데이터 오류와 렌더링 오류는 Web에서 처리했다. document 자체를 불러오지 못하는 경우에는 Web 코드가 실행될 수 없으므로, timeout과 main-frame load error 같은 컨테이너 오류는 Native가 처리하도록 책임을 나눴다.

오류를 복구하기 위해 사용자를 로딩 화면에 계속 머물게 하거나 무한 재시도하지 않았다. 정상 콘텐츠를 보여줄 수 없다면 모달 웹뷰를 닫고 홈을 그대로 사용할 수 있게 했다.

한 가지 더 있다. 저장과 이벤트 전송은 서로 다른 시스템에 대한 두 번의 쓰기라 부분 실패가 가능하다. 저장은 됐는데 이벤트가 나가지 못하면 payload는 TTL까지 남았다가 만료된다. 이벤트는 나갔는데 저장이 실패하면 모달 웹뷰가 열렸다가 읽을 값이 없어 바로 닫힌다.

두 실패는 결국 같은 곳에 도착한다. 모달이 뜨지 않고, 홈은 영향을 받지 않는다. 두 쓰기를 하나의 트랜잭션으로 묶는 대신 실패가 수렴하는 지점을 맞추는 쪽을 선택했다.

기존 Pages Router 앱 안에서 초기 실행을 줄였다

네이티브 모달은 이미 앱 안에 있는 UI를 바로 표시할 수 있다. 반면 모달 웹뷰에는 WebView 생성, document 요청, CSS와 JavaScript 로드, React 실행 비용이 추가된다.

홈 화면이 먼저 표시된 뒤 모달이 늦게 나타나면 사용자가 지연을 직접 인지한다. 일반 서비스 페이지보다 초기 렌더링 경로를 단순하게 유지할 필요가 있었다.

모달 문서는 정적으로 두었다

모달 document에는 요청 시점에 서버가 생성해야 하는 개인화 HTML이 없었다. 모달의 표현 데이터는 localStorage에 저장되어 있었기 때문에, 서버에서 같은 정보를 다시 조회하거나 HTML을 동적으로 생성할 이유가 없었다.

그래서 모달 Page는 정적으로 생성할 수 있는 구조로 두었다. 정적 document와 정적 CSS, 최소한의 JavaScript를 받아 localStorage를 읽고 바로 그리는 경로다.

getServerSidePropsCache-Control을 붙여 응답을 캐시하는 방법도 있으니 SSR이 기술적으로 불가능한 선택지는 아니었다. 다만 이번 모달에는 request-time rendering의 이점이 없었고, 정적 document가 실행 경로와 운영 범위를 더 단순하게 만들었다.

모달 웹뷰는 네트워크 요청 없이 렌더링했다

모달 웹뷰가 열린 뒤 설정 API를 호출했다면 Modal document 요청 → JavaScript 실행 → Modal API 요청 → API 응답 → Render라는 waterfall이 추가된다.

하지만 모달에 필요한 title, image, CTA는 Source WebView에서 이미 알고 있었다. 같은 데이터를 다시 조회할 필요가 없었기 때문에 저장된 payload를 바로 읽어 렌더링했다. 실제 경로는 Modal document 요청 → JavaScript 실행 → localStorage read → Render로 끝난다.

Backend API를 만들지 않은 결정은 변경 범위를 Web 안에 유지하는 동시에 초기 네트워크 요청을 하나 줄이는 방향으로도 작동했다.

_app.tsx에서 모달 경로를 분기했다

당시 서비스는 Next.js Pages Router를 사용하고 있었다. 일반 서비스 페이지를 위한 _app.tsx에는 Auth Provider, 전역 상태, Analytics, Experiment, 애플리케이션 bootstrap, 공통 layout이 들어 있었다. 모달 웹뷰에는 이 중 대부분이 필요하지 않았다.

그래서 _app.tsx에서 모달 Page를 구분해 일반 앱의 Provider와 bootstrap을 마운트하지 않았다. 개념적으로는 다음과 같은 구조다.

typescript
export default function App({
  Component,
  pageProps,
}: AppProps) {
  if (Component.isModalPage) {
    return <Component {...pageProps} />
  }
  return (
    <AppProviders>
      <AppLayout>
        <Component {...pageProps} />
      </AppLayout>
    </AppProviders>
  )
}

일반 서비스 화면은 _app → AppProviders → AppLayout → Page 경로를 그대로 유지하고, 모달 Page만 _app → Modal Page로 끝난다.

이 분기로 줄일 수 있는 것은 다음과 같은 runtime 작업이었다.

  • Provider mount
  • Provider 내부의 effect 실행
  • 전역 상태 구독
  • 일반 layout 렌더링
  • 모달에 필요하지 않은 bootstrap
  • Provider mount와 함께 시작되는 네트워크 요청

다만 조건부 렌더링만으로 _app.tsx가 정적으로 import한 모든 dependency가 모달 bundle에서 사라진다고 가정하지는 않았다. Provider를 마운트하지 않는 것과 Provider 코드가 bundle에서 제거되는 것은 다른 얘기다.

Next.js는 페이지 단위로 코드를 분할하지만 _app.tsx는 모든 Page가 공유하는 공통 진입점이다. 이번 분기의 목적은 완전한 build-level isolation이 아니라 모달 경로에서 불필요한 runtime 실행을 줄이는 것이었다.

최적화 비용을 기본 경로에 전가하지 않았다

모달 bundle을 줄이기 위해 일반 앱의 전체 shell을 dynamic import하는 방법도 있다.

typescript
const MainAppShell = dynamic(
  () => import('../main/MainAppShell'),
)

하지만 일반 앱은 대부분의 사용자가 사용하는 기본 경로다. 드물게 열리는 모달을 가볍게 만들려고 기본 앱의 critical shell에 새로운 비동기 chunk boundary를 추가하면, 모달 경로를 아끼려다 일반 앱의 초기 렌더링과 hydration을 늦출 수 있다. 우선순위가 뒤집히는 셈이다.

따라서 일반 앱의 인증 상태, 핵심 전역 상태, 기본 layout처럼 첫 화면에 필요한 코드는 기존 경로를 유지했다. 필요하다면 analytics나 experiment SDK처럼 첫 렌더에 필요하지 않은 기능을 별도로 지연할 수 있지만, 전체 shell을 모달 때문에 동적 로딩하지는 않았다.

이번 MVP에서는 다음 원칙으로 범위를 정했다.

  • 기본 앱의 critical path는 바꾸지 않는다.
  • 모달 Page는 Pages Router의 Page 단위 code splitting을 사용한다.
  • 모달 경로에서는 일반 Provider와 bootstrap을 마운트하지 않는다.
  • 별도 앱과 배포 구조는 기능 확장 시 다시 검토한다.

느린 네트워크에서 2.1초가 1.1초가 됐다

모달 표시까지 걸린 시간을 이전과 이후로 비교한 막대 그림. SSR·설정 API·전역 bootstrap을 포함하던 이전이 2.1초, 정적 document와 저장소 읽기만 남긴 이후가 1.1초이고, 아래에 설정 API 요청 제거·정적 document 사용·Provider mount 제외 세 가지가 놓여 있다

세 가지를 걷어낸 효과는 Slow 4G 스로틀링 기준으로 확인했다. 모달 웹뷰가 열리고 모달이 화면에 보이기까지 약 2.1초 걸리던 것이 1.1초 수준으로 줄었다.

가장 큰 몫은 설정 API 호출을 없앤 데서 나왔다. document를 받고 JavaScript를 실행한 뒤에야 시작되는 요청이라, 느린 네트워크에서는 왕복 한 번이 그대로 지연에 얹힌다. request-time rendering을 걷어낸 것은 첫 바이트가 도착하는 시점을 앞당겼고, Provider와 bootstrap을 마운트하지 않은 것은 JavaScript 실행 시간과 Provider가 함께 띄우던 요청을 줄였다.

체감이 갈리는 구간이기도 하다. 홈이 이미 떠 있는 상태에서 뒤늦게 올라오는 화면이라, 여기서의 1초는 첫 화면에서의 1초보다 눈에 띈다.

preload는 첫 번째 선택지에서 제외했다

1.1초에서 더 줄이려면 남은 방법은 WebView를 미리 만들어두는 것이었다. 미리 생성하고 document까지 로드해두면 표시 시점의 비용은 거의 사라진다. 하지만 그렇게 하면 모달을 보지 않을 사용자도 WebView instance, renderer, JavaScript runtime, DOM, network와 memory 비용을 부담한다.

순서를 뒤집지 않은 것이 중요했다. 실제 경로에서 불필요한 작업을 먼저 걷어내고 그 결과를 본 다음에 preload를 볼지 정하기로 했다. 반대로 했다면 지우면 되는 일을 미리 만들어두는 비용으로 덮었을 것이다.

  1. 정적 document 사용
  2. 추가 API 요청 제거
  3. 일반 앱 Provider와 bootstrap mount 제외
  4. 모달 Page의 의존성 축소
  5. 이후에도 필요하면 preload 검토

1.1초로도 부족해지는 시점이 오면 그때 5번을 꺼내면 된다.

별도 React 앱은 왜 지금이 아니었나

모달 웹뷰를 기존 Pages Router 앱 안에 두는 대신, 모노레포에 가벼운 React 앱을 하나 더 만드는 방법도 있었다.

text
apps/
├─ main-web/
│  └─ 기존 Next.js Pages Router 앱

└─ modal-web/
   └─ 작은 React 앱
packages/
├─ modal-contract/
├─ modal-ui/
└─ native-bridge/

이 구조라면 모달 앱은 React, 모달 UI, localStorage adapter, runtime validator, Native Bridge, Error Boundary 정도만 의존한다. 일반 앱의 Auth Provider, global store, experiment bootstrap, 공통 application lifecycle과는 완전히 분리된다. dependency boundary는 확실히 더 분명해진다.

문제는 origin이었다. 일반 Next.js 앱의 document는 example.com에서, JavaScript와 이미지 같은 asset은 cdn.example.com에서 제공하고 있었다. 별도 모달 앱의 document를 https://cdn.example.com/modal/index.html처럼 CDN에서 직접 제공하면 두 document의 origin이 달라져 기존 localStorage를 공유할 수 없다.

별도 앱을 쓰면서 same-origin을 유지하려면 두 가지 방법이 있었다.

선택지 A. 별도 React 앱의 HTML entry를 example.com/modal.html로 제공하고, JavaScript와 CSS만 cdn.example.com에서 가져온다. same-origin을 유지하면서 _app.tsx를 거치지 않는 별도 React entry를 만들 수 있다. 대신 HTML과 CDN asset의 배포를 함께 조정해야 하고, 기존 Web 배포 artifact와 완전히 독립된 release unit은 아니다.

선택지 B. example.com/modal/* 경로를 별도 Modal Web Origin으로 넘기는 reverse proxy 또는 edge routing을 둔다. WebView가 보는 document URL은 계속 example.com이므로 localStorage를 공유하면서 모달 앱을 독립 배포할 수 있다. 대신 당시에는 example.com 앞에 이런 path routing을 제공하는 CloudFront 구성이 없었다.

origin 문제를 풀어도 운영 문제는 남는다.

  • 별도 build pipeline
  • HTML과 hashed asset의 배포 순서
  • 모달 앱의 배포와 rollback
  • CSP와 security header
  • 오류 수집과 smoke test
  • Source Web과 Modal Web의 독립 배포 버전
  • 저장 payload contract의 호환

특히 payload writer와 reader가 다른 앱으로 분리되면 두 앱의 배포 시점이 달라진다. Main Web v2가 새로운 payload를 저장했는데 Modal Web v1이 이전 parser로 읽는 상황이 생긴다. 모노레포의 공통 contract package로 TypeScript 타입을 공유할 수는 있지만, 이미 배포된 앱 사이의 runtime 호환까지 자동으로 보장되지는 않는다. version, validation, TTL과 배포 순서를 더 명시적으로 관리해야 한다.

첫 MVP에서는 별도 앱이 주는 dependency isolation보다 새로 생기는 빌드, 배포, origin, 호환 관리 범위가 더 컸다. 그래서 기존 Pages Router 앱을 유지하고, 모달 경로에서 불필요한 runtime 초기화만 건너뛰는 방식을 선택했다.

다만 이 구조를 최종 형태로 고정하지는 않았다. 모달이 정식 기능으로 확장되면 상황이 달라진다.

  • 모달 종류가 지속적으로 늘어난다.
  • 여러 팀이 모달을 추가한다.
  • 일반 앱과 모달의 배포 주기를 분리해야 한다.
  • 모달 bundle에 공통 _app.tsx dependency가 계속 포함된다.
  • 모달만 독립적으로 rollback해야 한다.
  • 별도 모달 contract와 운영 도구가 필요해진다.
  • 모달 초기 표시 시간에 더 엄격한 성능 기준이 생긴다.

전환 판단은 아래 저울이 기울어지는 시점이다.

별도 앱의 이점 추가되는 비용
강한 dependency isolation 별도 build와 deployment
모달 독립 배포 writer·reader 버전 호환
모달 독립 rollback same-origin routing
일반 앱과 다른 성능 전략 별도 모니터링과 보안 설정
모달 팀의 독립성 공통 contract 관리

첫 MVP에서는 오른쪽 비용을 먼저 부담할 만큼 왼쪽 이점이 크지 않았다. 기존 앱 환경에 영향을 주지 않으면서 짧은 요구 기한 안에 기능을 배포하는 것이 우선이었다. 별도 앱은 배제한 선택지가 아니라, 정식 기능으로 확장될 때 다시 검토할 구조로 남겼다.

MVP 배포 결과

이 구조를 적용한 모달 웹뷰를 최종 MVP로 배포했다. 모달의 UI와 payload 구조는 Web이 소유하고, Native는 홈 진입과 모달 웹뷰의 생성·종료라는 공통 capability를 제공했다. 모달 설정을 위한 Backend API도, Web을 위한 범용 Native Storage도, Native 쪽 pending 플래그도 새로 만들지 않고 첫 기능의 범위를 제한할 수 있었다.

배포된 구조는 다음과 같이 정리할 수 있다.

모달 웹뷰의 책임 경계를 그린 그림. Source WebView가 데이터를 만들어 localStorage의 pending-modal 단일 슬롯에 저장하고, 이벤트 계층의 trackEvent·GTM을 거쳐 홈 화면이 웹뷰 컨테이너를 띄우면 Modal WebView가 그 값을 읽어 검증하고 렌더한다. 보라색이 Web 책임, 회색이 Native 책임, 초록색이 공유 저장소다

text
Source WebView
= 모달 데이터 생성과 저장
Native Event Layer
= trackEvent 처리와 모달 노출 기회 판단
Native
= 홈 생명주기와 모달 웹뷰 컨테이너
Modal WebView
= 데이터 읽기, 검증, 렌더링, 종료
localStorage
= 서로 다른 WebView 생명주기를 연결하는 단일 슬롯
Pages Router _app
= 일반 앱과 모달의 runtime 초기화 경계

이 구조로 얻은 것은 세 가지로 정리할 수 있다.

첫째, 모달이 화면에 뜨기까지 걸리는 시간을 Slow 4G 기준 2.1초에서 1.1초 수준으로 줄였다.

둘째, Native가 알아야 하는 인터페이스가 모달 종류와 무관해졌다. 조건 충족 신호는 기존 trackEvent를 그대로 썼고, 새로 생긴 것은 홈 위에 모달 웹뷰를 띄우고 닫는 capability 하나였다. 모달이 몇 종류로 늘어나도 이 목록은 늘지 않는다.

셋째, 조건 충족 직후 바로 뒤로가기를 누르는 즉시 노출 경로에서도 테스트상 지연 문제가 없었다. 트리거를 이벤트 파이프라인에 태울 때 가장 먼저 확인해야 하는 지점이었다.

MVP의 목표는 미래의 모든 모달 요구사항을 처리하는 범용 플랫폼을 만드는 것이 아니었다.

  • 모달은 홈에서 한 번에 하나만 표시한다.
  • 가장 마지막으로 저장된 모달만 사용한다.
  • 두 WebView의 document는 같은 origin을 사용한다.
  • 모달 데이터는 짧은 시간만 유지한다.
  • 모달 실패는 홈 사용을 막지 않는다.
  • Native는 모달 payload의 의미를 알지 않는다.
  • 기본 앱의 critical path는 변경하지 않는다.
  • 별도 앱은 기능 확장 시 다시 검토한다.

이 제약을 명시한 덕분에 Queue, Priority, 범용 Native Storage, 모달 CMS, 복잡한 migration과 별도 배포 인프라까지 한꺼번에 만들지 않아도 됐다.

그렇다고 MVP라는 이유로 저장된 JSON을 검증 없이 사용하거나, 오래된 데이터를 계속 남겨두거나, 모달 오류가 홈을 막도록 두지는 않았다. 단일 슬롯, version, TTL, 런타임 검증, cleanup, fail-open을 적용해 persisted state의 실패 범위를 제한했다.

이 설계의 중심은 localStorage를 사용했다는 사실이 아니다. 자주 바뀌는 모달의 표현과 데이터는 Web에 두고, 안정적으로 유지되어야 하는 홈과 WebView 생명주기는 Native에 두었다. 첫 MVP에서는 이미 검증된 capability와 기존 배포 단위를 재사용하되, 저장 데이터와 모달 실행 경로가 만드는 위험은 작은 정책으로 제한했다.

그 결과 짧은 요구 기한 안에 MVP를 배포하면서도 Native와 Backend가 모달의 구체적인 UI 모델에 종속되지 않는 경계를 유지할 수 있었다.

참고 자료