로딩을 띄웠는데 사용자는 보지 못했다

소유권, 시점, document 경계, 종료 조건. 원인은 네 갈래였다.


화면이 바뀌는 동안 사용자는 흰 화면을 봤다.

2편에서 남겨둔 문제였다. 화면 전환 중에 무엇을 보여줄지 정하지 않아서, 이전 화면이 사라지고 다음 화면이 준비되기 전까지 아무것도 없는 구간이 생겼다.

그래서 화면이 바뀌는 동안 화면을 덮을 로딩을 붙였다. 이 글에서는 이 UI를 전환 로딩이라고 부른다.

붙인 뒤에도 문제는 끝나지 않았다. loading.show()를 호출했다고 해서 사용자가 로딩을 본 것은 아니었다. 7편 끝에서 예고한 두 가지, 로딩이 이전 화면과 함께 사라지는 경우와 브라우저가 그릴 기회를 얻지 못하는 경우를 따라가다 보니 document 교체와 종료 조건까지 함께 다뤄야 했다.

text
소유권   로딩이 이전 화면과 함께 사라진다
시점     표시 코드는 실행됐지만 화면에 나타나지 않는다
경계     document가 교체되면 이전 로딩을 이어갈 수 없다
종료     로딩은 시작됐지만 끝나지 않는다

앞의 둘은 로딩을 보이게 하는 문제고, 뒤의 둘은 로딩을 어디까지 유지하고 언제 끝낼지의 문제다.

이 글은 다음 다섯 가지를 전제로 한다.

text
unmount   전환할 때 SDK가 현재 화면의 React root를 정리한다
재사용    로딩 UI는 사내 디자인시스템의 모달 컴포넌트에서 출발했다
격리      SDK 스타일은 Shadow DOM으로 금융사 페이지와 분리한다
점유      금융사 코드가 메인 스레드를 오래 점유할 수 있고 SDK는 그 코드를 통제하지 못한다
교체      document 자체가 교체되는 MPA 금융사가 있다

1. 소유권: 로딩이 이전 화면의 React 트리에 있었다

처음부터 로딩을 별도 UI로 만들지는 않았다. 사내 디자인시스템에 모달 컴포넌트가 있었고, 화면을 덮는 오버레이가 필요하다는 점은 같았다. 그래서 그대로 가져다 썼다.

모달은 Portal 기반이었다. Portal은 모달 DOM을 부모 컨테이너의 overflow: hidden 같은 제약 밖으로 옮겨준다. 다만 바뀌는 것은 DOM에서의 물리적 위치뿐이다. React 공식 문서도 Portal을 이렇게 설명한다.

A portal only changes the physical placement of the DOM node.

Portal 안의 컴포넌트는 여전히 자신을 렌더링한 컴포넌트의 자식이다. Context를 이어받고 이벤트도 React 트리를 따라 전파된다. 수명도 같은 트리에 묶인다.

flowchart TB
    subgraph DOM["DOM 트리"]
      D1["page host"] --> D2["현재 화면 DOM"]
      D3["overlay container"] --> D4["로딩 DOM"]
    end
    subgraph REACT["React 트리"]
      R1["Page React Root"] --> R2["현재 화면"]
      R2 --> R3["Portal"]
      R3 --> R4["로딩"]
    end

전환할 때 SDK는 현재 화면의 root에 root.unmount()를 호출했다. 로딩 DOM이 다른 컨테이너에 있어도 React 트리에서는 같은 root에 속하므로 함께 제거됐다.

sequenceDiagram
    participant S as SDK
    participant P as Page React Root
    participant L as Loading Portal
    S->>L: 로딩 상태 설정
    S->>P: root.unmount()
    P->>L: 같은 React 트리의 Portal 제거
    Note over L: 전환 로딩도 함께 사라짐
    S->>S: 다음 화면 렌더 시작

DOM 쪽 수명도 따로 봐야 했다. 금융사마다 DOM 구조와 라우터는 달랐지만 대체로 body 아래에 자기 wrapper를 두었고, SDK의 page host는 그 안에 있었다. 3편에서 옮긴 Portal의 도착지도 page host의 shadow tree 안이었다.

flowchart TB
    B["document.body"] --> W["금융사 wrapper"]
    W --> PH["SDK page host"]
    PH --> SR["shadow root"]
    SR --> PD["Page DOM · Portal DOM"]

그중 자체 SPA router를 가진 금융사는 화면을 바꿀 때 wrapper 내부를 innerHTML로 비웠다. 이때 대상이 된 노드는 SDK의 page host보다 한 단계 위, 금융사 wrapper였다. wrapper의 자손이던 page host가 통째로 제거됐고, 거기 붙어 있던 shadow tree도 document에서 떨어져 나갔다.

