웹뷰 라우팅과 뒤로가기 핸들링

같은 document인가, 새 document인가


6편에서 다음 화면을 SDK가 정하게 됐다. 금융사가 업무 결과를 반환하면 빌드에 고정된 그래프가 다음 pageKey를 결정한다.

그런데 다음 화면이 무엇인지 정하는 것과 거기까지 실제로 가는 것은 다른 일이었다.

text
1. SDK가 다음 화면을 정해도 실제 이동 방식은 금융사마다 달랐다
2. 금융사 same-document 라우터는 SDK가 참여할 lifecycle hook을 제공하지 않았다
3. MPA 금융사는 document가 교체되어 JavaScript 메모리가 끊긴다
4. WebView의 뒤로가기 입력은 기본적으로 Web에 도달하지 않는다
5. 브라우저 히스토리에는 SDK 진입 전 금융사 페이지들이 섞여 있다
6. 뒤로가기를 눌렀을 때 어디로 가야 하는지는 화면마다 다르다

앞의 셋은 앞으로 가는 문제이고 뒤의 셋은 되돌아가는 문제다. 순서대로 정리한다.

이동은 세 종류였다

라우팅 소유권과 document 수명을 축으로 나누면 세 가지가 나온다.

모드 다음 화면 결정 실제 이동 실행 document SDK가 해야 하는 일
SDK-managed SDK SDK 유지 화면 전환, 히스토리, 뒤로가기, 로딩
금융사 same-document SDK 금융사 라우터 유지 라우터 호출 전 React 정리, 새 target에서 재렌더
금융사 MPA SDK 금융사 라우터·브라우저 교체 URL 이동 요청, 새 document에서 재초기화
flowchart TD
    A["SDK Flow가 다음 pageKey 결정"] --> B{"이동을 누가 실행하나"}
    B -- SDK --> C["같은 document<br/>React 화면 교체"]
    B -- 금융사 라우터 --> D{"document가 바뀌나"}
    D -- 아니오 --> E["같은 document<br/>DOM 교체"]
    D -- 예 --> F["새 document<br/>URL 이동"]
    C --> G["React root 유지"]
    E --> H["React root 재생성"]
    F --> I["SDK 전체 재초기화"]

2편의 초기 구조는 첫 번째 줄의 이동 방식만 있었다. 같은 document 안에서 SDK가 화면을 갈아 끼웠고 히스토리도 우리 것이었다. 다만 그때는 다음 화면 결정만 금융사에 있었으니, 표의 첫 줄에서 결정 열만 금융사였던 상태다. 금융사가 늘면서 나머지 두 줄이 들어왔다.

여기서 구분해둘 것이 하나 있다. 금융사가 라우터를 소유한다는 것과 그 금융사가 MPA라는 것은 같은 말이 아니다. 자기 라우터로 같은 document 안에서 DOM만 바꾸는 금융사가 있었다.

text
라우팅 소유권
→ 누가 이동 코드를 실행하는가
document 수명
→ JavaScript 메모리와 React root가 살아남는가

cleanup, 로딩, ready 신호, storage의 수명은 라우팅 소유자보다 document 교체 여부에 직접 영향을 받는다. 두 축을 하나로 묶어 생각하면 “금융사 라우터를 쓰는 곳”에 같은 처리를 적용하게 되고, 그중 절반에서 어긋난다.

금융사 라우터의 navigate() 안에서 벌어지는 일

두 번째 모드가 가장 다루기 어려웠다. 금융사 라우터의 navigate() 하나가 여러 작업을 한 번에 수행했다.

sequenceDiagram
    participant S as SDK
    participant N as 금융사 navigate()
    participant D as target DOM
    S->>N: navigate(url)
    N->>D: innerHTML으로 기존 내용 제거
    N->>D: 새 markup 삽입
    N->>N: 새 페이지 script 실행
    N->>S: renderPage() 호출

이 네 단계 사이에 SDK가 끼어들 자리가 없었다. 조건 2가 그것이다. beforeNavigate 같은 hook이 없었으므로 DOM이 언제 지워지는지 SDK는 알 수 없었다.

