인앱약정 SDK의 최종 구조 정리

고칠 수 없는 자리는 그대로 두고, 고쳐야 할 일을 줄인다

인앱약정 SDK 시리즈 10편. 아홉 편에서 만든 구조를 한 장에 펼쳐 보고, 금융사 30곳까지 가려면 무엇이 더 필요한지를 정리한다. 각 문제를 발견하고 풀어간 과정은 앞 편들에 있다. 이 글에서는 그 결과로 남은 책임 경계와 실행 구조를 한 장에 다시 조립하고, 아직 남은 문제를 정리한다.


문제는 이탈이었다

사용자는 우리 앱에서 여러 금융사의 대출 조건을 비교하고 원하는 상품을 골라 신청한다. 문제는 그다음이었다.

약정은 금융사가 해야 했다. 신청 버튼을 누른 사용자는 해당 금융사의 앱을 설치하고, 다시 로그인하고, 본인인증을 처음부터 다시 거쳐야 했다.

여기서 사람이 빠져나갔다. 비교하고 고민해서 신청까지 온, 가장 멀리 걸어온 사용자를 마지막 한 걸음에서 놓쳤다.

flowchart LR
    S["대출 비교 · 신청"] --> Q{"약정은 어디서 하나"}
    Q -->|기존| OLD["금융사 앱 설치<br/>재로그인 · 재인증"]
    OLD -.->|여기서 빠진다| DROP(("이탈"))
    OLD --> DONE1["약정 완료"]
    Q -->|인앱약정| NEW["우리 앱 웹뷰 안<br/>금융사 도메인 화면"]
    NEW --> DONE2["약정 완료"]

그래서 약정 화면을 우리 앱 안으로 가져오기로 했다. 다만 화면은 금융사가 제공하는 형태여야 했고, 실행 위치도 금융사 도메인이어야 했다. 우리 앱 웹뷰가 금융사 도메인 페이지를 열고 그 페이지 위에 SDK가 화면 전체를 그리는 구조가 된 이유다.

첫 금융사를 붙인 뒤 같은 기간 기존 흐름과 나눠 비교했더니, 인앱 방식의 약정 완료율이 기존보다 30% 가까이 높았다. 가설은 맞았다. 그다음부터는 이 구조를 여러 금융사에서 유지하는 문제였다.


MVP는 렌더러였다

첫 구조는 단순했다. 금융사가 renderPage(pageKey)를 부르면 우리가 그 화면을 그린다. 두 달, 화면 스물몇 개, 금융사 한 곳이었다.

sequenceDiagram
    participant U as 사용자
    participant S as SDK
    participant H as 금융사 JavaScript
    participant A as 금융사 서버
    U->>S: CTA 클릭
    S->>H: props로 받은 콜백 실행
    H->>A: 업무 API 호출
    A-->>H: 심사 결과
    H->>H: 응답을 해석해 다음 화면 결정
    H->>S: renderPage('rejection')
    S->>U: 요청받은 화면 렌더

그때의 분담은 이랬다.

주체 그때 맡은 일
우리 네이티브 앱 WebView 생명주기, 신분증 카메라 촬영, 사용자 정보 주입, 뒤로가기 입력
인앱약정 SDK 요청받은 화면 렌더, 화면 전환, 내부 히스토리
금융사 업무 API 호출, 응답 해석, 다음 화면 선택, 그릴 자리(targetId) 제공

전환을 실행하는 쪽은 SDK인데 무엇으로 전환할지 고르는 쪽은 금융사였다. 이 렌더러는 요청받은 화면을 정확히 그릴 수는 있어도, 그 화면이 왜 왔는지는 알 수 없었다.

이 어긋남에서 나머지 문제가 전부 나왔다.


지금까지 다룬 이야기

2편부터 9편까지 다룬 문제를 두 열로 정리한 표. 왼쪽은 금융사 환경에 맡겨져 있던 변수, 오른쪽은 SDK 경계 안으로 가져온 방법이고, 맨 아래에 끝까지 경계 밖에 남은 것으로 번들이 놓이는 위치가 따로 표시되어 있다

문제는 매번 달랐다. CSS, 로딩, 계약, 흐름, 관측. 해결의 방향은 하나였다. 통제할 수 없는 곳에 놓여 있던 변수를 우리가 소유한 경계 안으로 옮기는 것.

옮기지 못한 것이 하나 남았다. 번들이 놓이는 위치다. 여기서부터 그 문제를 다룬다.


지금은 누가 무엇을 소유하나

세 개의 가로 레인으로 역할을 나눈 표. 우리 네이티브 앱은 WebView 생명주기와 신분증 카메라 촬영, 사용자 정보 주입, 뒤로가기 입력 전달을 소유하고 화면 내용과 업무 판단은 소유하지 않는다. SDK는 화면 렌더와 스타일 격리, 화면의 정의, 업무 결과에서 다음 화면으로의 연결, 전환과 로딩, 오류 UI 정책, 관측 신호를 소유하고 업무 API의 의미와 보고된 결과의 진위, 번들이 놓이는 서버는 소유하지 않는다. 금융사는 업무 API 호출과 응답 해석, 업무 결과 보고, 뒤로가기 목적지 판단, 페이지 이동 실행, 그릴 자리와 파일 배포를 소유하고 어떤 화면을 그릴지와 어떤 오류 UI를 띄울지는 소유하지 않는다

