상황

하이브리드 앱의 이벤트 페이지(룰렛)에는 당첨 시 웹에서 앱스킴을 호출해 네이티브 화면으로 전환하는 흐름이 있다. 필드 리포트는 한 줄이었다. "페이지를 새로고침한 뒤에만 앱스킴이 동작하지 않는다."

문제의 코드는 서버 렌더링 JSP 안에 있었고, 당첨 확정 시점에 이렇게 동작했다.

// 당첨 confirm 이후
setTimeout(function () {
  location.href = appScheme; // 앱스킴 호출을 500ms 지연
}, 500);
location.reload(); // reload는 즉시

앱스킴 호출은 500ms 뒤로 미뤄져 있고, reload는 즉시 실행된다. 증상은 확률적이었다. 같은 기기에서 같은 조작을 해도 어떤 때는 네이티브 화면이 뜨고 어떤 때는 아무 일도 일어나지 않았다.

문제

확률적으로만 재현되는 웹뷰 버그는 두 가지가 어렵다.

첫째, 판정 근거가 웹 레이어에 없다. 앱스킴은 location.href 대입 즉시 웹 코드의 손을 떠난다. 스킴이 네이티브에 실제로 도달했는지, 도달했다면 언제 도달했는지는 웹 콘솔 로그로 알 수 없다.

둘째, 재현 환경이 실제 환경과 같다는 보장이 없다. 데스크톱 브라우저나 CDP(Chrome DevTools Protocol) 기반 자동화로 재현하면 편하지만, 웹뷰의 navigation 커밋 타이밍과 스킴 인터셉트 동작은 실제 앱 웹뷰와 다를 수 있다.

이 두 가지가 이번 조사에서 정확히 함정으로 작동했다.

접근과 판단

가설 소거

  1. templateVO 빈 값
  2. reload abort
  3. 하네스 충실도 의심
  4. 실사용 플로우 재현

가설을 하나씩 세우고 기각했다.

가설 1 — 서버 템플릿 값이 비어서 스킴 자체가 만들어지지 않는다 (기각)

새로고침 시 서버 템플릿 객체가 빈 값으로 내려와 스킴 URL이 깨진다는 가설. 렌더링된 HTML을 확인하니 해당 값은 'E'로 명시적으로 전달되고 있었다. 스킴 문자열은 정상 생성됨 — 기각.

가설 2 — reload가 스킴 navigation을 abort시킨다 (기각... 처럼 보였다)

즉시 실행되는 reload가 500ms 뒤의 스킴 navigation을 취소한다는 가설. CDP 하네스에서 reload를 제거하고 테스트했는데도 여전히 실패했다. "reload가 원인이 아니다"로 기각 — 그러나 이 기각이 잘못된 것이었다.

전환점 — 하네스 자체의 충실도 문제 발견

reload를 제거했는데도 실패한다는 결과는 실제 기기 수동 테스트와 모순됐다. 재현 하네스가 실제와 다른 결과를 내고 있다면, 다음으로 의심할 것은 가설이 아니라 하네스다. CDP 주입 방식은 실제 사용자 입력 경로와 스킴 인터셉트 타이밍을 충실히 재현하지 못하고 있었다.

최종 — 실사용 플로우 자동화 + 네이티브 로그 판정

스크립트 주입 대신 실제 사용자 입력 경로(터치 이벤트)로 플로우를 자동화하고, 판정은 네이티브 웹뷰 로그의 ms 타임스탬프(didCommit)로 했다. 여기서 처음으로 실사용과 동일한 확률적 실패가 재현됐다.

여기서 얻은 판단 기준이 이 글의 핵심이다. 재현 하네스가 실제와 다른 결과를 내면, 가설을 더 세우기 전에 하네스의 재현 범위를 먼저 의심한다. 하네스 위에서 기각된 가설은 기각된 것이 아니라 "그 하네스에서는 판정 불가"일 뿐이다.

판정: 네이티브 로그 ms 타임스탬프

실사용 플로우 재현으로 성공/실패 케이스를 각각 잡고, 네이티브 로그에서 confirm dismiss 시점과 다음 navigation의 didCommit 시점 간격을 측정했다.

confirm dismiss → reload didCommit 간격
항목비교값 (ms)
성공 케이스645645 ms
실패 케이스491491 ms
  • 성공 케이스: 645ms. reload의 커밋이 500ms 타이머보다 늦게 도착했다. 타이머가 먼저 발화해 스킴이 네이티브에 전달된 뒤 reload가 커밋됐다.
  • 실패 케이스: 491ms. reload가 500ms 타이머보다 9ms 먼저 커밋됐다. 문서가 교체되면서 이전 문서의 타이머는 폐기되고, 스킴은 영원히 호출되지 않는다.

즉 이 버그는 500ms 타이머와 reload의 서버 왕복 중 누가 먼저 도착하는가의 경주였고, 승부 마진이 9ms까지 좁혀지는 것을 실측으로 확인했다. 변동의 원인도 로그로 귀속시켰다. 성공 케이스의 서버 응답은 450ms, 실패 케이스는 352ms — 서버 응답 시간이 빠를수록 버그가 발생한다. 프로세스 스왑이나 웹뷰 cold/warm 같은 구조적 차이는 로그상 없었다.

