상황

하이브리드 앱의 웹 페이지에서 네이티브 기능을 호출할 때, 오래된 방식이지만 여전히 널리 쓰이는 패턴이 있다. 숨겨진 iframe의 src에 커스텀 스킴 URL을 할당하면 WebView가 이를 가로채 네이티브 핸들러로 전달하는 방식이다.

// 앱스킴 발화: 숨겨진 iframe의 src 할당
document.getElementById('cmd_frm').src = 'appscheme://open_main_webpage?url=...';

문제가 된 화면은 confirm 다이얼로그에서 사용자가 확인을 누르면 다른 웹페이지를 새 컨텍스트로 여는 스킴을 발화하고, 원래 페이지는 복귀 시점에 갱신된 상태를 보여주기 위해 location.reload()를 호출하는 흐름이었다. 그런데 코드가 이렇게 되어 있었다.

// 문제의 코드: 스킴은 지연, reload는 즉시
setTimeout(() => {
  frame.src = scheme;   // 500ms 뒤 발화 예약
}, 500);
location.reload();      // 즉시 실행

증상은 "페이지를 새로고침한 뒤에는 스킴이 안 열린다"였다. 첫 진입에서는 잘 동작하고, 새로고침 후에는 간헐적으로 실패했다.

문제

iframe src 할당 기반 앱스킴은 발화 시점에 문서가 살아있어야 한다. 그리고 location.reload()는 호출 즉시 문서를 죽이지 않는다. 브라우저는 네트워크 요청을 보내고, 서버 응답이 도착해 새 문서가 커밋(didCommit)되는 순간 기존 문서를 폐기한다. 이때 기존 문서에 걸려 있던 pending setTimeout도 문서와 함께 전부 폐기된다.

즉 위 코드는 두 개의 타이머가 경쟁하는 구조다.

  • 500ms 뒤 발화 예약된 스킴 타이머
  • reload 요청 → 서버 응답 → 새 문서 커밋까지 걸리는 시간

커밋이 500ms보다 늦으면 스킴이 먼저 발화해 성공하고, 500ms보다 빠르면 타이머가 문서와 함께 폐기되어 실패한다. 딜레이는 안전 마진이 아니라, 서버 응답 속도에 좌우되는 동전 던지기였다.

sequenceDiagram
    participant U as 사용자 (confirm 확인)
    participant D as 현재 문서
    participant S as 서버
    participant N as 새 문서

    U->>D: dismiss
    D->>D: setTimeout(스킴, 500ms) 예약
    D->>S: location.reload() 요청
    alt 서버 느림 — 커밋 645ms (성공 케이스)
        D->>D: 500ms 도달 → 스킴 발화 (145ms 여유)
        S-->>N: 응답 도착 → didCommit
        N->>D: 기존 문서 폐기
    else 서버 빠름 — 커밋 491ms (실패 케이스)
        S-->>N: 응답 도착 → didCommit (9ms 차이)
        N->>D: 기존 문서 + pending 타이머 폐기
        Note over D: 스킴 타이머 소멸 — 발화 안 됨
    end

접근과 판단

"새로고침 후에만 깨진다"는 상관관계였다

초기 가설은 증상 그대로 "새로고침이라는 상태가 문제"라는 쪽이었다. 그러나 WebView 로그로 confirm dismiss부터 didCommit까지를 실측해 보니 결정 요인은 상태가 아니라 타이밍이었다.

시도 dismiss→didCommit 서버 응답 시간 결과
성공 케이스 645ms 450ms 500ms 타이머가 145ms 여유로 선발화
실패 케이스 491ms 352ms 타이머보다 9ms 빨리 커밋 → 타이머 폐기

두 케이스의 차이 154ms 중 대부분(98ms)이 서버 응답 시간 변동이었다. 즉 이 코드는 서버가 빨라질수록 더 자주 깨진다. "새로고침 후에만 깨진다"는 관찰은 상관관계일 뿐이고, 실제 결정 요인은 "reload가 딜레이(500ms) 안에 커밋되는가"였다. 증상이 나타나는 조건과 증상을 결정하는 요인은 다른 것이다.