javascript
bankWrapper.innerHTML = ''
// bankWrapper의 자손인 pageHost가 제거되고,
// pageHost에 붙어 있던 shadow tree도 document에서 분리된다.

pageHost.innerHTML = ''이었다면 shadow root는 그대로였다. 그 코드는 page host의 light DOM 자식만 바꾸기 때문이다. 같은 API라도 어느 노드에 호출했느냐가 결과를 갈랐다.

Portal도 원인은 아니었다. 로딩을 현재 화면 컴포넌트 안에 직접 렌더링했어도 page root를 unmount하면 결과는 같다. 원인은 로딩의 소유자가 현재 화면의 React 트리였다는 데 있었다. Portal은 DOM 위치가 바깥에 있으니 수명도 분리됐을 것처럼 보이게 했을 뿐이다.

시각적 요구는 같았지만 수명은 반대였다

전환 로딩을 Portal로 만들겠다고 따로 결정한 적은 없다. 기존 모달을 재사용했고, 그 안에 들어 있던 수명 가정도 같이 딸려 왔다.

text
모달
→ 자신을 연 화면이 살아 있는 동안 존재한다
→ 화면이 사라지면 함께 사라지는 것이 자연스럽다

전환 로딩
→ 자신을 연 화면이 사라지는 동안 존재한다
→ 이전 화면보다 오래 살아야 한다

두 UI 모두 화면을 덮지만 수명은 반대다. 컴포넌트를 재사용할 때 생김새가 같은지는 바로 보였지만, 소유권과 수명까지 같은지는 확인하지 않았다.

우리 앱에서 같은 모달이 문제없이 동작했던 이유도 여기 있었다. 앱 안에서는 화면 전환과 React root를 모두 우리가 관리했다. 금융사 페이지 위에 SDK를 올리자 React 바깥의 라우터가 host를 제거할 수 있게 됐고, 그제야 컴포넌트가 깔고 있던 전제가 드러났다.

2편에서 React를 택한 이유 중 하나가 디자인시스템 재사용이었다. 컴포넌트를 가져온다는 건 모양과 API만 가져오는 일이 아니었다. 그 컴포넌트가 가정하는 소유권과 수명도 함께 들어왔다.

요구사항을 수명으로 다시 썼다

처음 정의는 “화면을 덮는 오버레이”였다. 이 정의만 보면 모달과 다르지 않다.

문제를 겪고 나서 이렇게 고쳐 썼다.

전환 로딩은 page root가 unmount된 뒤부터 다음 화면이 준비될 때까지 살아 있어야 한다.

로딩이 버텨야 하는 구간에 page React root는 존재하지 않는다. 이 수명을 기준으로 선택지를 다시 나눴다.

text
A. page host의 shadow tree 안에 별도 React root를 둔다
   → page React root의 unmount와는 분리된다
   → 그러나 금융사 라우터가 page host 자체를 제거하면 함께 사라진다

B. document.body 직속에 shadow boundary 없이 둔다
   → page host 제거에서는 살아남는다
   → 금융사 CSS와 SDK CSS의 격리를 다시 해결해야 한다

C. document.body 아래에 별도 host, shadow root, React root를 둔다
   → page root와 수명이 분리되고 스타일도 캡슐화할 수 있다
   → 로딩 하나를 위해 React root 하나를 별도로 운영해야 한다

D. C와 같은 host와 shadow root를 사용하되 내부는 DOM과 CSS로만 만든다
   → page React root와 독립적이고 별도 React root도 필요 없다

A는 앞에서 본 SPA 금융사에서 무너진다. 그 라우터는 조상 wrapper를 비워 page host 자체를 제거했으므로, page host 안에 React root를 하나 더 둬도 같은 DOM 경계를 벗어나지 못한다.

B는 이 SDK의 격리 전제와 맞지 않았다. Shadow DOM이 모든 스타일 영향을 차단하지는 않지만 적어도 선택자와 내부 스타일의 캡슐화 경계는 준다. 로딩만 light DOM에 두면 금융사 CSS와 다시 충돌할 수 있었다.

C도 충분히 가능한 설계다. 별도 React root를 두면 상태 관리, 접근성 속성, 국제화, 테스트 도구를 기존 방식 그대로 쓸 수 있다. 다만 당시 로딩의 상태는 표시·숨김·오류 안내 정도였고 내부도 스피너와 문구가 전부였다. 이 범위에서는 React root를 하나 더 운영하는 이점보다 수명 경계를 단순하게 만드는 쪽이 나아 보였다.

그래서 D로 갔다.