그래서 순서를 바깥에서 정했다. navigate()를 부르기 전에 SDK가 해야 할 일을 다 끝내는 방식이다.

text
1. 별도 loading root에 로딩 표시
2. 현재 화면의 React root unmount
3. 금융사 navigate() 호출
4. 금융사가 DOM 교체
5. 새 target에서 renderPage()
6. PAGE_READY 이후 로딩 숨김

2번을 3번보다 앞에 둔 이유가 이 순서의 전부다.

React를 먼저 정리해야 하는 이유

navigate()가 하는 일은 target 노드를 떼어내는 것이 아니라 innerHTML로 그 안을 비우는 것이다. React root의 컨테이너는 그대로 남고 컨테이너가 만든 자식만 사라진다. 이 상태에서 실패는 두 가지 형태로 나타난다.

먼저 unmount를 아예 호출하지 못한 경우다. React는 자기 DOM이 사라진 것을 알지 못한다. 감지하는 수단이 없다.

effect cleanup이 돌지 않으므로 타이머와 구독과 이벤트 리스너가 그대로 살아 있고, 진행 중이던 상태 업데이트도 계속 적용된다. 화면에 붙어 있지 않은 노드를 갱신하는 상태로 남는다. 경고는 뜨지 않는다.

다음은 비워진 뒤에 unmount를 호출한 경우다. React는 자기가 만든 자식 노드를 container.removeChild()로 제거하려 하는데, 그 노드는 이미 컨테이너의 자식이 아니다. DOM 명세상 NotFoundError이고, 우리가 쓰던 React 18 환경에서는 이 예외가 unmount() 호출부로 다시 던져졌다.

text
위험한 순서
금융사 navigate() 호출
→ innerHTML으로 target 내부가 비워짐
→ React는 이 사실을 모름
→ unmount를 못 부르면 cleanup 자체가 없음
→ 뒤늦게 부르면 removeChild가 NotFoundError

컨테이너 노드 자체가 부모에서 떨어지는 경우는 사정이 다르다. removeChild는 그 노드와 자식의 관계를 건드리지 않으므로, 나중에 root.unmount()를 불러도 정리가 정상적으로 끝난다. 무엇이 사라지느냐에 따라 여유가 있고 없고가 갈린다.

React 문서가 다루는 쪽은 앞의 경우다. 다른 코드가 root DOM 노드나 그 조상을 제거할 수 있다면 root.unmount()를 호출해 React에게 관리를 멈추라고 알려야 하고, 그러지 않으면 제거된 root 안의 컴포넌트가 정리할 기회를 얻지 못한다고 적혀 있다. 예시도 비활성 탭을 통째로 떼어내는 jQuery 탭 패널이다.

문서는 “제거되면 unmount를 호출하라”고 말하지 순서를 요구하지 않는다. 순서 요구는 우리 쪽 조건에서 나온다. 컨테이너가 남고 안이 비워지는 이상, 호출할 수 있는 시점은 navigate() 앞뿐이다.

text
안전한 순서
root.unmount()
→ React가 effect cleanup 완료
→ 금융사 navigate()
→ DOM 교체
→ 새 target에서 renderPage()

8편에서 로딩을 별도 root로 분리한 것이 여기서 두 번째 역할을 한다. 현재 화면의 root를 먼저 정리해야 하는데, 로딩이 그 root 안에 있으면 로딩부터 사라진다. 두 요구가 서로 맞물려 있어서 한쪽만 해결할 수 없었다.

navigate()를 호출한 뒤 아무 일도 일어나지 않는 경우가 있다. SDK가 그것을 어떻게 알 수 있는지 정리해보면 감지 가능성이 실패 형태마다 다르다.

text
1. navigate()가 동기적으로 throw
   → SDK의 try/catch에서 직접 감지
2. Promise를 반환하고 reject
   → await했다면 직접 감지
3. 라우터 내부 uncaught error가 window까지 전파
   → 전역 리스너에서 간접 감지