MVP 시절과 비교하면 SDK 열이 크게 늘었다. 늘어난 항목이 바로 앞 도해의 여덟 줄이다.

경계는 두 곳에서 만난다. 네이티브와 SDK 사이는 브릿지가, SDK와 금융사 사이는 계약 표면이 잇는다. 그 두 자리에서 무엇이 오갈 수 있는지 정해두는 일이 이 프로젝트 설계의 대부분이었다.

오른쪽 열은 못 해서 비워둔 칸이 아니다. SDK가 업무 결과의 진위까지 판정하려 들면 금융사 서버의 상태를 추측해야 하고, 금융사가 오류 UI까지 고르게 하면 같은 실패가 금융사마다 다른 화면으로 나타난다. 소유하지 않기로 정한 것도 설계다.


최종 구조

여기서부터는 그 분담이 실제 구조에서 왜 이런 모양이 됐는지 본다.

로드 순서

sequenceDiagram
    participant H as 금융사 페이지
    participant Q as 스텁 · 커맨드 큐
    participant E as loan-sdk.js
    participant S as SDK 런타임
    H->>Q: 스텁 설치 (동기 실행)
    H->>Q: configure · renderPage 호출
    Note over Q: 번들이 아직 없다 → 큐에 쌓는다
    Q->>E: async script 삽입 (고정 이름 엔트리)
    E->>S: 해시 청크 로드 · 런타임 초기화
    Note over S: document.readyState 확인
    S->>Q: 큐를 순서대로 flush
    Q->>S: configure 실행
    Q->>S: renderPage 실행
    S->>S: targetId 확인 → host · shadow root · CSS 장착
    S->>S: Page 렌더 · React mount
    S-->>H: PAGE_READY (mount Effect)

이 순서가 복잡해 보이는 건 금융사가 알아야 할 게 하나뿐이어야 했기 때문이다. 금융사 HTML에 고정으로 박히는 것은 이름 하나(스텁)뿐이고, 그 스텁이 언제 무엇을 하는지는 전부 SDK 쪽 소관이다. 번들이 아직 없을 때 호출이 와도 순서가 깨지지 않게 하는 책임은 SDK가 진다 — 금융사 통합 코드가 어떤 순서로 스크립트를 배치하든 기대지 않기 위해서다.

CSS를 금융사 <head>에 두지 않은 것도 같은 이유다. <style> 태그를 쓰려면 금융사 style-src에 우리 nonce나 hash를 매번 새로 걸어야 했는데, 그건 우리 스타일 하나 바꾸는 데 금융사의 배포 절차를 매번 거치게 만드는 일이었다. shadow root 안에 캡슐화하면 그 의존이 사라진다.

계약 표면

앞서 그린 경계가 코드에서는 이 정도로 좁은 계약이 된다.

javascript
// 1. 금융사별 값과 이동 방식
sdk.configure({
  partner: { name: 'BANK_A', logo: 'https://.../logo.png' },
  routes:  { terms: '/loan/terms.jsp', identity: '/loan/identity.jsp' },
  navigation: { navigate(url) { bankRouter.navigate(url) } }
})

// 2. 이 pageKey에 도착했다는 신고
sdk.renderPage({
  pageKey: 'underwriting',
  targetId: 'loan-sdk-root',
  props: {
    async resolve(ctx) {
      const res = await bankApi.underwrite()          // 업무 판단은 금융사
      return ctx.resolve({                            // 화면 연결은 SDK
        status: 'success',
        outcome: res.approved ? 'APPROVED' : 'REJECTED',
      })
    },
  },
})

금융사가 아는 것은 configure로 넘기는 값과 renderPage로 신고하는 업무 결과뿐이다. REJECTED가 어느 화면으로 이어지는지, 그 화면 위에 팝업을 띄울지 공통 오류 UI로 전환할지는 코드 어디에도 금융사 쪽에 없다. 전부 SDK가 정한다.

CTA 한 번에 무엇이 오가나

왼쪽에 화면 네 컷이 시간순으로 놓여 있고 오른쪽에 loading host와 React 트리, SDK 런타임, 금융사 JavaScript, 금융사 서버 다섯 개의 레인이 있는 시퀀스 도해. CTA 탭이 React에 도달하면 SDK로 dispatch되고, SDK는 ref에 실행 중임을 기록한 뒤 CTA를 비활성으로 반영하고 금융사 콜백을 호출한다. 금융사가 서버 응답을 업무 결과로 바꿔 돌려주면 SDK가 허용된 결과인지 확인하고 다음 pageKey를 찾은 뒤, React 밖 DOM인 loading host를 켜고 React root를 unmount하고 금융사 라우터로 이동을 요청한다. 아래에는 예외가 갈라지는 네 지점이 정리되어 있다