javascript
const loadingHost = document.createElement('div')
loadingHost.id = 'loan-sdk-loading'
loadingHost.hidden = true

const shadow = loadingHost.attachShadow({ mode: 'closed' })
// LOADING_TEMPLATE은 빌드 시 고정된 상수이며 외부 문자열을 합치지 않는다.
shadow.innerHTML = LOADING_TEMPLATE

document.body.appendChild(loadingHost)

export const loading = {
  show() {
    loadingHost.hidden = false
  },
  hide() {
    loadingHost.hidden = true
  },
  fail(message) {
    // 안내 문구로 교체
    loadingHost.hidden = false
  },
}

이 구현에는 단서가 몇 개 붙는다. innerHTML은 문자열을 HTML로 해석하므로 LOADING_TEMPLATE이 빌드 시 고정된 상수일 때만 안전하다. 런타임 메시지를 반영한다면 textContent로 갱신해야 하고, 금융사 CSP가 Trusted Types를 강제한다면 템플릿 생성 방식부터 다시 맞춰야 한다.

hidden 속성 하나로도 부족하다. 페이지 쪽 CSS가 display를 지정하면 hidden이 덮인다. shadow host에 숨김 규칙을 같이 넣어야 이 제어 방식이 성립한다.

css
:host([hidden]) {
  display: none !important;
}

closed shadow root는 보안 경계가 아니다. 페이지 코드가 host.shadowRoot로 내부를 바로 들여다보지 못하게 할 뿐, host 제거는 막지 못한다. 여기서 쓴 목적은 스타일 캡슐화다. 상속 속성, CSS custom property, dir, lang처럼 경계를 넘는 값은 host에서 따로 정리해야 한다.

loading.show()를 명령형 API로 둔 것도 같은 맥락이다. 이 로딩은 특정 화면의 렌더 결과가 아니라, 전환을 지휘하는 SDK가 page root 바깥에서 관리하는 상태였다.

flowchart TB
    B["document.body"] --> PH["page host"]
    B --> LH["loading host"]
    PH --> PS["shadow root"]
    PS --> PR["Page React Root"]
    LH --> LS["shadow root"]
    LS --> LD["Loading DOM · CSS"]

전환 순서는 이렇게 바뀌었다.

sequenceDiagram
    participant S as SDK
    participant L as Loading Controller
    participant P as Page React Root
    participant H as 금융사 Router
    S->>L: show()
    S->>P: root.unmount()
    S->>H: navigate()
    H->>H: wrapper.innerHTML로 page host 제거
    H->>S: 새 target에서 renderPage()
    S->>P: 다음 화면 commit
    P-->>S: PAGE_READY
    S->>L: hide()

root.unmount()를 금융사 라우터 호출보다 먼저 둔 데도 이유가 있다. React 문서는 다른 코드가 root DOM이나 그 조상을 제거할 수 있다면 먼저 root.unmount()를 호출해 React가 구독과 전역 자원을 정리할 기회를 주라고 설명한다. 라우터가 DOM부터 걷어내면 화면에서는 사라져도 Effect cleanup과 구독 해제는 실행되지 않을 수 있다.

여기서 얻은 기준은 단순했다.

전환 중에 남아야 하는 UI는 전환이 파괴하는 수명 경계보다 바깥에서 소유해야 한다.

2. 시점: DOM을 바꾼 것과 사용자가 본 것은 다르다

로딩을 page root 밖으로 옮긴 뒤에도 사용자가 로딩을 보지 못하는 경우가 남았다.

이번엔 로딩이 unmount된 게 아니었다. loading.show()가 실행됐고 host의 hidden도 벗겨졌지만, 화면이 갱신되기 전에 다음 작업이 메인 스레드를 오래 붙잡았다.

표시 과정을 세 단계로 나누면 어디가 끊겼는지 보인다.

text
표시 요청
   ↓ React를 사용한다면 render/commit 대기가 생길 수 있다
DOM 상태 변경
   ↓ 브라우저의 렌더링 갱신과 실제 화면 출력이 남아 있다
사용자에게 표시

“표시 코드를 실행했다”는 사실은 첫 단계까지만 보증한다. React commit, 브라우저의 렌더링 기회, 실제로 픽셀이 그려지는 시점을 한 사건으로 묶어 보면 원인을 엉뚱한 곳에서 찾게 된다.

React 안에서는 commit까지 간격이 생긴다

React state setter는 호출한 줄에서 곧바로 DOM을 바꾸지 않는다. React 이벤트 핸들러 안에서는 핸들러 코드가 끝난 뒤 state update를 처리하도록 배칭한다.

javascript
const handleSubmit = () => {
  setLoading(true)   // 업데이트 요청
  bankCallback()     // 긴 동기 작업
  router.navigate()  // host 영역 교체
}