4. navigate()는 정상 반환했지만 DOM 교체나 script 실행이 중단
   → 원인은 알 수 없음
   → 다음 renderPage()가 오지 않는 timeout으로 간접 감지
5. 라우터가 내부에서 오류를 잡고 조용히 멈춤
   → 예외 자체를 SDK가 볼 수 없음
   → PAGE_READY 미수신만 관측 가능

3번은 리스너를 하나만 달아서는 안 된다. 비동기 콜백 안에서 던져진 예외는 error 이벤트로 오고, 처리되지 않은 Promise 거부는 unhandledrejection으로 온다. 둘은 서로 다른 이벤트이고 이벤트 객체 타입도 다르다.

3번에는 한계가 하나 더 있다. 다른 origin에서 로드된 스크립트에서 난 오류는 error 이벤트에 도달하더라도 message가 "Script error."로, filename이 빈 문자열로 마스킹된다. 스크립트 태그의 crossorigin 속성과 응답의 Access-Control-Allow-Origin 헤더가 함께 있어야 내용이 보인다. 이 조건이 갖춰지지 않으면 3번은 사실상 4번과 같아진다.

7편의 결론이 여기서 그대로 적용된다. 4번과 5번에서 SDK가 알 수 있는 것은 원인이 아니라 결과다. 이동을 요청했는데 다음 화면이 준비되지 않았다는 사실 하나다.

text
NAVIGATION_DISPATCHED
→ 다음 renderPage 없음
→ PAGE_READY 없음
→ TRANSITION_TIMEOUT

그래서 SDK가 확실히 관측할 수 있는 마지막 경계를 PAGE_READY로 잡았다. 그 앞의 실패 원인을 전부 알아내려는 대신, 사용자가 다음 화면에 도달하지 못했다는 사실을 기준으로 오류 UI를 띄웠다.

뒤로가기 입력은 기본적으로 오지 않는다

여기부터는 되돌아가는 문제다. 그런데 목적지를 정하기 전에 입력을 확보하는 일이 먼저였다.

WebView 안의 Web은 시스템 뒤로가기를 직접 관측할 수 없다. Android의 하드웨어·제스처 back은 Activity 계층에서 먼저 소비되고, iOS에는 하드웨어 back 자체가 없다. 앱이 아무 처리도 하지 않으면 뒤로가기는 WebView를 닫는 동작으로 끝난다. 그것이 기본값이다.

그래서 WebView를 열 때 Web이 Native에 뒤로가기 제어 모드를 설정했다. 뒤로가기를 Web에서 처리하겠다는 선언이다.

text
기본 동작
Native가 back 감지 → WebView 종료

제어 모드 설정 후
Native가 back 감지 → Web으로 이벤트 전달 → Web이 판정 → 필요하면 종료를 다시 요청

Android는 하드웨어 뒤로가기를, iOS는 스와이프를 감지해 같은 이벤트를 내려줬다. 이 선언으로 얻은 것은 뒤로가기가 무조건 종료로 끝나지 않을 자리다. 그러고 나서 남은 질문이 어디로 보낼 것인가였다.

뒤로가기의 목적지는 그래프가 답하지 못했다

앞으로 가는 이동에서는 목적지가 자동으로 나왔다. 금융사가 업무 결과를 반환하면 현재 pageKey와 그 결과로 그래프에서 다음 노드가 하나로 정해진다. SDK가 판단한다기보다 그래프를 조회하는 일에 가까웠다.

뒤로가기에는 그 입력이 없다. 사용자가 눌렀다는 사실뿐이고 업무 결과가 실려 있지 않다.

그래프를 거꾸로 타는 것도 답이 되지 않았다. 앞으로 가는 간선은 조건마다 하나씩이지만, 분기가 합류하는 노드에는 들어오는 간선이 여럿이다. 그래프는 가능한 전이를 담지 지나온 경로를 담지 않는다.

여기에 더해 되돌아가도 되는지가 업무 판단이었다. 이전 화면으로 가면서 접수 건을 정리해야 할 수도 있고, 그 화면을 다시 그리면 안 되는 상태일 수도 있다. 판단 근거를 가진 쪽은 금융사였다.