MVP 시절에는 명령형이었다. 금융사가 업무 API를 부르고 응답을 해석한 뒤, renderPage('rejection')처럼 갈 화면을 직접 골라 SDK에 넘겼다. SDK는 시키는 대로 그릴 뿐이었다.

문제는 코드보다 설계 문서에서 먼저 드러났다. 퍼널 흐름은 Figma로 미리 설계해뒀는데, 코드에서는 금융사가 아무 pageKey나 자유롭게 지정할 수 있었다. 설계와 실제로 도는 화면 전환이 자꾸 어긋났다.

그래서 명령형을 선언형으로 바꿨다. 지금 금융사가 보고하는 것은 화면 이름이 아니라 업무 결과(ctx.resolve({status, outcome}))뿐이다. 그 상태가 어느 화면으로 이어지는지, 위에 팝업을 띄울지 공통 오류 UI로 전환할지는 전부 SDK가 Flow Definition을 보고 정한다. 화면 렌더링만이 아니라 화면 흐름과 오류 UI 정책까지 SDK가 소유하게 되면서, Figma의 플로우와 코드의 플로우가 갈라질 이유 자체가 없어졌다 — 둘 다 같은 정의를 본다.

한 번의 탭이 실제로 통과하는 경로(잠금·loading host·React root 재생성)는 앞 편과 8편에서 다뤘다. 이 경계 이동이 그 경로가 지금 모양이 된 이유다.

예외는 대부분 한 지점(⑧, 금융사가 무엇을 돌려줬는지 혹은 아무것도 돌려주지 않았는지)에서 갈린다. 그중 timeout을 실패로 확정하지 않는 것이 가장 자주 설명해야 했던 결정이다. SDK는 금융사 API가 어디까지 처리됐는지 모르기 때문에, 시간이 지났다는 이유만으로 사용자 흐름을 바꾸면 모르는 상태를 추측해 제품 동작에 반영하는 셈이 된다.

뒤로가기를 누르면

네이티브가 뒤로가기 입력을 감지해 Web으로 넘기고, SDK가 진행 중인 action이 있는지 먼저 확인한 뒤 금융사 뒤로가기 콜백을 호출하고, 금융사가 목적지와 확인 여부를 돌려주면 SDK가 퍼널 그래프 안의 pageKey인지 검사하고 필요하면 확인 팝업을 렌더한 뒤 실행하는 세로 판정 흐름도. 오른쪽에는 콜백이 돌려줄 수 있는 목적지 네 가지와 확인 여부 두 가지, 그리고 확인 팝업이 뜬 화면이 있다

앞으로 가는 이동에는 업무 결과가 실려 있어서 목적지가 자동으로 나온다. 뒤로가기에는 그 입력이 없다. 사용자가 눌렀다는 사실뿐이다.

그래프를 거꾸로 타는 것도 답이 아니었다. 분기가 합류하는 노드에는 들어오는 간선이 여럿이고, 그래프는 가능한 전이를 담지 지나온 경로를 담지 않는다. 게다가 되돌아가도 되는지는 업무 판단이다. 대출 실행이 끝난 화면에서 심사 화면으로 돌아가면 중복 신청이 된다.

그래서 목적지는 금융사가 정하고, 그것이 플로우 안에 있는지는 SDK가 확인한다. 같은 그래프를 앞에서는 목적지를 정하는 데, 뒤에서는 목적지를 검사하는 데 쓴다.

첫 분기만 콜백보다 앞에 있다. 진행 중인 action은 SDK만 아는 상태이고, 여기서 되돌아가면 서버는 처리를 끝내는데 화면만 앞선 단계로 돌아가 어긋난다.

남의 document 안에서의 자리

지금까지가 시간 축이었다면, 같은 구조를 DOM 관점에서 보면 다른 것이 보인다.

우리 네이티브 앱의 WebView가 금융사 도메인 document를 열고, 그 안에 금융사 JavaScript와 SDK 고정 엔트리, 금융사가 targetId로 지정한 자리에 만든 page host와, 같은 document의 body 직속에 별도로 둔 loading host가 놓인 구조도. page host의 shadow root 안에 SDK CSS와 React 앱과 portal-root가 있고, 아래에는 금융사 코드가 호출하거나 구현하는 계약 표면과 네이티브 브릿지가, 오른쪽에는 업무 API와 SDK 정적 파일을 함께 제공하는 금융사 서버가 있다

SDK가 자기 DOM 자리를 마음대로 고르지 않는 이유는 그릴 자리(targetId)가 금융사 소유이기 때문이다. 그 안에 host를 만들고 shadow root를 붙이지만, 자리의 수명까지 가져오지는 못한다 — 라우터가 innerHTML로 target을 비우면 우리 host도 함께 사라진다. loading host만 그 소유 경계 밖(body 직속)에 따로 둔 것도 같은 이유다. 전환과 로딩은 SDK 소유인데, 금융사가 소유한 자리 안에 있으면 그 책임을 지킬 수 없었다.