bankCallback()navigate()가 같은 핸들러 안에서 동기 실행되면, 로딩 state가 commit되기 전에 긴 작업과 DOM 교체가 먼저 끝나버릴 수 있다. 여기서 말하는 범위는 React 이벤트 핸들러 안에서 이어지는 동기 코드다. 모든 state update가 항상 핸들러 종료까지 기다린다는 뜻은 아니다.

우리 구현에서는 1절에서 로딩을 React 밖으로 뺐다. loadingHost.hidden = false는 DOM 속성을 그 자리에서 바꾸므로 commit을 기다리는 간격은 사라졌다. 그렇다고 화면 표시까지 끝난 건 아니었다.

DOM을 바꾼 다음이 진짜 문제였다

JavaScript task가 메인 스레드를 계속 붙잡고 있는 동안 브라우저는 같은 스레드에서 style과 layout 같은 렌더링 작업을 진행할 수 없다.

flowchart TD
    A["클릭 처리 시작"] --> B["loading.show() · DOM 상태 변경"]
    B --> C["금융사의 긴 동기 콜백"]
    C --> D["라우터의 DOM 교체"]
    D --> E["현재 task 종료"]
    E --> F["브라우저가 렌더링 task를 선택할 기회"]
    F --> G["style · layout · paint/composite 준비"]

이 그림은 개념을 단순화한 것이다. HTML 표준은 렌더링 기회가 생기면 렌더링 task source에 갱신 task를 넣는 모델로 설명하고, 브라우저는 렌더링 갱신을 건너뛰거나 여러 timer callback 사이의 갱신을 합칠 수도 있다. “task 하나가 끝날 때마다 반드시 paint한다”고 말할 수는 없다.

전제 점유가 여기서 작동한다. SDK가 DOM을 먼저 바꿔도 곧바로 금융사 동기 코드가 메인 스레드를 잡으면 사용자는 바뀐 프레임을 보지 못할 수 있다.

검토한 API와 각각의 한계

기술 이 문제에서 할 수 있는 일 할 수 없는 일
별도 loading host page root의 unmount와 로딩 수명을 분리 렌더링 기회와 실제 화면 표시 보장
queueMicrotask, resolved Promise 현재 call stack 뒤에서 연속 작업 실행 rendering task보다 먼저 microtask가 비워질 수 있어 화면 갱신을 위한 양보가 되지 않음
setTimeout 후속 작업을 별도 task로 분리 그 사이 렌더링 갱신이나 화면 표시 보장, 다른 task보다 앞선 재개
requestAnimationFrame 다음 렌더링 갱신의 rAF 단계에서 콜백 실행 콜백 자체는 다음 repaint를 준비하는 단계에 있으므로 paint 이후 실행 보장
rAF 안의 setTimeout 후속 동기 작업을 rAF 콜백과 같은 호출 스택 밖의 task로 보냄 해당 프레임이 실제로 사용자에게 보였다는 보장
scheduler.yield() 현재 작업을 나누고 continuation을 우선순위가 있는 task로 예약 모든 대상 WebView 지원과 실제 화면 표시 보장
flushSync React update를 동기 commit해 DOM 상태를 맞춤 브라우저 paint와 화면 표시 보장

표의 요점은 DOM 반영 순서와 실제 화면 표시를 구분하는 데 있다. 후속 동기 작업의 실행 순서는 API로 늦출 수 있다. 그러나 사용자 눈에 픽셀이 닿았다는 사실은 메인 스레드의 JavaScript가 알 수 없다.

scheduler.yield()가 후보였던 이유

await scheduler.yield()는 현재 작업을 끊고 나머지 코드를 새 task에서 이어간다. setTimeout과 달리 이어지는 작업이 우선순위를 갖는다는 점이 이 환경에 맞았다.

금융사 페이지에는 SDK가 통제하지 못하는 공통 스크립트와 보안 스크립트가 있었다. setTimeout으로 후속 전환을 미루면 이미 대기 중인 다른 task들이 먼저 실행될 수 있다. 로딩을 보여줄 틈을 만들려다 전환 시작이 예상보다 밀리는 셈이다.

물론 scheduler.yield()도 렌더링을 명령하는 API는 아니다. 작업을 나눠 브라우저가 입력과 렌더링을 처리할 기회를 만들면서, 남은 작업의 순위는 지켜주는 쪽에 가깝다.

javascript
button.addEventListener('click', async () => {
  loading.show()
  await scheduler.yield()
  doSlowContentSwap()
})