그래서 뒤로가기 시점에 호출되는 콜백을 열었다. 목적지는 금융사가 정한다.

다만 아무 데로나 보낼 수 있게 두지는 않았다. 콜백이 돌려주는 목적지는 퍼널 그래프 안의 pageKey여야 하고, 그 확인과 이동은 SDK가 한다.

text
앞으로
금융사가 업무 결과 반환 → 그래프가 다음 pageKey 결정 → SDK가 이동 실행
뒤로
SDK가 콜백 호출 → 금융사가 pageKey 지정 → SDK가 그래프로 확인 후 이동 실행

방향이 반대라 묻는 쪽과 답하는 쪽이 뒤집힐 뿐, 금융사가 업무를 판단하고 SDK가 플로우 경계를 지키는 구조는 같다. 이동을 실행하는 방식도 앞에서 정리한 세 모드를 그대로 따랐다.

6편의 그래프가 여기서 두 번째 역할을 한다. 앞으로 갈 때는 목적지를 정하는 데 쓰고, 뒤로 갈 때는 목적지가 이 플로우 안에 있는지 확인하는 데 쓴다.

콜백이 돌려줄 수 있는 결정은 목적지와 확인 여부 두 축이었다.

text
Target
├─ 이전 SDK 화면
├─ 금융사 페이지로 이동
├─ WebView 종료
└─ 차단
Guard
├─ 없음
└─ 확인 팝업

이 구조를 택하면 MPA가 특별한 경우가 아니게 된다. document가 교체되어 SDK 메모리가 끊겨도 콜백은 새 페이지에 있고, 검증에 쓰는 그래프는 빌드에 고정되어 있다. SDK가 이전 화면을 기억하고 있어야 할 이유가 없다.

대신 비용이 하나 생긴다. 뒤로가기가 동작하려면 금융사가 콜백을 구현해야 한다. SDK 번들만 올려서는 아무 일도 일어나지 않는다.

뒤로가기 판정은 이렇게 이어졌다

flowchart TD
    A["Native가 back 이벤트 전달"] --> B{"진행 중인 action이 있나"}
    B -- 예 --> BLOCK["차단"]
    B -- 아니오 --> C["금융사 콜백 호출"]
    C --> D{"Target"}
    D -- 차단 --> BLOCK
    D -- 화면 이동 --> E{"퍼널 그래프에 있는 pageKey인가"}
    E -- 아니오 --> REJECT["이동 거부"]
    E -- 예 --> G{"Guard"}
    D -- WebView 종료 --> G
    G -- 없음 --> RUN["실행"]
    G -- 확인 팝업 --> CONFIRM["확인 후 실행"]

첫 분기만 콜백보다 앞에 있다. 진행 중인 action은 SDK가 dispatch한 것이라 SDK만 아는 상태이고, 여기서 되돌아가면 7편의 결과 불명 문제가 그대로 재현되기 때문이다. 금융사 API가 처리 중인데 이전 화면으로 돌아가면 그 처리가 끝났을 때 화면과 서버 상태가 어긋난다.

그래프 확인이 필요한 이유는 콜백이 틀릴 수 있기 때문이다. 플로우 밖의 화면이 지정되면 사용자는 SDK가 관리하지 않는 상태에 놓인다. 앞으로 갈 때 그래프에 없는 pageKey를 거부하는 것과 같은 처리다.

나머지 판단은 콜백에서 나온다. 어떤 화면에서 어디로 갈지는 업무가 정했다.

대출 실행이 끝난 화면에서 심사 화면으로 돌아가지 않게 한 것이 그 예다. 심사 화면을 다시 그려도 사용자가 할 수 있는 일이 없고, 다시 신청하려 하면 중복 신청이 된다. 화면 구조가 아니라 업무가 판단할 문제다.

첫 진입 화면의 확인 팝업도 마찬가지다. 정보를 많이 입력한 상태라면 확인을 받고, 아직 아무것도 입력하지 않았다면 바로 닫는 편이 낫다. 어느 쪽인지는 그 화면에서 무엇을 받는지 아는 쪽이 정한다.

