requestAnimationFrame 동작 정리
애니메이션을 setTimeout 대신 requestAnimationFrame(이하 rAF)으로 굴려야 한다는 얘기는 자주 들었다. 그런데 막상 "왜 그래야 하는지", "안에서 정확히 무슨 일이 벌어지는지"는 흐릿했다. 한 번 짚고 넘어가려고 동작 원리를 차근차근 따라가봤다.
rAF는 다음 리페인트 직전에 콜백을 깨운다
브라우저에게 실행될 애니메이션을 알리고, 다음 리페인트 바로 전에 브라우저가 애니메이션 함수를 호출하도록 요청하는 API다.
그리고 배터리와 CPU 낭비를 막기 위해, 대부분의 브라우저에서 백그라운드 탭(다른 탭으로 전환된 상태)이나 숨겨진 iframe에서는 rAF 콜백 실행을 일시 중지한다. 보이지 않는 화면을 위해 매 프레임 콜백을 도는 건 낭비라는 판단이다.
호출 흐름은 "다음 화면 갱신 때 깨워줘"에 가깝다
requestAnimationFrame(animate)를 호출하면 브라우저에게 "다음 화면 갱신 때 animate 함수를 호출해줘"라고 알려주는 셈이다. 흐름을 정리하면 이렇다.
호출 빈도는 디스플레이 주사율과 일치한다. 60Hz 디스플레이라면 프레임 간격이 약 16.7ms이고, 그 주기에 맞춰 animate가 호출된다. 다음 프레임 렌더링이 끝났으면 그대로 보여주고, 끝나지 않았으면 이전 프레임이 한 주기 더 표시된다.
프레임과 화면 갱신 주기는 같은 게 아니다
처음에는 둘을 같은 말로 알고 있었는데, 짚어보니 다른 개념이었다. 둘이 1:1로 맞아떨어질수록 효율적이고, 어긋날수록 끊김이 생긴다.
프레임
- 60fps라면 1초당 콜백이 60회 실행된다.
- 렌더링 복잡도에 따라 실제 fps는 달라진다. 그릴 게 많으면 떨어지고, 단순하면 유지된다.
화면 갱신 주기
- 디스플레이(모니터)의 주사율과 일치한다. 60Hz면 1초에 60번, 120Hz면 120번 화면이 갱신된다.
- 즉 주사율에 따라 rAF 호출 빈도 자체가 바뀐다.
요점은 **프레임은 "내가 얼마나 빨리 그려낼 수 있는가"**이고, **화면 갱신 주기는 "디스플레이가 얼마나 자주 갱신하는가"**라는 점이다. 둘이 맞아야 부드럽다.
렌더링이 제때 끝나는 경우와 늦는 경우
60fps, 60Hz 기준으로 두 경우를 비교해봤다. 정상 케이스는 매 프레임 16ms 안에 렌더링이 끝나서 그대로 화면에 반영된다.
| 프레임 | 프레임 슬롯 | 렌더링 소요 | 결과 |
|---|---|---|---|
| 1 | 0~16ms | 10ms | 16ms vsync에 화면 갱신 |
| 2 | 16~32ms | 12ms | 32ms vsync에 화면 갱신 |
| 3 | 32~48ms | 8ms | 48ms vsync에 화면 갱신 |
문제는 렌더링이 한 프레임 시간을 넘기는 순간이다. 프레임 1이 25ms 걸리면 16ms 시점의 vsync(디스플레이가 새 프레임으로 화면을 갱신하는 타이밍 신호)에 맞춰 들어가지 못한다. 그 사이에 끼인 프레임 2는 통째로 건너뛰고, 화면에는 직전 프레임이 그대로 유지된다.
| 프레임 | 프레임 슬롯 | 렌더링 소요 | 결과 |
|---|---|---|---|
| 1 | 0~16ms | 25ms (16ms에 미완성) | 16ms vsync 놓침 |
| 2 | 16~32ms | — (건너뜀) | 이전 프레임 그대로 유지 |
| 3 | 32~48ms | 완료 | 32ms vsync에 화면 갱신 |
브라우저는 vsync 시점에 완성된 프레임만 화면에 스왑한다. 현재 프레임이 vsync까지 완성되지 않으면 이전 프레임이 그대로 남는다. 그래서 한 프레임이 길어지면 그 다음 프레임 슬롯이 통째로 날아가는 셈이다.
vsync와 더블 버퍼링이 완성된 프레임만 표시하는 이유
그러면 왜 "절반만 그려진 프레임"을 보여주지 않을까. 처음엔 "그래도 뭐라도 보여주는 게 낫지 않나" 싶었는데, vsync + 더블 버퍼링이 완성된 프레임만 표시하는 데는 이유가 있었다.
여기서 더블 버퍼링은 렌더링용 백버퍼와 화면 표시용 프런트버퍼를 따로 두고, 백버퍼에 한 프레임이 다 그려지면 두 버퍼를 한 번에 맞바꾸는 방식이다. vsync 신호가 오는 순간 완성된 백버퍼만 프런트로 스왑되기 때문에, 절반만 그려진 화면이 노출되지 않는다. vsync는 "언제 스왑할지"를 정하고, 더블 버퍼링은 "무엇을 스왑할지"를 정한다고 보면 된다.
- 화면 깜빡임 방지 — 그리는 도중의 버퍼를 그대로 보여주면 위아래가 어긋난 화면(테어링)이 보인다.
- 일관성 보장 — 자연스러운 화면 갱신을 위해 한 프레임 안의 모든 요소가 동시에 업데이트돼야 한다. 일부만 갱신된 상태가 노출되면 시각적으로 깨진다.
- 하드웨어 제약 — 모니터와 GPU 자체가 완성된 프레임만 처리하도록 설계되어 있다. 미완성 버퍼를 강제로 밀어 넣을 길이 없다.
프레임 드롭은 "새 프레임이 누락된" 결과다
프레임 드롭이라는 단어가 헷갈렸다. "이전 프레임이 한 번 더 표시되는 것"이 드롭인 줄 알았는데, 정확히는 새 프레임이 화면 갱신 주기 안에 준비되지 못해 누락되는 현상이다. 새 프레임이 제시간에 만들어지지 못한 동안 이전 프레임이 한 주기 더 표시되는 건 결과일 뿐이다.
그래서 화면이 비어 보이지는 않는다. 대신 애니메이션이 뚝뚝 끊어지는 느낌이 난다. 60Hz에서 어쩌다 한 번 끊기면 잘 모르지만, 자주 발생하면 눈에 띄게 버벅인다.
정리해놓고 보니, rAF가 그냥 "주기적으로 호출되는 setTimeout 대체재"가 아니었다. vsync와 디스플레이 주사율에 맞춰진 호출 채널이고, 렌더링이 늦으면 한 프레임 통째로 건너뛰는 비용까지 같이 따라온다. 다음에 애니메이션이 끊겨 보이는 상황을 만나면, 콜백 안에서 너무 무거운 일을 하는 건 아닌지부터 의심해봐야겠다.