당시 대상 환경 전체에서는 쓸 수 없었다. 지금 기준으로도 scheduler.yield()는 Baseline이 아니므로, “Safari는 지원하지 않는다” 같은 문장을 고정된 사실로 남기기보다 지원 대상 앱의 Android WebView와 WKWebView 버전에서 실제 지원 여부를 확인하는 편이 맞다.

폴리필도 검토했지만 native API와 같은 우선순위 약속을 그대로 만들 수는 없다. scheduler-polyfillsetTimeout, MessageChannel, requestIdleCallback 등을 조합하고, native postTask() 지원 여부에 따라 이어지는 작업의 상대 순서가 달라진다고 스스로 밝히고 있다. 그래서 도입 여부는 “같은 API 모양이 필요한가”가 아니라 “fallback 환경에서 우선순위가 약해져도 안전한가”로 판단해야 했다.

rAF도 프레임을 보장하지 않는다

다음 코드로는 부족하다.

javascript
loading.show()
requestAnimationFrame(() => host.onSubmit())

rAF 콜백은 다음 repaint를 준비하는 렌더링 갱신 과정에서 실행된다. 콜백 안에서 긴 동기 작업을 시작하면 그 갱신이 끝나기 전에 메인 스레드를 다시 점유한다.

후속 작업을 rAF 콜백의 호출 스택 밖으로 보내려면 timer를 한 번 더 두면 된다.

javascript
const handleSubmit = () => {
  setLoading(true)
  requestAnimationFrame(() => {
    setTimeout(() => host.onSubmit(), 0)
  })
}

이렇게 하면 timer callback이 rAF 콜백과 같은 렌더링 task 안에서 실행되지 않는다. rAF 뒤에 이어지는 style/layout과 화면 갱신 단계를 막지 않는다는 의미는 있다.

그래도 “그 사이 한 프레임이 사용자에게 반드시 보였다”고 쓰면 범위를 넘는다. 렌더링 갱신이 생략될 수도 있고, 합성과 디스플레이 출력, WebView의 상태는 JavaScript가 확인할 수 없다. 이중 rAF도 마찬가지다. 후속 작업을 한 프레임 더 미룰 뿐, 화면에 그려졌다는 확인은 되지 못한다.

문서가 hidden 상태가 되면 rAF가 멈춘다는 문제도 있다. WebView에서 인증 앱으로 갔다가 돌아오는 흐름이 있다면 rAF 경로만으로 업무 시작을 묶을 수 없다. fallback timer와 경쟁시키고 콜백이 두 번 실행되지 않도록 가드를 둬야 하며, fallback 시간도 정해진 정답이 없어서 대상 WebView의 화면 전환 동작과 업무 지연 허용치를 보고 정해야 한다.

requestIdleCallback은 이 자리에 맞지 않는다. 사용자가 누른 CTA의 본체는 idle work가 아니고, SDK가 호출하는 금융사 동기 코드를 deadline에 맞춰 잘게 나눌 수도 없다.

로딩을 React 안에 남기는 선택도 가능하다. 그 경우엔 commit과 렌더링 기회를 따로 다뤄야 한다. flushSync는 다음 줄이 실행될 때 DOM이 갱신돼 있음을 보장하지만 paint까지 보장하지는 않고, pending Effect를 동기 실행하거나 Suspense fallback을 다시 노출하는 부작용이 있다.

startTransition은 즉시 보여야 하는 로딩 state에 맞는 도구가 아니다. 전달한 함수는 즉시 실행되고 그 안의 update를 non-blocking Transition으로 표시한다. 어느 쪽이든 긴 작업을 핸들러 밖으로 분리할 수 있다면 그 구조를 먼저 검토하는 편이 낫다.

첫 프레임과 그 뒤 애니메이션은 다른 문제였다

첫 프레임이 준비된 뒤에도 금융사 콜백이 메인 스레드를 오래 잡으면 JavaScript나 layout/paint에 의존하는 스피너는 멈춘다.

transformopacity는 layout이나 paint를 반복하지 않고 합성 단계에서 처리되기 좋은 속성이다. 그래서 회전 스피너는 색과 모양을 정적으로 그려두고 transform만 애니메이션하도록 만들었다.

css
.sdk-loading__spinner {
  width: 32px;
  height: 32px;
  border: 3px solid rgb(0 0 0 / 12%);
  border-top-color: rgb(0 0 0 / 72%);
  border-radius: 50%;
  animation: sdk-loading-spin 800ms linear infinite;
  will-change: transform;
}

@keyframes sdk-loading-spin {
  to {
    transform: rotate(360deg);
  }
}