이 판정을 분기가 아니라 통신 순서로 그리면 다음과 같다.

sequenceDiagram
    participant N as Native
    participant S as SDK
    participant F as 금융사

    N->>S: back 이벤트 전달
    alt 진행 중인 action 있음
        Note over S: 이벤트만 소비, 아무 것도 하지 않음
    else action 없음
        S->>F: 콜백 호출
        F-->>S: Target · Guard 반환
        S->>S: 그래프에서 pageKey 검증
        alt Target이 화면 이동
            S->>S: 이동 실행
        else Target이 WebView 종료
            S->>N: 종료 요청
            Note over N: 컨테이너 제거<br/>완료 여부는 Web에 통보되지 않음
        end
    end

Native와 SDK 사이를 오간 것은 back 이벤트가 넘어올 때와 종료를 요청할 때, 두 번뿐이다. 콜백 호출과 그래프 검증은 전부 SDK 안에서 끝난다. 종료 요청 뒤에 무슨 일이 있었는지는 뒤에서 다룬다.

이 구조를 넣기 전과 후를 비교하면 숫자로도 차이가 났다. 뒤로가기 시 흰 화면이 노출되는 CX 오류는 월 12건에서 0건으로 줄었고, 뒤로가기 자체가 되지 않던 금융사는 3곳에서 0곳이 됐다.

히스토리 후보는 세 개였다

이전 SDK 화면이라는 결론이 나왔을 때는 SDK가 그 화면이 무엇인지 답해야 한다. 이 질문에 답할 수 있는 후보가 세 개 있었고, 세 개가 서로 다른 것을 담고 있었다.

첫 번째는 사용자가 실제로 지나간 경로다. 7편에서 PAGE_READY 시점의 pageKey를 배열에 덧붙여 기록한 그것이다.

text
사용자 행동
terms → identity → 뒤로가기 → terms → identity
퍼널 기록
['terms', 'identity', 'terms', 'identity']

이 배열은 덧붙이기만 한다. 뒤로 갔다고 지우지 않는다. 이탈 지점과 반복 진입을 보려면 지나간 순서가 그대로 남아 있어야 한다.

두 번째는 플로우 안에서 이전 업무 단계가 무엇인지다. 퍼널 기록으로 이걸 대신할 수 없다. 위 예에서 마지막 identity의 이전 화면은 배열상 terms지만, 그 terms는 뒤로가기로 되돌아갔던 것이다. 방문 기록과 논리적 위치는 다르다.

세 번째는 브라우저 히스토리다. 이건 SDK 전용 저장소가 아니다.

flowchart TD
    subgraph BH["브라우저 히스토리에 섞여 있는 것"]
      H1["금융사 진입 페이지"]
      H2["본인 인증 외부 페이지"]
      H3["금융사 라우터 entry"]
      H4["SDK가 넣은 entry"]
      H5["hash 변경"]
    end

세 번째를 판정에 쓰지 않기로 한 이유는 다음 절에 적는다. 결국 셋을 분리해서 각자의 역할만 맡겼다.

text
퍼널 기록
→ 사용자가 지난 경로. 관측용 append-only
SDK 논리 스택
→ SDK가 소유한 화면 구간에서 이전 화면을 정하는 기준
브라우저 히스토리
→ 뒤로가기 판정에는 쓰지 않음

history.length는 이전 화면의 존재를 말해주지 않는다

사용자가 SDK 첫 화면에 있다고 하자. 논리적으로는 이전 업무 화면이 없다. 그런데 window.history.length는 1보다 크다. SDK에 도달하기 전에 금융사 진입 페이지와 본인 인증 페이지가 히스토리에 들어와 있기 때문이다.

여기서 history.back()을 호출하면 사용자는 대출 플로우의 이전 단계가 아니라 인증 페이지나 진입 페이지로 돌아간다. 그 페이지들은 다시 방문될 것을 가정하고 만들어지지 않았으므로, 만료된 토큰으로 오류가 나거나 처음부터 다시 시작된다.