shadow 경계는 셀렉터와 스타일까지만 막는다. JavaScript 실행 컨텍스트는 하나이고 host 자체도 여전히 금융사 document의 요소라, 이 경계의 목적은 보안이 아니라 사고 방지였다.

오른쪽이 이 편의 출발점이다. 업무 API도, SDK 정적 파일도 금융사 서버에서 나온다.


그다음 질문: 이 구조를 30곳에 올리려면

위쪽은 브랜치 병합에서 GitHub Actions와 S3 업로드를 거쳐 스토리북의 가이드와 패치 노트를 동기화하는 자동화 구간이고, 아래쪽은 금융사가 패치 노트를 확인해 검토와 승인과 배포 일정을 거치는 수동 구간이다. 그 결과 금융사마다 실제 반영된 SDK 버전이 다르게 남는다

브랜치가 병합되면 GitHub Actions가 빌드하고 테스트하고 산출물을 압축해 S3에 올린다. 그다음 스토리북이 동기화된다. 연동 가이드와 금융사별 패치 노트가 갱신되고, 패치 노트에는 방금 올린 번들의 다운로드 링크가 붙는다.

여기까지가 자동이다. 그리고 여기서 끝난다.

남은 일은 금융사가 그 링크를 눌러 자기 서버에 파일을 올리는 것이다. 변경 검토, 승인, 배포 일정 조율이 붙는다. 담당자가 바뀌면 멈추고, 정기 배포 주기가 분기 단위인 곳도 있다.

4편에서 고정 엔트리와 해시 청크로 줄인 것은 금융사가 HTML을 고치는 일이었다. 파일을 올리는 일은 그대로 남아 있었다. 엔트리도 청크도 폰트도 전부 금융사 서버에 있고, 우리 CDN은 실행 경로에 없다.

결과는 버전 드리프트다. 6편에서 계약을 바꿨을 때 v1과 v2가 한동안 함께 운영된 것도, 7편에서 관측 이벤트에 sdkVersion을 문맥으로 실어야 했던 것도 같은 이유였다. 장애를 볼 때 금융사 이름만으로는 부족했다. 그 금융사가 어느 버전을 올려뒀는지까지 알아야 했다. 별도의 배포 현황 시스템이 없었으므로, 실제 트래픽에서 마지막으로 관측된 sdkVersion이 어느 버전이 돌고 있는지 추정할 수 있는 거의 유일한 단서였다. 트래픽이 없거나 이벤트가 끊긴 금융사의 상태는 그것으로도 알 수 없었다.

목표는 분명했다. 금융사 배포에 대한 의존을 줄인다.

여기까지가 현재 운영 중인 구조다. 아래부터는 그 의존을 줄이려고 검토했다가 접은 선택지와, 아직 구현하지 못한 다음 과제를 다룬다. 적용된 것과 제안이 섞이지 않도록 절을 나눠 적었다.


접은 안: 웹뷰가 번들을 주입한다

먼저 확인한 것은 도메인을 바꿀 수 있느냐였다. 바꿀 수 없었다. 당시 금융사별 세션·보안·연동 제약상 업무 API를 금융사 도메인 문맥에서 호출해야 했고, 약정 화면을 우리가 직접 서비스하는 형태는 선택지가 아니었다.

그렇다면 파일만 우리 쪽에서 내려주면 어떨까.

네이티브가 documentStart 타이밍에 script 태그를 주입해 우리 CDN의 번들을 로드한다. 번들이 도착하기 전에 금융사 코드가 LoanSDK를 부를 수 있으므로, 어디에 두든 동기적으로 설치되는 스텁과 커맨드 큐가 필요했다. 검토안에서는 그 역할을 금융사 서버의 작은 loader.js가 맡게 했다.

4편의 스텁과 같은 패턴이다. 그때는 호출 시점이라는 변수를 큐로 흡수했고, 이번에는 번들의 출처라는 변수를 같은 방식으로 흡수하려 했다.

flowchart LR
    N["Native<br/>documentStart"] -->|script 태그 주입| D["금융사 document"]
    D --> C{"금융사 CSP<br/>script-src"}
    C -->|허용| B["우리 CDN 번들"]
    C -->|차단| X["아무것도 실행되지 않음"]
    L["금융사에 올려둔 loader.js"] -.->|그동안 호출을 큐에 쌓는다| B
    B --> R["큐 flush · 렌더"]

접었다. 이유가 둘이었고, 두 번째가 결정적이었다.

첫째, CSP. 네이티브가 등록한 document-start 코드 자체가 페이지의 inline script처럼 차단되는지는 주입 API와 실행 world, WebView 버전에 따라 달라서 플랫폼별로 확인해야 하는 영역이었다. 확실한 것은 그다음이다. 그 코드가 금융사 document에 <script src="우리 CDN">을 추가하면, 그 리소스 요청은 금융사 CSP의 script-src 대상이 된다. fetch로 받아 실행하는 우회도 connect-src와 동적 코드 실행 정책에 다시 걸린다.