@media (prefers-reduced-motion: reduce) {
  .sdk-loading__spinner {
    animation-duration: 2400ms;
  }
}
flowchart LR
    T["transform · opacity"] --> C["합성 단계에서 처리될 가능성이 높음"]
    C --> V["메인 스레드 점유 중에도 이어질 수 있음"]
    L["width · top · margin"] --> LA["layout 필요"]
    P["border-color · box-shadow"] --> PA["paint 필요"]
    LA --> M["메인 스레드 작업"]
    PA --> M
    M --> S["긴 task 동안 갱신 지연"]

여기에도 전제가 있다. transform 애니메이션이 실제로 합성 경로에 올라갔고 첫 프레임이 준비된 뒤라야 메인 스레드와 독립적으로 이어질 수 있다. “CSS transform이면 언제나 멈추지 않는다”는 보장은 아니다.

will-change: transform도 넣어뒀는데, 이건 합성 레이어 승격을 명령하는 게 아니라 힌트이고 레이어는 메모리를 쓴다. 실제로 필요한지는 대상 WebView의 Performance trace로 확인하는 편이 정확하다.

prefers-reduced-motion: reduce에서는 회전 속도를 늦췄다. 지금 다시 보면 속도를 늦추는 것만으로 움직임 감소 요구를 충족한다고 보기 어렵고, 애니메이션을 끄고 정적인 문구를 남기는 쪽도 검토할 만하다. 접근성 측면에서는 스피너와 별개로 role="status" 영역에 상태 문구를 제공하는 보완도 남아 있다.

표시 정책은 즉시 표시로 정했다

기술만으로 실제 화면 표시를 확정할 수 없다면, 로딩을 언제 DOM에 넣을지가 정책 문제가 된다.

정책 장점 위험
즉시 표시 느린 전환과 빈 화면에 강함 빠른 전환에서 짧게 깜빡일 수 있음
지연 표시 빠른 전환의 깜빡임 감소 표시 전에 이전 화면이 제거되면 빈 구간이 생길 수 있음
최소 표시 시간 보였다 바로 사라지는 현상 감소 실제 완료 뒤에도 전환을 늦춤
즉시 삽입·지연 등장 DOM을 먼저 준비하면서 시각적 깜빡임을 줄일 수 있음 첫 렌더링 자체가 밀리면 등장도 같이 밀림

즉시 표시를 택했다. 금융사 공통 스크립트와 보안 스크립트, 구형 디바이스까지 SDK가 페이지 전체의 성능을 통제할 수 없었기 때문이다. 빠른 전환에서 로딩이 잠깐 스치는 것보다, 느린 전환에서 이전 화면이 사라진 뒤 빈 화면이 남는 쪽을 더 큰 문제로 봤다.

지금이라면 opacity: 0 상태로 먼저 삽입한 뒤 등장을 지연하는 방식도 비교해 보겠지만, 이것도 첫 렌더링 기회에 기대는 건 마찬가지다. 실제 전환 시간 분포와 깜빡임 빈도를 재고 나서 정할 문제다.

로딩을 켜는 범위도 좁혔다.

text
로딩을 켜지 않는 동작
→ 약관 상세 열기
→ BottomSheet 열기
→ 입력값 검증

로딩을 켜는 동작
→ 현재 업무 단계를 완료하고 다음 화면을 결정하는 동작

CTA 클릭이라는 외형만 보고 로딩을 켜면 추가 동의를 받는 BottomSheet까지 오버레이가 덮어버린다. 5편에서 상태가 바뀌는 동작과 표현만 바뀌는 동작을 나눴던 기준을 로딩에도 적용했다.

중복 실행 방어는 렌더링에 맡기지 않았다

오버레이는 보이기만 하면 클릭을 가로막는다. 그런데 이 글의 출발점이 오버레이가 제때 보이지 않을 수 있다는 것이었다. 중복 실행 방어를 시각적 레이어에 맡길 수 없었다.

버튼의 disabled state도 DOM에 반영되기 전에는 두 번째 입력을 막지 못한다. 그래서 Page 컴포넌트의 제출 핸들러에는 렌더와 무관한 ref 가드를 뒀다.

javascript
const inFlightRef = useRef(false)

const handleSubmit = async () => {
  if (inFlightRef.current) return
  inFlightRef.current = true
  loading.show()
  try {
    const result = await host.onSubmit()
    // 결과 검증과 전환
  } finally {
    inFlightRef.current = false
  }
}

ref 값은 해당 컴포넌트 인스턴스에서 즉시 바뀐다. 당시 필요했던 범위는 CTA를 빠르게 연속으로 눌러도 host.onSubmit() callback이 두 번 실행되지 않게 하는 것이었고, 이 가드로 충분했다.