length를 조금 더 들여다보면 이 값이 신호로 쓰기 어려운 이유가 몇 겹으로 나온다.

text
세션 히스토리 전체를 센다
→ origin 구분이 없어 진입 전 페이지까지 포함된다
pushState도 length를 올린다
→ document는 그대로인데 숫자만 늘어난다
뒤로 간 뒤 새로 이동하면 forward entry가 잘린다
→ 단조 증가하지 않는다
브라우저마다 상한이 있다
→ Chromium 계열은 탭당 50에서 포화된다

같은 이유로 진입 경로를 히스토리 개수로 역산하는 것도 안 된다. HTTP 3xx 리다이렉트는 하나의 navigation 안에서 처리되어 entry를 남기지 않고, location.replace()로 넘어온 이동도 entry를 늘리지 않는다. 반대로 location.href 대입이나 form submit은 남는다. 같은 “리다이렉트를 거쳐 들어왔다”도 금융사 구현에 따라 히스토리에 남는 개수가 다르다.

조건 5가 만드는 문제이고, 브라우저가 제공하는 값만으로는 판별할 수 없다.

브라우저 히스토리에 가드 entry를 심어 popstate로 뒤로가기를 받는 방식도 같은 이유로 쓰지 않았다. Native가 이미 이벤트를 내려주는데 입력 경로를 하나 더 만들면, 어느 쪽이 사용자의 입력이고 어느 쪽이 SDK 자신의 히스토리 조작인지 구분할 근거가 사라진다.

WebView 종료는 Web이 할 수 없다

뒤로가기의 결론이 WebView 종료인 경우가 여럿 있다. 이 동작은 Web에서 할 수 없다.

window.close()가 실제로 창을 닫는 조건은 명세에 두 가지로 적혀 있다. 스크립트가 연 창이거나, 세션 히스토리 entry가 하나뿐인 경우다.

WebView는 대개 둘 다 아니다. 앱이 띄운 것이므로 스크립트가 연 창이 아니고, 진입 과정에서 이미 몇 개의 entry가 쌓여 있다. close()는 콘솔 경고만 남기고 아무 일도 하지 않는다.

조건을 통과한다 해도 그다음이 Web의 영역이 아니다. 명세가 정의하는 것은 브라우징 컨텍스트를 닫는 것까지이고, WebView를 뷰 계층에서 제거하는 것은 앱에 위임된다. Android는 onCloseWindow로, iOS는 webViewDidClose로 앱에 통보가 갈 뿐이다. 그래서 종료 요청은 처음부터 Native bridge로 넘겼다.

여기에 관측상의 한계가 하나 붙는다. Web이 종료 요청을 보낸 직후 컨테이너가 사라지면 그 이후의 이벤트 전송이 완료되지 않을 수 있다. 종료를 요청했다는 기록은 남기지만 실제로 종료됐다는 기록은 Web 쪽에서 신뢰하기 어렵다.

text
Web에서 확실한 것
→ 종료를 요청했다
Native에서만 확실한 것
→ 컨테이너가 실제로 닫혔다

이 구분이 필요한 이유는 이탈 분석 때문이다. 사용자가 스스로 나간 것과 오류로 나간 것과 앱이 죽은 것은 다른 사건인데, Web 로그만 보면 셋 다 마지막 이벤트 이후 아무것도 없는 상태로 보인다.

document가 바뀌면 무엇이 끊기는가

MPA 금융사에서는 위의 논리 스택이 메모리에 남지 않는다. document가 교체되면 JavaScript 실행 환경 자체가 새로 시작된다.

text
새 document에서 사라지는 것
├─ SDK 논리 스택
├─ 이전 React root
├─ 별도 loading root
└─ 진행 중이던 action 상태
새 document에서도 남는 것
├─ 같은 origin의 sessionStorage
└─ 브라우저 히스토리

뒤로가기가 여기서 크게 문제되지 않은 것은 목적지를 콜백이 지정하기 때문이다. 스택이 사라져도 콜백은 새 페이지에 있고, 검증에 쓰는 그래프는 번들에 들어 있다.