sequenceDiagram
    participant W as 웹 (JSP)
    participant N as 네이티브 웹뷰
    participant S as 서버
    Note over W: confirm dismiss (t=0)
    W->>W: setTimeout(스킴, 500ms) 등록
    W->>S: location.reload() 즉시
    S-->>N: 응답 352ms (빠른 날)
    Note over N: t=491ms didCommit<br/>→ 이전 문서 타이머 폐기 (9ms 차이 패배)
    Note over W: t=500ms 스킴 발화 예정이었으나<br/>문서가 이미 교체됨 — 미동작

변수 분리: 4구성 비교 실험

"타이머-커밋 레이스"라는 결론을 단일 관찰에 의존하지 않기 위해, 의심 변수(스킴 호출의 동기성, reload의 유무/지연)를 분리한 4개 구성을 같은 실사용 플로우로 반복 실행했다.

ORIG (동기 스킴 + reload 없음)

장점

  • 전부 성공

고려할 점

  • 회귀 이전의 원형

CUR (스킴 500ms 지연 + 즉시 reload)

장점

  • 없음

고려할 점

  • 유일하게 확률적 실패 — 현재 배포 코드

CURNR (스킴 500ms 지연 + reload 제거)

장점

  • 전부 성공

고려할 점

  • reload가 레이스의 필요조건임을 입증

FIX (동기 스킴 + reload 지연)

장점

  • 전부 성공

고려할 점

  • 수정 후보

CUR만 확률적으로 실패하고 나머지 세 구성은 전부 성공했다. 레이스의 필요조건이 "지연된 스킴 + 즉시 reload"의 조합임이 실험적으로 분리됐다.

회귀 원인

git 히스토리를 추적하니 특정 커밋이 이 조합을 만들었다. 하이브리드 웹뷰에는 "앱스킴 호출 직후의 navigation은 지연시킨다"는 관례가 있는데(스킴 인터셉트가 완료될 시간을 주기 위해), 해당 커밋은 이 관례를 반대로 적용했다 — 스킴을 지연시키고 navigation(reload)을 즉시 실행한 것이다. 관례의 형태(setTimeout 500ms)만 남고 방향이 뒤집히면서, 보호 장치가 그대로 버그 유발 장치가 됐다.

구현

수정 방향은 실측 결과에서 직접 도출된다. 관례의 원래 방향대로 되돌리면 된다.

// FIX: 스킴은 동기, navigation이 양보한다
location.href = appScheme;
setTimeout(function () {
  location.reload();
}, 500);

수정 지점의 범위(이벤트 페이지 JSP만 고칠지, 같은 패턴을 내려주는 서버 측 템플릿 데이터까지 함께 고칠지)는 배포 영향 범위가 달라 사용자 결정 사항으로 분리해 보고했다. 진단과 수정 방향은 확정, 적용 범위는 의사결정 대기 상태로 종료한 조사다.

결과

  • 근본원인 확정: setTimeout(스킴, 500) + 즉시 reload의 타이머-커밋 레이스. 실패 시 타이머가 9ms 차이로 폐기되는 것을 네이티브 로그로 실측.
  • 판정 수치: 성공 645ms vs 실패 491ms (confirm dismiss → didCommit), 변동 요인은 서버 응답 시간(450ms vs 352ms)으로 귀속. 프로세스 스왑/웹뷰 cold-warm 가설은 배제.
  • 4구성 비교로 CUR만 확률적 실패, 나머지 전부 성공 — 원인 변수의 실험적 분리 완료.
  • 회귀 유발 커밋과 그 메커니즘("스킴 후 navigation 지연" 관례의 방향 반전)까지 git 히스토리로 특정.

배운 것

재현 하네스가 실제와 다른 결과를 내면, 하네스의 재현 범위를 먼저 의심하라. 하네스 위의 실험 결과가 실기기 관찰과 모순되는 순간, 그 하네스에서 나온 모든 기각/채택은 보류 대상이다. 이번 조사에서 "reload 제거해도 실패"라는 CDP 하네스 결과를 그대로 믿었다면 진짜 원인(reload 레이스)을 이미 기각한 채로 헤맸을 것이다.

최종 판정은 실제 입력 경로 + 가장 낮은 레이어의 타임스탬프로 한다. 웹 레이어 증상이라도 판정 근거는 네이티브 로그의 ms 타임스탬프였다. 스킴 호출처럼 웹 코드의 손을 떠나는 동작은, 그것을 받는 쪽의 로그만이 "실제로 일어났는가"에 답할 수 있다.

확률적 버그는 단일 재현이 아니라 변수 분리 실험으로 확정한다. 성공/실패 케이스 하나씩의 관찰은 가설이고, 의심 변수를 하나씩 뒤집은 구성들의 반복 실행 결과가 모여야 원인이 된다. 4구성 중 문제 조합에서만 실패가 나오는 것을 보여주는 편이, 어떤 설명보다 강한 증거다.

관례는 방향까지가 관례다. "스킴 다음 navigation을 500ms 지연"이라는 패턴에서 지연의 대상이 뒤집히면, 같은 모양의 코드가 정확히 반대의 효과를 낸다. 회귀 리뷰에서 패턴의 존재만 확인하고 방향을 확인하지 않으면 이런 반전을 놓친다.