상황

대형 유통사의 하이브리드 앱에서 웹뷰와 네이티브 간 통신은 URL scheme 기반 브릿지로 이루어진다. 기존에는 CDN에서 전역 함수(emart.app.cmd, emart.util)를 로드하고, 숨겨진 iframe 싱글톤의 src 속성에 scheme URL을 할당하는 방식으로 네이티브 명령을 전달했다. 이 구조는 수년간 유지되었고, 14개 이상의 호출부가 이 통신 레이어에 의존하고 있었다.

문제

iframe 싱글톤에 src를 연속으로 할당하면, 브라우저 엔진이 이전 navigation을 abort하고 마지막 할당만 처리한다. 짧은 시간 안에 여러 scheme 호출이 발생하면 앞선 명령이 유실되는 것이다.

이전에는 이 문제를 pushScheme이라는 워크어라운드로 회피했다. 500ms 딜레이 큐에 scheme을 쌓고 순차 발사하는 방식이었는데, 이는 근본 원인 해결이 아닌 증상 우회였다. 딜레이를 넣어도 타이밍에 따라 race condition이 잔존했고, 모든 네이티브 호출에 500ms 지연이 강제되어 UX가 저하됐다.

---
title: "iframe 싱글톤 vs 호출별 생성"
---
[AS-IS]
- iframe 하나를 재사용, src 재할당으로 scheme 전달
- 연속 호출 시 navigation abort로 명령 유실
- pushScheme 500ms 딜레이 큐로 우회 시도
- race condition 잔존, 모든 호출에 불필요한 지연

[TO-BE]
- 호출마다 새 iframe 생성, 독립 navigation context 확보
- 100ms 후 DOM에서 제거하여 메모리 누수 방지
- 딜레이 큐 불필요, 동시 호출도 안전
- pushScheme 서브시스템 완전 제거

접근과 판단

핵심 판단은 세 가지였다.

1. 싱글톤 재사용이 아니라 navigation context 격리가 답이다.

브라우저의 iframe navigation은 한 프레임에 하나의 pending navigation만 허용한다. 이건 브라우저 엔진의 설계 원칙이지 버그가 아니므로, 딜레이로 회피하는 건 본질적 해결이 될 수 없다. 호출마다 새 iframe을 생성하면 각 scheme이 독립된 navigation context를 갖게 되어 동시 호출이 서로 간섭하지 않는다.

2. CDN 전역 함수를 내부 모듈로 전환한다.

emart.app.cmd는 어떤 명령이 iframe URL scheme으로 가야 하고, 어떤 것이 Android JavascriptInterface나 iOS WKScriptMessageHandler로 직접 가야 하는지를 판별하는 라우팅 로직을 담고 있었다. 이를 executeAppCmd 내부 함수로 이동하고, CMD_SCHEMES Set으로 iframe 대상 명령을 관리하도록 했다. emart.util.loginCheck/appCheck 같은 유틸리티는 composables/auth/authGuard.ts로 이동하여 브릿지 모듈을 직접 사용하게 했다.

3. API 표면을 최소화한다.

외부에는 nativeBridge.send, request, subscribe, listen 4개 메서드만 노출하고, executeScheme과 executeAppCmd는 barrel export에서 제외하여 internal로 유지했다. 호출부는 통신 메커니즘을 알 필요 없이 의도만 전달하면 된다.

구현

---
direction: horizontal
---
steps:
  - label: "호출부 (14개)"
    color: blue
  - label: "nativeBridge.send()"
    color: green
  - label: "executeAppCmd"
    color: yellow
  - label: "CMD_SCHEMES 판별"
    color: yellow
  - label: "iframe 생성 or 직접 인터페이스"
    color: orange
  - label: "네이티브 실행"
    color: red

주요 변경:

  • iframe 생성/제거 패턴: executeScheme은 호출 시 hidden iframe을 생성하고 src에 scheme URL을 할당한 뒤, 100ms 후 DOM에서 제거한다. 네이티브가 scheme을 수신하기에 충분한 시간이면서 DOM 노드가 누적되지 않는 최소 값이다.
  • 플랫폼별 분기: CMD_SCHEMES Set에 포함된 명령은 iframe URL scheme으로, 나머지는 Android에서는 JavascriptInterface, iOS에서는 WKScriptMessageHandler를 통해 직접 전달한다.
  • pushScheme 완전 제거: 500ms 딜레이 큐와 schemeList state를 삭제하고, 14개 호출부를 nativeBridge.send 직접 호출로 전환했다.
  • 인증 로직 분리: 전역 유틸에 묶여 있던 loginCheck, appCheck 등을 composables/auth/authGuard.ts로 이동하여 관심사를 분리했다.
---
tabs:
  - label: "Before (pushScheme)"
    content: |
      // 500ms 간격으로 scheme을 큐에 넣고 순차 발사
      pushScheme(scheme) {
        this.schemeList.push(scheme)
        if (!this.timer) {
          this.timer = setInterval(() => {
            const next = this.schemeList.shift()
            if (next) this.iframe.src = next
            else clearInterval(this.timer)
          }, 500)
        }
      }
  - label: "After (per-call iframe)"
    content: |
      // 호출마다 독립 iframe, 간섭 없음
      function executeScheme(scheme: string) {
        const iframe = document.createElement('iframe')
        iframe.style.display = 'none'
        iframe.src = scheme
        document.body.appendChild(iframe)
        setTimeout(() => iframe.remove(), 100)
      }

결과

  • pushScheme 500ms 딜레이 큐 완전 제거: 모든 네이티브 호출에서 불필요한 500ms 대기가 사라짐
  • 14개 호출부 전환: 간접 레이어 없이 nativeBridge.send를 직접 사용하는 구조로 단순화
  • 외부 API 4개 메서드로 축소: send, request, subscribe, listen만 노출하여 진입장벽 최소화
  • CDN 의존 제거: 전역 함수를 내부 모듈로 전환하여 번들에 포함, 버전 관리와 타입 안전성 확보
  • 연속 호출 안정성 확보: iframe 간 navigation 간섭이 구조적으로 불가능한 설계

배운 것

워크어라운드 레이어는 기술 부채의 신호다. pushScheme의 500ms 딜레이 큐는 "왜 scheme이 씹히는가"에 대한 답을 회피하고 증상만 완화한 것이었다. 브라우저 엔진의 navigation 모델을 이해하면 근본 해결은 단순했다 - 싱글톤을 버리고 호출별로 격리하면 된다.

URL scheme 기반 브릿지의 일반 패턴: Cordova, Capacitor 등 URL scheme으로 네이티브와 통신하는 모든 하이브리드 앱에서 동일한 문제가 발생할 수 있다. iframe 싱글톤 재사용은 직관적이지만, 브라우저의 "하나의 프레임에 하나의 pending navigation" 원칙과 충돌한다. 호출별 생성/제거 패턴은 이 제약을 구조적으로 우회하는 범용 해법이다.

API 표면 축소는 설계 의도를 코드로 표현하는 것이다. internal 함수를 barrel export에서 제외하는 것만으로, "이 모듈의 계약은 이 4개 메서드"라는 선언이 된다. 문서 없이도 사용자는 올바른 진입점을 찾게 된다.