이걸 전환 전체의 동시성 제어로 넓혀 설명하지는 않는다. 화면을 벗어난 뒤 늦게 도착한 결과를 처리하거나 여러 진입점에서 같은 업무를 시작할 수 있게 되면 transitionId 같은 요청 식별자가 필요해진다. 당시 범위에서는 거기까지 가지 않았다.

사용자에게 진행 상태를 알리는 일과 같은 업무를 두 번 실행하지 않는 일은 서로 다른 책임이다.

3. 경계: document가 바뀌면 이전 로딩도 끝난다

별도 loading host는 같은 document 안에서만 유지된다. MPA 금융사는 JSP 단위로 document가 교체되므로 이전 document의 DOM과 JavaScript 상태를 다음으로 가져갈 수 없다.

새 document의 HTML이 도착하고 SDK script가 실행되기 전까지는 SDK 코드 자체가 없다. 이 구간의 초기 로딩은 새 HTML이 소유해야 했다.

html
<div id="loan-sdk-initial-loading">
  <div class="sdk-loading__spinner"></div>
  <p>화면을 불러오고 있습니다.</p>
</div>

<div id="loan-sdk-root"></div>

<script src=".../bank-sdk.js"></script>
<script>
  sdk.renderPage({
    targetId: 'loan-sdk-root',
    pageKey: 'identity',
  })
</script>

초기 로딩은 HTML이 파싱될 때부터 존재한다. SDK 화면이 준비되면 그때 제거한다.

text
새 document HTML 파싱
→ 초기 로딩 표시
→ 금융사·SDK script 실행
→ renderPage()
→ PAGE_READY
→ 초기 로딩 제거

같은 디자인을 쓴다는 것과 같은 CSS를 그대로 쓴다는 건 다르다. 전환 로딩은 Shadow DOM 안에 있지만 JSP의 초기 로딩은 light DOM에 있으므로 금융사 CSS의 영향을 받을 수 있다. 두 로딩은 시각 토큰과 마크업 규약을 공유하되, 초기 로딩에는 prefix가 강한 선택자와 reset을 적용하거나 지원 환경에서 Declarative Shadow DOM을 검토해야 한다.

HTML 실패와 SDK 번들 실패는 다르다

초기 로딩이 덮을 수 있는 구간도 나눠 봐야 한다.

text
HTML은 도착했지만 SDK 번들이 실행되지 않음
→ 초기 로딩은 이미 DOM에 있음
→ 제거 코드도 실행되지 않아 초기 로딩이 계속 남음

HTML document 자체가 도착하지 않음
→ 새 document의 초기 로딩도 존재하지 않음
→ WebView navigation을 소유한 Native 계층만 이 실패를 관찰할 수 있음

당시에는 첫 번째 경우를 처리하지 않았다. HTML은 왔는데 SDK 번들이 404이거나 초기화에 실패하면 초기 로딩이 계속 도는 구조였다.

지금 보면 이 구간은 웹 안에서 보완할 수 있다. 외부 SDK script보다 먼저 watchdog timer를 걸어두고 PAGE_READY에서 해제하되, timer가 먼저 터지면 안내 문구로 바꾸는 방식이다. 다만 외부 script가 parser-blocking이라면 watchdog을 그 뒤에 등록해서는 안 된다. 네트워크 응답이 지연되는 동안 뒤의 inline script도 실행되지 않기 때문이다. inline script를 허용하는지는 금융사 CSP와 함께 확인해야 한다.

반대로 HTML 응답 자체가 오지 않으면 새 document 안의 어떤 코드도 실행되지 않는다. 7편에서 겪은 인프라 장애가 이 경우였다. 이 구간은 WebView navigation을 시작한 Native 계층의 timeout과 오류 화면으로 막아야 한다. 설계는 했지만 당시 적용하지 못했고 10편에서 다룬다.

4. 종료: 로딩을 숨기는 조건도 전환 상태의 일부다

로딩을 빨리 보이게 하는 것과 제대로 끝내는 것은 다른 문제였다.

7편에서는 timeout을 업무 실패가 아니라 관찰 기준으로 뒀다. 금융사 콜백의 결과를 모르는 상태에서 SDK가 실패를 확정하면, 실제로는 완료된 요청을 사용자가 다시 실행할 수 있기 때문이다.

하지만 로딩은 화면을 덮는 UI다. timeout 뒤에도 계속 돌리기만 하면 사용자는 지금 무슨 상태인지 알 수 없다. “업무 결과를 실패로 확정하지 않는다”와 “로딩을 무한히 유지한다”는 같은 정책이 아니다.

전환 단계에 따라 돌아갈 화면이 남아 있는지가 달랐다.

