포모동(집중 타이머)과 인타바(운동 타이머)는 둘 다 카운트다운 타이머가 핵심이다. 그런데 두 서비스의 타이머는 같은 약점을 갖고 있다. 탭을 오래 백그라운드에 두거나 휴대폰 화면이 꺼졌다 켜지면, 화면의 남은 시간이 실제 흐른 시간과 어긋난다. 이 글은 왜 그런 일이 생기는지, 그리고 어떻게 고치면 되는지를 실제 코드를 기준으로 정리한다.

현재 구현: 1초마다 1을 뺀다
포모동의 useTimer는 다음과 같은 구조다.
const tick = useCallback(() => {
setRemainingSeconds((prev) => {
const next = prev - 1;
onTickRef.current?.(next);
if (next <= 0) {
clearTimer();
setState("complete");
onCompleteRef.current?.();
return 0;
}
return next;
});
}, [clearTimer]);
const start = useCallback(() => {
if (state === "running") return;
setState("running");
intervalRef.current = setInterval(tick, 1000);
}, [state, tick]);
인타바도 원리는 같다. setInterval(tick, 1000)을 걸고, tick이 실행될 때마다 timeRemaining에서 1을 뺀다. 화면에 보이는 남은 시간은 "지금까지 tick이 몇 번 실행되었는가"로 결정된다.
이 방식은 직관적이고 일시정지도 쉽다. 인터벌만 지우면 숫자가 그대로 멈춘다. 문제는 tick이 정확히 1초마다 실행된다는 보장이 없다는 것이다.
tick이 늦거나 사라지는 세 가지 경우
1. 메인 스레드가 바쁘면 늦거나 건너뛴다
setInterval(fn, 1000)은 "1000ms 뒤에 실행해 달라"는 요청이지 "정확히 1000ms마다"라는 약속이 아니다. 메인 스레드가 렌더링이나 다른 작업으로 바쁘면 콜백은 그만큼 늦게 실행된다. 다만 이 지연이 그대로 쌓이는 것은 아니다. 두 방식을 같은 조건에서 직접 돌려 비교했다.
- 환경: Chromium 156.0.8078.4(Playwright 1.64.1, headless), macOS 26.6.2 arm64(Apple M5 Pro), Node v22.22.2, 2026-10-23 측정
- 방법: 빈 페이지에서 "1초마다 1 빼기"와 "종료 시각 기준 계산"을 60초 동안 동시에 돌리고, 메인 스레드를
while루프로 주기적으로 막는다. 조건마다 새 페이지를 쓴다.
| 메인 스레드 블로킹 | 실제 경과 | 1초마다 1 빼기: tick 횟수 | 1초마다 1 빼기: 남은 시간 표시 | 종료 시각 기준: 남은 시간 표시 |
|---|---|---|---|---|
| 없음 | 60.1초 | 60 | 0초 | 0초 |
| 500ms, 1000ms마다 | 60.5초 | 60 | 0초 | 0초 |
| 1,200ms, 3,000ms마다 | 61.2초 | 60 | 0초 | 0초 |
| 2,500ms, 4,000ms마다 | 62.5초 | 46 | 14초 | 0초 |
간격보다 짧은 블로킹은 브라우저가 다음 실행 시각을 맞춰 주어 tick 횟수가 줄지 않았다. 그래서 "몇 밀리초씩 늦어지는 것"은 쌓이지 않는다. 하지만 블로킹이 간격(1초)을 넘겨 tick이 건너뛰어지면 그 tick은 되돌아오지 않고, 마지막 줄처럼 60초가 지났는데 14초가 남은 채로 표시된다. 같은 조건에서 종료 시각 기준 계산은 매번 0초를 표시했다. 블로킹 200ms를 700ms마다 걸고 CPU를 6배 느리게 한 조건에서도 두 방식 모두 60초 뒤 0초였다(실제 경과 60.4초).
2. 백그라운드 탭에서 느려진다
브라우저는 보이지 않는 탭의 타이머를 의도적으로 느리게 만든다. 배터리와 CPU를 아끼기 위해서다. Chrome은 숨겨진 탭의 타이머를 1초에 한 번 수준으로 묶는다. 여기에 더해 숨겨진 지 5분이 넘고, 타이머가 연쇄된 횟수가 5 이상이며, 30초 이상 소리를 내지 않고, WebRTC를 쓰지 않는 페이지는 타이머를 1분에 한 번만 실행한다(Chrome 공식 문서). 같은 문서에 따르면 최근에 소리를 낸 페이지는 이 강한 제한에서 빠지므로, 앰비언트 사운드를 재생 중인 포모동은 소리가 나는 동안에는 해당하지 않는다. 1분에 한 번만 tick이 실행되면 화면 시간은 1분에 1초씩만 줄어든다.
3. 화면이 꺼지면 아예 멈춘다
모바일에서 화면을 끄거나 다른 앱으로 전환하면 브라우저는 페이지의 자바스크립트 실행을 멈출 수 있다. 그동안 tick은 한 번도 실행되지 않는다. 5분 뒤 다시 돌아오면 타이머는 5분 전 숫자에서 다시 줄어들기 시작한다.
세 경우 모두 원인은 같다. "몇 번 실행되었나"로 시간을 세고 있기 때문이다. 실행 횟수는 브라우저 사정에 따라 달라지지만, 실제로 흐른 시간은 그렇지 않다.
고치는 방법: 종료 시각을 저장한다
해결책은 간단하다. 남은 초를 1씩 줄이는 대신, 타이머를 시작할 때 언제 끝나야 하는지(종료 시각) 를 저장하고, tick마다 "종료 시각 − 현재 시각"으로 남은 시간을 다시 계산한다.
type TimerCore =
| { status: "idle"; remainingMs: number }
| { status: "running"; endsAt: number } // 끝나야 하는 절대 시각
| { status: "paused"; remainingMs: number }
| { status: "complete" };
function remainingOf(core: TimerCore, now: number): number {
if (core.status === "running") return Math.max(0, core.endsAt - now);
if (core.status === "complete") return 0;
return core.remainingMs;
}
이 구조에서 tick은 더 이상 시간을 "줄이지" 않는다. 그냥 화면을 다시 그리라는 신호일 뿐이다. tick이 늦게 오든, 몇 번 건너뛰든, 다시 실행되는 순간 현재 시각으로 계산하면 항상 정확한 값이 나온다.
export function useCountdown(totalMs: number, onComplete?: () => void) {
const [core, setCore] = useState<TimerCore>({ status: "idle", remainingMs: totalMs });
const [now, setNow] = useState(() => Date.now());
const start = () => setCore((c) =>
c.status === "running" ? c : { status: "running", endsAt: Date.now() + remainingOf(c, Date.now()) });
const pause = () => setCore((c) =>
c.status === "running" ? { status: "paused", remainingMs: remainingOf(c, Date.now()) } : c);
const reset = () => setCore({ status: "idle", remainingMs: totalMs });
// 실행 중에만 화면 갱신용 tick을 건다
useEffect(() => {
if (core.status !== "running") return;
const id = setInterval(() => setNow(Date.now()), 250);
// 탭으로 돌아오는 순간 즉시 다시 계산한다
const onVisible = () => { if (document.visibilityState === "visible") setNow(Date.now()); };
document.addEventListener("visibilitychange", onVisible);
return () => { clearInterval(id); document.removeEventListener("visibilitychange", onVisible); };
}, [core.status]);
const remainingMs = remainingOf(core, now);
// 완료 판정은 렌더링 결과를 보고 이펙트에서 한 번만
useEffect(() => {
if (core.status === "running" && remainingMs === 0) {
setCore({ status: "complete" });
onComplete?.();
}
}, [core.status, remainingMs, onComplete]);
return { status: core.status, remainingSeconds: Math.ceil(remainingMs / 1000), start, pause, reset };
}
몇 가지 설계 포인트를 짚어 보자.
갱신 주기는 250ms로 짧게. 1초마다 갱신하면 표시가 실제보다 최대 1초 늦을 수 있고, 초가 바뀌는 순간이 들쭉날쭉해 보인다. 계산이 정확하니 갱신을 자주 해도 오차가 쌓이지 않는다. 비용은 숫자 하나를 다시 그리는 정도다.
남은 초는 올림으로. 남은 시간이 0.3초일 때 화면에 "0"이 보이면 끝난 것처럼 느껴진다. Math.ceil로 올림해야 "1"이 보이다가 정확히 끝나는 순간 "0"이 된다.
완료 처리는 업데이트 함수 밖에서. 기존 코드는 setRemainingSeconds의 업데이트 함수 안에서 onComplete를 호출했다. 업데이트 함수는 React가 두 번 호출할 수 있으므로(개발 모드 StrictMode 등) 알림이 두 번 갈 위험이 있다. 완료 판정을 이펙트로 옮기면 그 문제도 함께 사라진다.
일시정지는 남은 시간을 저장한다. 일시정지할 때 "종료 시각 − 지금"을 계산해 남은 시간으로 저장하고, 재개할 때 "지금 + 남은 시간"으로 새 종료 시각을 만든다. 일시정지한 동안 흐른 시간은 자연스럽게 빠진다.
Date.now()와 performance.now() 중 무엇을 쓸까
정밀한 시간 측정에는 보통 performance.now()를 권한다. 사용자가 시스템 시계를 바꿔도 거꾸로 가거나 튀지 않는 단조 증가 시계이기 때문이다. 하지만 카운트다운처럼 **"현실의 시간이 얼마나 흘렀나"**가 중요한 경우에는 고민이 필요하다. 기기가 절전 상태에 있던 동안 performance.now()가 멈춰 있을 수 있다. 스펙(Level 2)은 절전 중에도 진행해야 한다고 정하지만, MDN은 실제로는 Windows의 브라우저만 그렇고 다른 OS에서는 Chrome·Firefox·Safari 모두 멈출 수 있다고 정리한다(MDN: Performance.now()). 이 글에서 절전 동작을 직접 측정하지는 않았다. 반대로 Date.now()는 절전 중에도 흐르지만, 시계 동기화로 값이 갑자기 바뀔 수 있다.
25분짜리 포모도로나 몇 분짜리 인터벌에서는 시계 동기화로 생기는 오차보다 화면이 꺼진 동안 시간이 멈추는 문제가 훨씬 크다. 그래서 위 예시는 Date.now()를 썼다. 반응속도처럼 수백 밀리초를 재는 측정이라면 performance.now()가 맞다.
인타바처럼 여러 구간이 이어지는 경우
인타바는 운동 → 휴식 → 운동 … 으로 구간이 이어진다. 화면이 꺼져 있던 동안 여러 구간이 지나갔을 수 있다. 이때도 "타이머 전체를 시작한 시각"과 "총 일시정지 시간"만 알면, 지금이 몇 번째 라운드의 어느 구간인지 계산할 수 있다.
// 구간 목록을 미리 펼쳐 둔다: [운동 20, 휴식 10, 운동 20, 휴식 10, ...]
function locate(segments: number[], elapsedSec: number) {
let acc = 0;
for (let i = 0; i < segments.length; i += 1) {
if (elapsedSec < acc + segments[i]) return { index: i, remaining: acc + segments[i] - elapsedSec };
acc += segments[i];
}
return { index: segments.length, remaining: 0 }; // 모두 끝남
}
이렇게 하면 화면이 꺼진 동안 지나간 구간도 정확히 건너뛸 수 있다. 다만 지나간 구간마다 전환음을 뒤늦게 몰아서 울리면 오히려 혼란스러우니, 돌아왔을 때는 현재 구간의 소리만 내는 것이 좋다.
그래도 남는 한계
계산을 정확하게 바꿔도, 화면이 꺼진 동안 소리를 낼 수 없다는 한계는 남는다. 자바스크립트가 멈춰 있으면 "세션이 끝났습니다" 알림도 제때 울리지 않는다. 확실한 알림이 필요하면 서비스 워커와 푸시 알림, 또는 네이티브 앱의 예약 알림이 필요하다. 웹 타이머에서 현실적으로 할 수 있는 일은 두 가지다.
- 돌아왔을 때 정확한 상태를 즉시 보여 주기 — 이 글의 방식으로 해결된다.
- 운동처럼 실시간 안내가 중요한 경우 화면을 켜 두도록 안내하기 — 이 사이트의 인타바 프로젝트 소개 FAQ도 그렇게 안내하고 있다. Screen Wake Lock API로 화면 꺼짐을 막는 방법도 있다.
이 글에서 측정하지 못한 것
위 측정은 빈 페이지에서 두 방식의 코드를 비교한 결과이고, 개선안은 아직 포모동·인타바에 적용하지 않았다. 숨겨진 탭에서 10분 뒤의 표시 오차나 화면이 꺼졌을 때의 동작은 headless Chromium으로 재현할 수 없어 측정하지 않았다. 실기기에서 확인한 값이 생기면 이 글에 더하겠다.
정리
| 구분 | 1초마다 1 빼기 | 종료 시각 기준 계산 |
|---|---|---|
| 시간의 기준 | tick 실행 횟수 | 실제 흐른 시간 |
| 백그라운드 탭 | 느려짐 | 돌아오면 즉시 정확 |
| 화면 꺼짐 | 멈춘 만큼 어긋남 | 정확 |
| 일시정지 | 인터벌만 지우면 됨 | 남은 시간을 저장 |
| 구현 난이도 | 쉬움 | 조금 더 복잡 |
타이머를 만들 때 기억할 한 가지는 이것이다. tick은 시간이 아니라 "다시 그려라"는 신호다. 시간은 항상 시계에서 읽어야 한다.