금융사들이 쓰던 출처 허용 목록 방식의 CSP에서는 우리 CDN을 script-src에 추가해야 했다. 그것도 금융사 배포다. 배포를 없애려고 만든 구조가 배포를 요구한다. 그리고 차단되면 SDK JavaScript가 아예 실행되지 않는다. 7편에서 정리한, Web 안에 아무 신호도 남지 않는 그 구간이다.

우회 구현을 더 찾을 수는 있었을 것이다. 문제는 기술적 가능 여부가 아니라, 그 경로까지 운영 계약으로 만들 가치가 있느냐였다.

둘째, 번들 교체는 계약 교체다.

우리 번들과 금융사 JavaScript는 독립적으로 도는 두 프로그램이 아니다. 금융사 코드는 우리가 정의한 outcome 집합을 반환하고, 우리가 정한 자리에서 renderPage()를 부르고, 뒤로가기 콜백을 구현한다. 6편에서 계약을 한 번 바꿨을 때 v1과 v2가 함께 운영될 수밖에 없었던 이유가 이것이다.

검수 없이 번들을 바꿀 수 있다는 것은, 금융사 코드가 믿는 계약과 실제로 실행되는 계약이 어긋난 상태를 언제든 만들 수 있다는 뜻이었다. 그 어긋남은 사용자가 대출 신청 도중에 멈추는 것으로 나타난다.

배포가 느린 것은 비용이지만, 검수가 있다는 것은 비용만이 아니었다.


앞으로의 방향: 번들을 고쳐야 하는 일을 줄인다

질문을 다시 썼다.

text
이전
→ 번들을 우리가 갈아끼울 수 있는가

이후
→ 번들을 갈아끼워야 하는 일을 얼마나 줄일 수 있는가

그래서 실제로 무엇이 자주 바뀌는지 세어봤다.

가장 안 바뀌는 것은 화면별 콜백 계약이었다. 금융사 코드와 강하게 맞물려 있어서 바꾸기도 어렵고 바꿀 일도 드물었다. 자주 바뀌는 것은 둘이었다. 팝업과 안내 문구 같은 데이터, 그리고 퍼널 흐름 수정.

왼쪽에 문구와 팝업, 퍼널 흐름과 오류 정책, 기존 블록의 새 조합으로 만드는 화면, 새 컴포넌트와 콜백 계약이 변경 빈도와 함께 나열되고, 오른쪽의 런타임 주입과 번들 두 갈래로 이어지는 도해. 앞의 셋은 런타임 주입으로 가고 마지막 하나는 번들에 남으며, 아래에 주입 경로가 금융사 도메인이 아니라 우리 웹뷰인 이유가 적혀 있다

문구와 기능 플래그는 4편에서 이미 번들 밖으로 뺐다. 남은 것이 흐름이다.

제안 1. 플로우를 버전이 붙은 런타임 스냅샷으로

6편은 Flow Definition을 일부러 빌드에 넣었다. 금융사와 협의해 확정한 플로우이고, 운영 중 임의로 바뀌어야 하는 설정이 아니라는 이유였다.

지금 다시 보면 그 근거는 “빌드”가 아니라 “임의로 바뀌면 안 된다”였다. 그리고 지금 구조에서 그 정의를 바꾸는 주체도, 금융사와 협의하는 주체도 우리다. 금융사 배포가 필요했던 이유는 협의 때문이 아니라 파일이 금융사 서버에 있었기 때문이다.

옮기되 조건을 붙인다.

text
주입되는 것은 우리 배포 파이프라인이 만든 스냅샷 하나다
→ 런타임에 조립되지 않는다. 버전이 붙은 산출물이다

주입 경로는 금융사 도메인이 아니라 우리 웹뷰다
→ 금융사 CSP의 connect-src와 CORS를 건드리지 않는다
→ 이미 열려 있는 Native bridge를 쓴다

스냅샷은 금융사 document의 JavaScript가 요청하지 않는다. Native 앱의 네트워크 계층이 우리 서버에서 현재 partner와 sdkVersion에 맞는 스냅샷을 받아 검증한 뒤, 브릿지를 통해 SDK에 전달한다. 따라서 금융사 document가 시작한 네트워크 요청이 아니고, 해당 document의 connect-src와 CORS를 새로 열 필요가 없다.

없애려는 것은 배포지 검수가 아니다. 줄이려는 것은 금융사 서버에 파일을 다시 올리는 절차다. 금융사와의 업무 협의나 필요한 검수까지 생략하겠다는 뜻이 아니다. 이 구분이 흐려지면 운영 중에 우리 마음대로 대출 플로우를 바꾸겠다는 말이 된다.

주입이 실패했을 때 무조건 번들 기본값으로 떨어지는 것도 답이 아니다. 새 스냅샷이 법적 문구나 필수 동의, 업무 분기 변경을 담고 있다면 오래된 정의로 조용히 되돌아가는 쪽이 더 위험하다. fallback은 하나의 기술 정책이 아니라 플로우별 판단이어야 한다.