flowchart TD
    A["loading.show()"] --> B["금융사 callback 대기"]
    B --> C["결과 검증"]
    C --> D["page root.unmount()"]
    D --> E["navigate() · host 영역 교체"]
    E --> F["renderPage()"]
    F --> G["PAGE_READY"]
    B -. "ACTION_OUTCOME_UNKNOWN" .-> X["이전 화면이 아직 존재"]
    F -. "PAGE_READY_TIMEOUT" .-> Y["이전 화면은 이미 정리됨"]

콜백 timeout은 root.unmount() 전에 발생한다. 이전 화면이 남아 있으니 로딩을 숨길 자리는 있다. 다만 업무 결과는 여전히 모른다.

전환 timeout은 다르다. 이전 화면은 이미 정리됐고 다음 화면은 준비되지 않았다. 여기서 로딩을 그냥 숨기면 빈 화면이 된다. page root보다 오래 사는 loading host의 내용을 오류 안내로 바꿔야 한다.

text
종료 또는 상태 전환 조건
├─ PAGE_READY 수신                  → 다음 화면을 유지하고 로딩 숨김
├─ 명시적 실패 결과 수신            → 오류 안내
├─ 계약 위반 (INVALID_HOST_RESULT)  → 오류 안내와 진단 기록
├─ callback timeout                 → 업무 결과 미확정 정책 적용
└─ PAGE_READY timeout               → loading host에서 전환 오류 안내

PAGE_READY는 Page 컴포넌트가 mount된 뒤 useEffect에서 dispatch했다. 그러니 이 신호가 뜻하는 건 “다음 Page가 React 트리에 mount되고 Effect가 실행됐다”까지다. React는 상호작용으로 발생한 Effect를 paint 전에 실행할 수도 있다고 문서화하므로, 이걸 사용자가 다음 화면을 봤다는 신호로 읽으면 안 된다. 이미지와 폰트, 별도 비동기 데이터의 완료도 포함하지 않는다.

정리

하나의 증상처럼 보였지만 원인은 네 축으로 나뉘었다.

증상 원인 대응
소유권 로딩이 이전 화면과 함께 사라짐 로딩이 page React root와 같은 수명에 속함 별도 host와 shadow root에서 SDK가 직접 소유
시점 표시 코드는 실행됐지만 로딩이 보이지 않음 React commit 또는 브라우저 렌더링 갱신 전에 긴 동기 작업이 이어짐 DOM 상태를 먼저 바꾸고, 작업 분리의 보장 범위를 제한해 적용
경계 새 document의 초기 구간이 비어 있음 이전 document의 DOM과 JavaScript를 이어갈 수 없음 새 HTML이 초기 로딩을 소유
종료 로딩이 끝나지 않음 성공만 종료 조건으로 두면 timeout 뒤 표시 상태가 남음 전환 단계에 따라 로딩 숨김과 오류 안내를 구분

처음에는 로딩을 화면을 덮는 오버레이로 정의했다. 그래서 모달과 비슷하다고 봤다.

실제로 필요했던 건 이전 page root보다 오래 살고, 같은 document 안의 교체를 견디며, 새 document에서는 HTML에 소유권을 넘기고, 전환 결과가 불명확해도 종료 정책을 유지하는 UI였다. 모양보다 수명이 먼저인 컴포넌트였다.

React 밖의 DOM으로 옮긴 뒤 commit 대기는 없앨 수 있었다. 그래도 DOM 변경과 화면 표시는 끝까지 같은 사건이 아니었다. task를 나누고 rAF를 써서 실행 순서를 조정할 수는 있지만, 사용자가 실제 프레임을 봤는지는 메인 스레드 JavaScript가 알 수 없는 영역에 남았다. transform 애니메이션도 합성 경로를 쓰기 좋은 선택이지 모든 WebView에서의 보장은 아니었다.

그래서 이 로딩의 설계 기준은 “보이게 만드는 API 하나”가 아니었다.

  • page 전환보다 긴 수명을 어디에서 소유할지
  • DOM 반영, 렌더링 갱신, 실제 표시를 어디까지 구분할지
  • document가 바뀌는 지점에서 누가 초기 상태를 그릴지
  • callback timeout과 전환 timeout에서 로딩을 어떻게 끝낼지

중복 실행 방어를 오버레이와 분리한 것도 같은 기준에서 나왔다. 사용자에게 상태를 보여주는 일과 같은 업무를 두 번 실행하지 않는 일을 한 장치에 맡길 이유가 없었다.

다음 편에서는 이 글이 전제로 둔 이동 자체를 다룬다. SDK가 다음 화면을 결정해도 실제 이동 방식은 세 종류였고, 각각 cleanup 순서와 history 처리가 달랐다. 뒤로가기를 history.back() 한 줄로 처리할 수 없었던 이유도 여기 있다.

참고한 문서