git 이력을 추적하니 원래 코드는 스킴을 동기 발화하고 있었고, 회귀 커밋(f539bcf535)이 이 순서를 뒤집어(스킴 지연 + reload 즉시) 레이스를 만들어냈다.

4가지 변형 측정

가설을 확정하기 위해 4가지 변형을 실기기에서 반복 측정했다.

변형 구성 결과
ORIG 동기 스킴 + 즉시 reload 2/2 성공
CUR (현재 코드) 스킴 500ms 지연 + 즉시 reload 첫 진입만 성공
CURNR 스킴 500ms 지연, reload 제거 4/4 성공
FIX 동기 스킴 + reload 300ms 지연 3/3 성공

CURNR(reload 제거)도 문제를 고친다. 하지만 이 스킴은 새 웹페이지를 열면서 원래 페이지를 뒤에 남기는 동작이라, 사용자가 복귀했을 때 갱신된 상태를 보여주려면 reload에는 목적이 있다. 편한 삭제 대신 UX 제약을 유지하는 FIX를 선택했다.

구현

수정은 순서를 뒤집는 것이 전부다. 네비게이션과 경쟁하는 사이드이펙트(스킴)는 동기 발화하고, 네비게이션(reload) 쪽을 지연한다.

// FIX: 스킴은 동기 발화, reload를 지연
frame.src = scheme;       // 문서가 확실히 살아있는 지금 발화
setTimeout(() => {
  location.reload();      // 스킴이 네이티브로 전달된 뒤 갱신
}, 300);

reload 쪽 타이머는 폐기되어도 무해하다 — 최악의 경우 갱신이 한 번 안 될 뿐, 핵심 기능(스킴 발화)은 이미 완료된 상태다. 폐기되면 기능이 죽는 쪽을 타이머에 올리는 것과, 폐기되어도 무해한 쪽을 타이머에 올리는 것의 차이다.

결과

  • FIX 적용 후 3/3 재현 성공, 첫 진입/새로고침 후 구분 없이 동작.
  • 레이스의 판정 격차가 실측 9ms까지 좁혀져 있었음을 수치로 확인 — "간헐적"이 아니라 서버 응답 시간 분포가 500ms 경계에 걸쳐 있었던 것.
  • 회귀 커밋을 특정해, 같은 파일을 만지는 이후 작업에서 순서가 다시 뒤집히지 않도록 근거를 남김.

배운 것

  1. 문서 커밋 시 pending 타이머는 문서와 함께 폐기된다. 네비게이션(reload, href 변경)과 경쟁하는 사이드이펙트가 있다면, 사이드이펙트를 동기 발화하고 네비게이션 쪽을 지연하라. 반대로 하면 딜레이 값이 아무리 커도 서버 속도에 따라 깨진다.
  2. 증상 조건 ≠ 결정 요인. "새로고침 후에만 깨진다"는 관찰은 디버깅의 출발점이지 결론이 아니다. 실측 없이 증상 조건을 원인으로 채택했다면 "새로고침 여부 플래그 분기" 같은 엉뚱한 수정이 나왔을 것이다.
  3. 서버가 빨라질수록 깨지는 코드가 존재한다. 인프라 개선이 프론트 버그를 유발하는 반직관적 결합은, 타이머와 네트워크 완료 시점이 경쟁하는 구조에서 나온다. 딜레이 매직넘버가 보이면 "이 시간 안에 무엇이 완료되면 무슨 일이 생기는가"를 물어야 한다.
  4. 대안이 동작해도 목적을 확인하고 기각하라. reload 제거(4/4 성공)는 더 짧은 수정이었지만, 복귀 시 갱신이라는 목적을 죽인다. 측정 매트릭스에 대안을 포함시키면 "왜 이 안인가"에 대한 답이 기록으로 남는다.