text
1. 현재 sdkVersion과 호환되는 마지막 정상 스냅샷을 쓴다
2. 없으면 해당 플로우의 실패 정책을 따른다
3. 안전하게 이어갈 수 있는 화면에서만 번들 기본값을 허용한다
4. 오래된 정의를 실행하면 안 되는 플로우는 진행을 중단한다

잃는 것도 적어둔다.

6편에서는 그래프 validator를 만들지 않고 QA에 맡길 수 있었다. 플로우 정의가 번들 안에 있었고, 번들은 어차피 금융사 검수와 QA를 함께 통과했기 때문이다. 런타임 주입에는 그 관문이 없다. 목적지 pageKey의 존재, 각 action이 선언한 outcome과 간선의 일치, 종료 경로 도달 가능성은 주입 전에 파이프라인이 걸러야 한다. 다만 6편에서 정리했듯 추가 인증 뒤 다시 심사로 돌아가는 순환은 정상 플로우다. cycle 자체를 막는 검사는 여기서도 쓸 수 없고, 의도된 재시도와 잘못된 무한 순환을 구분하는 일이 남는다.

롤백 단위도 갈라진다. 번들과 플로우가 따로 배포되면 조합이 생긴다. 무엇이 돌고 있었는지 말하려면 관측 이벤트에 sdkVersion 옆으로 스냅샷 버전이 함께 실려야 한다. 실제 분석에서는 여기에 partner와 앱 버전·플랫폼 문맥까지 붙는다.

제안 2. 승인된 화면 조합을 데이터로

남은 것은 화면 추가다.

5편에서 Page는 업무 유형(type)과 표현 유형(variant)과 전환 규칙(actions)으로 정의했고, 화면 안은 책임이 작은 컴포넌트로 나눠뒀다. 그러고 나서 들어온 요구를 보면, 상당수가 새로운 컴포넌트가 아니라 이미 있는 블록의 새로운 배치였다.

그건 코드의 문제가 아니라 데이터의 문제다. 다만 여기서 한 번 잘못 갈 뻔했다.

처음 떠올린 형태는 블록 배열이었다.

json
{
  "pageKey": "additional-terms",
  "type": "TERMS_AGREEMENT",
  "blocks": [
    { "block": "PageHeader",       "title": "추가 약관에 동의해주세요" },
    { "block": "TermsList",        "source": "additionalTerms" },
    { "block": "AgreementControl", "variant": "checkAll" },
    { "block": "PageCTA",          "label": "동의하고 계속", "action": "submit" }
  ]
}

블록 목록이 번들 안의 유한한 집합이니 조합도 유한하다고 생각했다. 틀렸다. 블록의 종류가 유한해도 배열의 순서·개수·반복이 자유로우면 만들 수 있는 화면은 사실상 무한하다. PageCTA를 두 개 넣어도 되는지, TermsListAgreementControl 뒤에 와도 되는지, action이 현재 Page가 선언하지 않은 이름을 가리켜도 되는지. 이 질문에 스키마가 답하지 못하면 5편이 두지 않기로 한 임의의 확장점이 이름만 바꿔 돌아온다.

그래서 제한은 블록 레지스트리가 아니라 업무 유형에 걸어야 한다. type마다 어떤 슬롯이 필수이고 무엇이 선택이며 어떤 action을 연결할 수 있는지를 스키마로 정한다.

text
TERMS_AGREEMENT
  header             필수
  notice             선택
  termsList          1개 이상
  agreementControl   checkAll | individual
  primaryAction      submit만 허용

IDENTITY_GUIDE
  header             필수
  description        필수
  guideImage         선택
  primaryAction      startIdentity만 허용

이쯤 되면 이름도 SDUI보다 승인된 Page Spec에 가깝다. 무엇이든 배열에 담는 구조가 아니라, type별로 허용된 구성을 데이터로 표현하는 것이다. variantactions가 사라지는 것도 아니다. 각 Spec도 기존 Page의 type · variant · actions 경계를 따른다. 데이터로 바꿀 수 있는 것은 해당 type이 허용한 구성과 문구뿐이다.

Storybook에서 확인하는 대상도 이론적 조합 전체가 아니라 이 스키마가 허용하는, 이름이 붙은 Spec 목록이 된다.

Custom Page도 그대로 남는다. 새 요구가 승인된 Spec에 들어오지 않으면 무리하게 스키마를 넓히지 않고 우선 해당 금융사의 Custom Page로 격리한다. 같은 의미의 요구가 다른 금융사에서 반복되면 그때 공통 Spec으로 올린다. 5편에서 정한 판정 규칙이 여기서도 그대로 쓰인다.

줄어드는 것은 화면을 만드는 일이 아니라, 화면을 배치하기 위해 금융사를 움직이는 일이다.


남은 과제: 붙이지 못한 안전장치

7편에서 남겨둔 과제가 하나 있다. SDK JavaScript가 아예 실행되지 않는 구간이다.

