img-profile

highjoon-dev

HOMEPOSTSABOUT
img-profile
img-profile
highjoon-dev

highjoon 2025. All Rights Reserved.

  • |
  • |

iOS Safari 딥링크 Fallback 오작동 해결하기 (feat. requestAnimationFrame)

9
iOS Safari 딥링크 Fallback 오작동 해결하기 (feat. requestAnimationFrame) 배너 이미지

Table of Contents

  • 1. 문제의 시작
    • 증상
  • 2. 첫 시도
  • 3. 원인
    • 백그라운드 타이머 클램핑?
  • 4. 해결 전략
    • requestAnimationFrame 기반 Heartbeat
    • setTimeout vs requestAnimationFrame의 차이
    • Heartbeat의 개념
      • 핵심 원리
    • 한 프레임 유예 처리
      • 예시
      • 한 프레임 뒤에 다시 확인하자
  • 5. 로직 구성
    • Fallback 흐름 요약

1. 문제의 시작

모바일 웹에서 앱으로 유도할 때 흔히 다음과 같은 로직을 사용한다.

window.location.href = "myapp://open/some-page";
setTimeout(() => {
  window.location.href = "https://myapp.com/download";
}, 1500);
  1. 앱 스킴(myapp://...)을 호출한다.
  2. 일정 시간 내에 앱이 실행되지 않으면 다운로드 페이지(redirectUrl)로 이동한다.

네이버와 같은 빅 테크에서도 이렇게 안내하는 것으로 보아, “딥링크의 표준 패턴”처럼 사용되어 온 것 같다.

나 역시 동일한 기능 구현이 필요해서 해당 가이드를 참고하여 구현했다. 하지만 특정 iOS 버전의 Safari에서 이 로직이 의도치 않게 오작동하는 사례를 겪었다.

증상

앱 스킴으로 전환 후, 사용자가 다시 Safari로 돌아오면

Fallback 타이머가 재개되며 redirectUrl로 강제로 이동하는 문제

결과적으로, “앱을 정상적으로 열었는데 다시 다운로드 페이지로 튕기는” 이상한 현상을 겪었다.

2. 첫 시도

가장 먼저 떠올린 대응은 “브라우저의 상태 변화를 감지하는 것” 이었다.

앱으로 전환되는 순간 페이지는 백그라운드 상태가 되기 때문이다.

document.addEventListener("visibilitychange", () => {
  if (document.visibilityState === "hidden") {
    // Safari 탭이 백그라운드로 전환됨
    clearTimeout(fallbackTimer);
  }
});

이 접근은 Chrome, Android WebView 등 대부분의 환경에서 잘 동작했지만, iOS Safari에선 여전히 재현되었다.

3. 원인

문제의 근본적인 원인은 바로 iOS Safari의 타이머 클램핑(timer clamping) 정책에 있었다.

백그라운드 타이머 클램핑?

iOS Safari는 사용자가 브라우저를 벗어나면, 자원의 낭비를 막기 위해 백그라운드 탭의 JS 실행을 일시 정지(pause) 한다.

이때 setTimeout, setInterval, requestAnimationFrame 등 모든 타이머가 멈추게 된다.

그리고 다시 포그라운드로 돌아오면 JS가 실행되어, 타이머가 “이어서 재개”된다.

즉, 이 시점에서는 이미 “지정된 시간이 지난 것처럼” 인식되어 콜백이 즉시 실행된다.

예를 들어, 아래 코드는 다음과 같이 동작한다.

setTimeout(() => console.log("timeout"), 1500);
  • Safari가 포그라운드 상태일 땐 정확히 1.5초 후 실행된다.
  • 백그라운드 전환 시 타이머는 일시 정지하고, 복귀 시 재개되어 콜백이 실행된다.

따라서 사용자는 앱을 이미 열었음에도 다시 다운로드 페이지로 이동하는 문제를 겪게 된다.

관련 문서

  • Background timer stopped after 30 seconds on iOS 13.3 & 13.3.1
  • “백그라운드 페이지 성능을 지원하는 정책”
  • How Web Content Can Affect Power Usage

4. 해결 전략

문제의 핵심을 정리해보면 다음과 같다.

“Safari가 백그라운드로 전환되면 타이머가 멈춘다.

그리고 복귀 시점에 재개되며 setTimeout 콜백이 터진다.”

따라서 이를 해결하기 위해서는 “앱 전환이 실제로 일어났는지 감지” 하고 Fallback을 취소하는 것이다.

requestAnimationFrame 기반 Heartbeat

브라우저는 포그라운드에 있을 때 약 16ms(=60fps) 간격으로 requestAnimationFrame 콜백을 실행한다. (MDN)

하지만 백그라운드로 전환되면 이 콜백 호출은 완전히 멈추거나 지연된다.

페이지가 비활성화되면 WebKit은 자동으로 절전 조치를 취합니다:

  • requestAnimationFrame 이 중지됩니다.
  • CSS와 SVG 애니메이션이 일시 정지됩니다.
  • 타이머가 쓰로틀 됩니다.

- Webkit 블로그, "How Web Content Can Affect Power Usage"

이 특성을 활용하면 앱 전환을 감지할 수 있다.

setTimeout vs requestAnimationFrame의 차이

구분setTimeoutrequestAnimationFrame
호출 주기인위적인 지연(ms)브라우저 렌더링 루프에 맞춰 자동 호출
브라우저 상태백그라운드 시 정지 후 재개백그라운드 시 콜백 자체가 중단됨
재개 시점복귀 직후 즉시 콜백 실행복귀 후 렌더링 루프 재시작 시점부터 호출

즉, setTimeout은 복귀 시 “멈춘 시간만큼 밀린 타이머를 즉시 실행”하지만, requestAnimationFrame은 멈춘 기간 동안 아예 호출되지 않는다.

이 성질을 이용하면, 프레임이 비정상적으로 오래 멈춘 시점을 포착할 수 있다.

또한, requestAnimationFrame 는 렌더링 루프의 일부이므로 탭/프로세스가 일시 중단되면 가장 먼저 멈추며, 인위적인 JS 타이머보다 브라우저 상태를 더 정밀하게 반영한다.

Heartbeat의 개념

requestAnimationFrame을 마치 심장박동(heartbeat) 처럼 계속 재귀하면서 프레임 간격(now - last)을 관찰한다.

let last = performance.now();
let wasHiddenOrBackgrounded = false;

function heartbeat() {
  const now = performance.now();
  const diff = now - last;

  if (diff > 250) {
    /**
     * 보통 60fps(16ms)인데, 250ms 이상 벌어지면
     * 백그라운드 전환이 있었던 것으로 판단한다.
     */
    wasHiddenOrBackgrounded = true;
  }

  last = now;
  requestAnimationFrame(heartbeat);
}

requestAnimationFrame(heartbeat);

핵심 원리

포그라운드 상태에서는 브라우저가 꾸준히 화면을 그리기 때문에 requestAnimationFrame 간격이 16~33ms 내외가 된다.

하지만 백그라운드로 전환되면 렌더링 루프가 멈추고, 복귀 시 첫 프레임까지 수백~수천 ms가 벌어진다.

이 간극(diff)을 백그라운드 감지 신호로 활용한다.

한 프레임 유예 처리

Heartbeat 로직은 재현율을 확실히 낮췄지만 문제를 완벽히 해결하지는 못했다.

Heartbeat 검사를 위해서는 최소 1번의 requestAnimationFrame 이 실행되어야 한다. 하지만 복귀 직후에 requestAnimationFrame 보다 setTimeout의 타이머가 먼저 실행될 수 있다.

setTimeout은 태스크 큐(macrotask) 단계에서 실행되고, requestAnimationFrame 은 렌더링 루프(vsync) 단계에서 호출된다.

포그라운드 복귀 직후에 렌더링 루프가 아직 준비되지 않았지만, JS 이벤트 루프는 이미 돌기 시작했기 때문에 타이머가 바로 실행될 수 있다.

즉, requestAnimationFrame 이 돌기도 전에 Fallback이 먼저 터질 수 있다.

이 경우 Heartbeat는 “큰 간격(diff)”이 없으니 백그라운드 전환을 감지하지 못한다.

예시

만약 Fallback이 1500ms일 때, 사용자가 t≈1500ms 직전에 복귀하면:

t=1500ms  : setTimeout 만료 → 태스크 큐에서 즉시 Fallback 실행 (redirect 실행)
t=1516ms~ : 다음 vsync에서 첫 requestAnimationFrame 호출 (60Hz 기준)

타이머가 rAF보다 먼저 실행된다.

즉, 앱 전환 ↔ 복귀 시간이 Fallback 임계값과 매우 근접한 경우를 커버하지 못한다.

한 프레임 뒤에 다시 확인하자

Fallback 타이머가 만료되었을 때, 바로 redirect하지 않고 requestAnimationFrame + setTimeout(0) 조합으로 한 프레임 더 기다린 뒤 다시 판단한다.

렌더링 루프 (requestAnimationFrame)가 한 번 재개된 다음, 이벤트 루프의 큐 까지 모두 비운 뒤 (setTimeout(0)), Fallback 여부를 판단하는 방식이다.

requestAnimationFrame(() => {
  setTimeout(() => {
    const elapsed = performance.now() - startTime;
    const secondCheck = checkIsLikelyBackgrounded(elapsed);

    /** 앱 전환 신호가 없다면, 앱 전환 실패로 보고 Fallback 실행 */
    if (!secondCheck && redirectUrl && document.visibilityState === "visible") {
      window.location.href = redirectUrl;
    }
  }, 0);
});
[ t=1500ms Fallback 만료 ]
     │
     └─ requestAnimationFrame → setTimeout(0) → 이벤트 큐/렌더 루프 정상화
                      │
                      ├─ 전환 신호 감지됨 → Fallback 취소
                      └─ 전환 신호 없음 → Fallback 실행

5. 로직 구성

최종적으로 구성한 로직은 다음과 같다.

requestAnimationFrame Heartbeat와 한 프레임 유예에 더해서, AOS 등 다른 환경에서의 동작을 위해 visibilitychange 등의 기본적인 이벤트 리스너 처리까지 추가한다.

const redirectUrl = "";
const deeplinkUrl = "";
const startTime = performance.now();
let wasHiddenOrBackgrounded = false;
let heartbeatId: number | undefined;
let fallbackTimeout: ReturnType<typeof setTimeout> | undefined;

/** setTimeout 시간 */
const FALLBACK_DURATION = 1500;
/** 경계 오차 (화면 전환 애니메이션 시간 등) */
const SLOP = 300;
/** requestAnimationFrame Heartbeat의 큰 격차 기준 */
const HEARTBEAT_THRESHOLD = 250;

function checkIsLikelyBackgrounded(elapsed: number) {
  return (
    wasHiddenOrBackgrounded ||
    elapsed > FALLBACK_DURATION + SLOP ||
    document.hidden === true
  );
}

function clearAll() {
  document.removeEventListener("visibilitychange", onVisibility);
  document.removeEventListener("webkitvisibilitychange", onVisibility);
  if (heartbeatId !== undefined) cancelAnimationFrame(heartbeatId);
  if (fallbackTimeout) clearTimeout(fallbackTimeout);
}

function onVisibility() {
  if (document.visibilityState === "hidden") {
    wasHiddenOrBackgrounded = true;
    /** 앱 전환 성공으로 간주 → Fallback 취소 */
    clearAll();
  }
}

/** requestAnimationFrame 기반의 Heartbeat */
function startHeartbeat() {
  let last = performance.now();
  const tick = () => {
    const now = performance.now();
    if (now - last > HEARTBEAT_THRESHOLD) wasHiddenOrBackgrounded = true;
    last = now;
    heartbeatId = requestAnimationFrame(tick);
  };
  heartbeatId = requestAnimationFrame(tick);
}

/** 1) 센서 구독  */
document.addEventListener("visibilitychange", onVisibility);
document.addEventListener("webkitvisibilitychange", onVisibility);
/** Heartbeat 시작 */
startHeartbeat();

/** 2) Fallback 타이머  */
fallbackTimeout = setTimeout(() => {
  const elapsed = performance.now() - startTime;
  const likelyBackgrounded = checkIsLikelyBackgrounded(elapsed);

  if (likelyBackgrounded) {
    /** 앱 전환 성공으로 간주 → Fallback 취소 */
    clearAll();
    return;
  }

  /** 3) 한 프레임 유예 후 최종 판단 */
  requestAnimationFrame(() => {
    setTimeout(() => {
      const elapsed2 = performance.now() - startTime;
      const secondCheck = checkIsLikelyBackgrounded(elapsed2);

      if (
        !secondCheck &&
        redirectUrl &&
        document.visibilityState === "visible"
      ) {
        clearAll();
        /** 진짜 실패 → Fallback 실행 */
        window.location.href = redirectUrl;
      } else {
        clearAll();
      }
    }, 0);
  });
}, FALLBACK_DURATION);

/** 4) 딥링크 호출 */
window.location.href = deeplinkUrl;

Fallback 흐름 요약

Fallback 시점에서 아래 중 하나라도 참이면 Fallback을 취소한다.

  1. requestAnimationFrame heartbeat로 프레임 간의 큰 간격(diff) 감지 → wasHiddenOrBackgrounded === true
  2. 경과 시간이 기준을 초과 → elapsed > FALLBACK_DURATION + SLOP
  3. 브라우저가 여전히 hidden 상태라면 → document.hidden === true

위가 모두 거짓이고 redirectUrl 이 있으며 화면이 보이는 상태라면, 앱 전환 성공 후 복귀한 것으로 간주하고 Fallback을 실행한다.