남는 문제는 앞으로 가는 쪽이다. 새 document에서 요청된 pageKey가 정당한지 판단할 근거를 메모리 밖에서 찾아야 했다. 여기서도 그래프가 그 역할을 한다.

다만 그래프가 답해주는 것은 “요청된 화면이 이 플로우에 존재하는가”까지다. 어느 경로로 왔는지는 그래프가 알지 못하므로, 이전 화면을 복원하는 용도로는 쓸 수 없다.

퍼널 기록은 sessionStorage에 뒀기 때문에 document가 바뀌어도 이어졌다. sessionStorage는 document가 아니라 탭에 매달려 있어서, 같은 origin이면 전체 페이지 이동을 건너뛰고도 유지된다. 같은 금융사 안에서는 origin이 같았으므로 다음 JSP가 이전 기록을 읽어 덧붙일 수 있었다.

여기서 한 가지는 하지 않았다. 저장된 기록으로 플로우를 복원하는 기능이다.

text
sessionStorage에 저장한 것
→ pageKey 배열뿐
저장하지 않은 것
├─ 업무 데이터
├─ 입력값
└─ 플로우 상태 스냅샷

WebView가 백그라운드에서 초기화되거나 종료된 뒤 이어서 진행하게 만들려면 상태 복원이 필요하다. 그런데 복원한 상태가 금융사 서버의 실제 상태와 일치하는지는 SDK가 확인할 수 없다. 사용자가 나가 있는 동안 서버 쪽에서 심사 결과가 바뀌었을 수도 있고, 다른 채널에서 같은 건이 진행됐을 수도 있다. 판단 근거를 가진 쪽은 금융사 서버이므로, 사용자가 다시 진입하면 서버가 현재 상태로 다시 화면을 결정하게 했다.

복원을 넣었더라도 기대만큼 동작하지 않았을 가능성도 있다. sessionStorage는 탭에 붙어 있는 저장소라서 WebView 인스턴스가 파괴되면 함께 사라진다. Android의 saveState가 직렬화하는 것도 back/forward 목록이지 web storage가 아니다.

정리

문제는 다음 화면을 정하는 것과 거기까지 가는 것이 다른 일이라는 데 있었다.

문제 원인 처리
금융사 라우터에 끼어들 자리가 없다 lifecycle hook 미제공 라우터 호출 전에 SDK 정리를 끝낸다
target 내부가 비워지면 React가 어긋난다 컨테이너는 남고 자식만 사라진다 root.unmount()를 먼저 호출한다
조용한 이동 실패를 볼 수 없다 라우터가 예외를 삼킨다 PAGE_READY 미수신을 기준으로 삼는다
뒤로가기 입력이 Web에 오지 않는다 Native가 먼저 소비하고 WebView를 닫는다 제어 모드를 선언해 이벤트를 넘겨받는다
뒤로가기 목적지를 그래프가 답하지 못한다 역방향 간선이 유일하지 않고 업무 판단이 필요하다 콜백으로 금융사가 정하고 그래프로 확인한다
이전 화면이 있는지 알 수 없다 브라우저 히스토리에 남의 entry가 섞여 있다 논리 스택을 별도로 관리한다
document 교체로 상태가 끊긴다 JavaScript 실행 환경이 새로 시작된다 그래프로 검증하고 복원은 하지 않는다

앞으로 가는 이동과 뒤로 가는 이동은 결정하는 쪽이 달랐지만 같은 그래프를 썼다. 앞에서는 목적지를 정하는 데 쓰고, 뒤에서는 목적지를 확인하는 데 썼다. 금융사에 결정을 넘기면서도 플로우가 깨지지 않은 것은 그 경계 때문이었다.

다음 편은 마지막이다. 설계했지만 적용하지 못한 것, 만들지 않기로 한 것, 그리고 우리가 번들을 배포한다고 끝나지 않는 구조에 대해 쓴다. 뒤로가기 콜백처럼 금융사 코드가 준비되어야 동작하는 것이 늘어나면, 버전은 코드 번호가 아니라 금융사와의 약속이 된다.