금융사 서버가 HTML을 내려주지 못하면 Sentry도, Error Boundary도, 전역 오류 리스너도 등록되지 않는다. Web 안에는 아무 신호도 남지 않는데, 사용자에게는 우리 앱에서 대출 화면이 열리지 않은 사건으로 보인다.

이 구간을 보려면 관측 지점이 Web 밖에 있어야 한다. WebView를 띄운 Native다.

왼쪽은 대출 신청하기에서 WebView open, 페이지 로드 성공, SDK_READY 수신, 신청 플로우 시작으로 이어지는 정상 경로이고 그 사이에 navigation timeout과 SDK ready timeout 두 감시 구간이 끼어 있다. 가운데는 각 타임아웃이 잡아내는 실패 원인이고, 오른쪽은 실패했을 때 사용자에게 보여줄 세 가지 상태다

Native가 걸어야 하는 타이머는 두 개이고, 서로 다른 사건이 해제한다.

flowchart LR
    A["WebView open"] --> B["navigation timer 시작"]
    B --> C["main-frame navigation 완료"]
    C -->|타이머 해제| D["SDK-ready timer 시작"]
    D --> E["SDK_READY 수신"]
    E -->|타이머 해제| F["신청 플로우 시작"]

이 타이머는 WebView가 새 document를 여는 시점에 건다. 금융사 action 콜백의 응답을 기다리는 것과는 별개다.

첫 번째는 navigation timeout이다. DNS, 연결, HTTP 응답, HTML 로드까지가 이 구간에 들어간다.

두 번째는 SDK ready timeout이다. 페이지의 main-frame navigation은 끝났지만 SDK 번들이 내려오지 않았거나, JavaScript 실행·런타임 초기화·브릿지 초기화가 끝나지 않은 경우다. Page의 실제 mount 완료는 별도의 PAGE_READY 신호가 관측 범위다. 실무에서 더 자주 만난 쪽은 이쪽이었다. 도메인 문제만 있는 게 아니라 번들 자체가 로드되지 못하는 일이 꽤 있었다.

두 번째를 감지하려면 Web이 신호를 보내야 한다. SDK가 준비되면 브릿지로 알린다.

javascript
window.AppBridge.onSdkReady()

여기서 신호 두 개를 구분해둘 필요가 있다. SDK_READY는 SDK 런타임과 브릿지 통신이 초기화됐다는 뜻이고, 7편의 PAGE_READY는 각 Page가 React 트리에 mount되어 Effect가 실행됐다는 뜻이다. 둘 다 화면의 픽셀이 실제로 그려졌다거나 폰트와 데이터까지 준비됐다는 뜻은 아니다.

그리고 타임아웃은 실패 구간을 나누는 신호이지, 원인을 단독으로 확정하는 진단값이 아니다. navigation timeout이 걸렸다는 사실만으로 금융사 도메인이 죽었다고 말할 수는 없다. 원인을 좁히려면 별도의 오류 콜백과 네트워크 정보가 함께 있어야 한다.

이게 필요한 이유는 단순하다. 이런 장애는 우리보다 고객이 먼저 알았다. 실제로 그랬다. 그리고 금융사가 늘어날수록 운영해야 할 실패 경로도 늘고, 어느 한 환경에서 문제가 생길 가능성도 함께 커진다.

통제할 수 없는 실행 환경에서는 실패를 막는 것보다 실패를 보는 것이 먼저다. 이 시리즈가 계속 확인한 것이 그거였는데, 정작 SDK가 실행되지 않는 구간에는 그 원칙을 적용하지 못하고 있었다.

실패 화면을 한 장으로 만들면 안 된다

Native가 실패를 감지했다고 “페이지를 불러올 수 없습니다”를 띄우면 안 된다.

이 웹뷰는 사용자가 대출 신청 버튼을 누른 뒤에 열린다. 화면이 안 뜨면 사용자가 궁금한 것은 페이지가 아니라 자기 신청이 접수됐는지다. 그리고 결제와 비슷하게, 요청이 서버에 도착했는데 응답만 못 받은 상태에서 다시 시도하면 중복 신청이 될 수 있다.

그래서 실패를 세 상태로 나눠야 했다.

상태 사용자에게 다음 동작
신청이 아직 시작되지 않음 아직 접수 전이라고 분명히 말한다 다시 시도
신청 진행 상태가 있음 중단된 건이 남아 있다고 알린다 상태 조회 → 이어서 진행
접수 여부 불명확 확인 중이라고 알린다 상태 조회 → 접수됐으면 신청 현황, 아니면 다시 시도

셋을 가르는 판단은 Web도 Native도 하지 않는다. 실패한 구간에서 Web은 실행되지 않았고, Native가 업무 상태를 해석하기 시작하면 판단 주체가 또 하나 늘어난다. 신청 상태를 소유한 서버가 NOT_STARTED·IN_PROGRESS·SUBMITTED·UNKNOWN 같은 복구 상태를 돌려주고, Native는 그 값에 대응하는 화면과 다음 동작을 보여주기만 하면 된다.

