모바일 웹에서 앱으로 유도할 때 흔히 다음과 같은 로직을 사용한다.
window.location.href = "myapp://open/some-page";
setTimeout(() => {
window.location.href = "https://myapp.com/download";
}, 1500);
myapp://...)을 호출한다.redirectUrl)로 이동한다.네이버와 같은 빅 테크에서도 이렇게 안내하는 것으로 보아, “딥링크의 표준 패턴”처럼 사용되어 온 것 같다.
나 역시 동일한 기능 구현이 필요해서 해당 가이드를 참고하여 구현했다. 하지만 특정 iOS 버전의 Safari에서 이 로직이 의도치 않게 오작동하는 사례를 겪었다.
앱 스킴으로 전환 후, 사용자가 다시 Safari로 돌아오면
Fallback 타이머가 재개되며 redirectUrl로 강제로 이동하는 문제
결과적으로, “앱을 정상적으로 열었는데 다시 다운로드 페이지로 튕기는” 이상한 현상을 겪었다.
가장 먼저 떠올린 대응은 “브라우저의 상태 변화를 감지하는 것” 이었다.
앱으로 전환되는 순간 페이지는 백그라운드 상태가 되기 때문이다.
document.addEventListener("visibilitychange", () => {
if (document.visibilityState === "hidden") {
// Safari 탭이 백그라운드로 전환됨
clearTimeout(fallbackTimer);
}
});
이 접근은 Chrome, Android WebView 등 대부분의 환경에서 잘 동작했지만, iOS Safari에선 여전히 재현되었다.
문제의 근본적인 원인은 바로 iOS Safari의 타이머 클램핑(timer clamping) 정책에 있었다.
iOS Safari는 사용자가 브라우저를 벗어나면, 자원의 낭비를 막기 위해 백그라운드 탭의 JS 실행을 일시 정지(pause) 한다.
이때 setTimeout, setInterval, requestAnimationFrame 등 모든 타이머가 멈추게 된다.
그리고 다시 포그라운드로 돌아오면 JS가 실행되어, 타이머가 “이어서 재개”된다.
즉, 이 시점에서는 이미 “지정된 시간이 지난 것처럼” 인식되어 콜백이 즉시 실행된다.
예를 들어, 아래 코드는 다음과 같이 동작한다.
setTimeout(() => console.log("timeout"), 1500);
따라서 사용자는 앱을 이미 열었음에도 다시 다운로드 페이지로 이동하는 문제를 겪게 된다.
관련 문서
문제의 핵심을 정리해보면 다음과 같다.
“Safari가 백그라운드로 전환되면 타이머가 멈춘다.
그리고 복귀 시점에 재개되며 setTimeout 콜백이 터진다.”
따라서 이를 해결하기 위해서는 “앱 전환이 실제로 일어났는지 감지” 하고 Fallback을 취소하는 것이다.
브라우저는 포그라운드에 있을 때 약 16ms(=60fps) 간격으로 requestAnimationFrame 콜백을 실행한다. (MDN)
하지만 백그라운드로 전환되면 이 콜백 호출은 완전히 멈추거나 지연된다.
페이지가 비활성화되면 WebKit은 자동으로 절전 조치를 취합니다:
requestAnimationFrame이 중지됩니다. CSS와 SVG 애니메이션이 일시 정지됩니다. 타이머가 쓰로틀 됩니다.
이 특성을 활용하면 앱 전환을 감지할 수 있다.
| 구분 | setTimeout | requestAnimationFrame |
|---|---|---|
| 호출 주기 | 인위적인 지연(ms) | 브라우저 렌더링 루프에 맞춰 자동 호출 |
| 브라우저 상태 | 백그라운드 시 정지 후 재개 | 백그라운드 시 콜백 자체가 중단됨 |
| 재개 시점 | 복귀 직후 즉시 콜백 실행 | 복귀 후 렌더링 루프 재시작 시점부터 호출 |
즉,
setTimeout은 복귀 시 “멈춘 시간만큼 밀린 타이머를 즉시 실행”하지만,
requestAnimationFrame은 멈춘 기간 동안 아예 호출되지 않는다.
이 성질을 이용하면, 프레임이 비정상적으로 오래 멈춘 시점을 포착할 수 있다.
또한, requestAnimationFrame 는 렌더링 루프의 일부이므로 탭/프로세스가 일시 중단되면 가장 먼저 멈추며, 인위적인 JS 타이머보다 브라우저 상태를 더 정밀하게 반영한다.
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 실행
최종적으로 구성한 로직은 다음과 같다.
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을 취소한다.
requestAnimationFrame heartbeat로 프레임 간의 큰 간격(diff) 감지 → wasHiddenOrBackgrounded === trueelapsed > FALLBACK_DURATION + SLOPdocument.hidden === true위가 모두 거짓이고 redirectUrl 이 있으며 화면이 보이는 상태라면, 앱 전환 성공 후 복귀한 것으로 간주하고 Fallback을 실행한다.