이 과제도 결국 브릿지 문제다

두 타이머도, 준비 신호도, 상태 조회도 전부 브릿지를 지난다. 앞에서 정리한 흐름 주입도 마찬가지다. 이 과제를 붙이지 못한 이유도 거기 있었다. 앱 배포 주기와 브릿지 호환 문제였다.

그래서 브릿지를 기능마다 늘리면 안 된다. 브릿지가 기능을 알수록 새 기능마다 앱 배포가 필요해지고, 웹 배포만으로 바꿀 수 있게 만들려던 목적이 다시 앱 배포에 묶인다. 필요한 것은 버전이 붙은 메시지 봉투를 실어 나르는 공통 transport 하나다.

다만 범용 transport와 무제한 기능 호출은 다르다. 채널이 임의의 payload와 임의의 명령을 받아들이면 Native 기능이 그대로 열린다. Android의 legacy addJavascriptInterface는 WebView의 모든 frame에 객체를 노출하고, 호출한 frame의 origin을 앱 쪽에서 안전하게 식별하기도 어렵다. origin 범위를 지원하는 메시지 채널을 쓸 수 있는 앱 버전에서는 허용된 금융사 origin으로 채널을 제한한다. legacy addJavascriptInterface만 가능한 버전에서는 브릿지 내부에서 호출 origin을 판별할 수 없으므로 같은 보장을 할 수 없다. 이 경우 허용된 top-level URL만 로드하고 신뢰할 수 없는 iframe과 외부 navigation을 차단하거나, 런타임 스냅샷 기능 자체를 capability에서 제외해야 한다. 채널이 받아들이는 메시지 type과 payload 스키마도 유한하게 제한한다.

이 transport가 최소한 갖춰야 하는 것은 이 정도다.

  • bridgeVersion 또는 capability negotiation — 주고받을 수 있는 메시지 범위를 버전으로 확인한다
  • 허용된 message type 목록과, type별 payload 스키마·크기 제한
  • main frame · allowed origin 확인
  • request-response correlation ID
  • 중복 수신과 재전송 정책

유한한 어휘 위에서만 성립한다는 점에서, 앞의 Page Spec과 같은 제약이다. 그 채널이 서면 관측 계층도 흐름 주입도 같은 문으로 들어간다.


정리

시리즈 전체를 손그림 한 장으로 정리한 그림. 왼쪽 위에 금융사 앱으로 빠지며 이탈이 나던 기존 여정, 그 아래 우리 앱 웹뷰 안 금융사 도메인에서 끝나는 인앱약정 여정. 가운데에 최종 실행 구조(앱 → 웹뷰 → 금융사 document → page host의 shadow root와 별도 loading host)와 계약 표면 네 개(configure · renderPage · ctx.resolve · 뒤로가기 콜백). 오른쪽에 배포 파이프라인이 S3와 패치 노트에서 끊기고 금융사 배포로 넘어가는 지점, 그리고 앞으로의 방향인 런타임 주입(플로우 스냅샷 · Page Spec 데이터)과 번들에 남기는 것(새 컴포넌트 · 콜백 계약)

무엇 어디에 두는가 누가 배포하는가 상태
React 컴포넌트, 화면 블록 번들 우리가 만들고 금융사가 올린다 운영 중
화면별 콜백 계약, outcome 집합 번들 + 연동 문서 금융사 코드 수정이 함께 필요하다 운영 중
문구, 팝업, 기능 플래그 런타임 설정 API 우리 운영 중
금융사 이름·로고·주소·이동 방식 런타임 configure() 금융사 운영 중
그릴 자리 (targetId) 금융사 document 금융사 운영 중
SDK 엔트리·청크·폰트 금융사 서버 우리가 만들고 금융사가 올린다 운영 중
뒤로가기 목적지, 업무 판단 금융사 콜백 금융사 운영 중
퍼널 그래프, 오류 정책 번들 → 런타임 스냅샷 우리 배포로 끝내려는 대상 제안
화면 배치(Page Spec) 런타임 데이터 우리 제안
로드 실패 감지와 실패 화면 분기 Native 앱 배포가 필요하다 미적용

이 시리즈에서 계속 나온 질문은 하나였다.

내 코드가 남의 환경에서 실행될 때, 어디까지 통제할 수 있는가.

아홉 편 동안의 답은 대체로 “생각보다 많이”였다. 호출 시점도, 파일명도, 화면의 정의도, 다음 화면도, 전환 중에 보이는 것도 하나씩 우리 쪽으로 가져올 수 있었다.

마지막 하나는 그렇지 않았다. 번들이 놓이는 위치는 우리가 정할 수 없고, 앞으로도 정할 수 없을 가능성이 크다.

그렇다면 남은 선택은 그 자리를 옮기는 것이 아니라, 그 자리를 건드려야 하는 일의 수를 줄이는 것이다. 통제할 수 없는 것을 통제하려 드는 대신, 그것에 덜 의존하도록 설계하는 일이다. 돌아보면 이 시리즈는 처음부터 같은 일을 하고 있었다.