
requestAnimationFrame으로 웹 애플리케이션 성능 최적화하기
왠지 모르게 버벅이는 애니메이션, 뭐가 문제일까요?
웹사이트를 만들다 보면 애니메이션이나 인터랙션을 넣을 일이 참 많죠. 예를 들어 스크롤할 때 요소가 스르륵 나타나거나, 차트 데이터가 부드럽게 변경되는 모습을 보여주고 싶을 때가 그렇습니다. 그런데 이런 효과들이 때로는 생각만큼 부드럽게 동작하지 않고, 어딘가 끊기거나 버벅이는 느낌을 줄 때가 있어요. 특히 사용자와의 상호작용이 중요한 부분에서 이런 문제가 생기면 전체적인 사용자 경험이 크게 나빠질 수 있습니다.
왜 그럴까요? 많은 경우, 브라우저가 화면을 그리는 방식(렌더링 파이프라인)과 우리가 작성한 코드의 실행 시점이 잘 맞지 않아서 발생합니다. 브라우저는 정해진 주기에 따라 화면을 업데이트하는데, 우리 코드가 이 주기를 고려하지 않고 너무 자주 또는 너무 늦게 화면 변경을 요청하면 문제가 생기는 거죠.
이럴 때 requestAnimationFrame이라는 좋은 친구가 있습니다. 이 친구를 활용하면 브라우저의 렌더링 주기에 맞춰 코드를 실행할 수 있어서, 훨씬 부드럽고 효율적인 애니메이션을 만들 수 있답니다.
그래서 requestAnimationFrame이 뭔가요?
간단히 말해, requestAnimationFrame은 브라우저에게 "다음 화면 그릴 준비가 되면 알려줘, 그때 이 함수를 실행할게!"라고 부탁하는 방법입니다. 브라우저는 보통 1초에 60번 정도 화면을 새로 그리는데(이걸 주사율이라고 하죠), requestAnimationFrame은 정확히 이 타이밍에 맞춰 우리가 등록한 함수를 실행시켜 줍니다.
setTimeout이나 setInterval 같은 타이머 함수로도 비슷하게 애니메이션을 만들 수 있지만, 몇 가지 문제가 있어요.
- 타이밍 불일치:
setTimeout(callback, 1000/60)이렇게 쓴다고 해서 정확히 16.67ms마다 콜백이 실행된다는 보장이 없습니다. 다른 작업 때문에 늦어질 수도 있고, 브라우저 탭이 비활성화되면 더 느리게 실행되기도 하죠. - 불필요한 작업: 화면 주사율과 맞지 않는 타이밍에 계속 화면을 업데이트하려고 하면, 브라우저는 필요 없는 계산을 하거나 프레임을 건너뛰게 되어 결국 애니메이션이 끊겨 보입니다.
requestAnimationFrame은 이런 문제를 해결해 줍니다. 브라우저가 알아서 최적의 타이밍에 콜백 함수를 실행시켜 주니까요. 덕분에 애니메이션은 더 부드러워지고, CPU 사용량도 줄어들어 배터리도 아낄 수 있습니다. 비활성 탭에서는 아예 콜백 실행을 멈춰주기도 하고요.
실제 코드에서 어떻게 쓸 수 있을까요?
흔한 스크롤 애니메이션, 이렇게 바꿔보세요
스크롤 위치에 따라 요소에 어떤 효과를 주는 건 정말 흔한 패턴이죠. 보통 이렇게 scroll 이벤트 리스너 안에서 바로 DOM을 조작하는 코드를 짜곤 합니다.
// 일반적인 스크롤 이벤트 처리 (성능 이슈 발생 가능성!)
window.addEventListener('scroll', function () {
// DOM 요소들 업데이트
const scrollPosition = window.scrollY
const parallaxElements = document.querySelectorAll('.parallax')
parallaxElements.forEach((element) => {
// 위치 계산
const speed = element.dataset.speed || 0.5
const yPos = -(scrollPosition * Number(speed))
// 스타일 적용 - 매 스크롤 이벤트마다 실행됨
element.style.transform = `translate3d(0, ${yPos}px, 0)`
})
})
이 코드는 잘 돌아가는 것처럼 보이지만, scroll 이벤트는 생각보다 훨씬 자주, 그리고 빠르게 발생합니다. 스크롤 한 번에 수십 번씩 이벤트가 호출되면서 그때마다 DOM 요소의 스타일을 바꾸면 브라우저가 힘들어해요. 결국 화면이 버벅거리는 주범이 되기 쉽습니다.
자, 그럼 requestAnimationFrame을 써서 개선해 볼까요? 아이디어는 간단합니다. 스크롤 이벤트가 발생하면 일단 현재 스크롤 위치만 기록해두고, 실제 화면 업데이트는 requestAnimationFrame에게 맡기는 거죠.
// requestAnimationFrame으로 최적화한 스크롤 이벤트 처리
let ticking = false
let scrollPosition = 0
window.addEventListener('scroll', function () {
// 현재 스크롤 위치 저장
scrollPosition = window.scrollY
// 애니메이션 프레임이 예약되어 있지 않은 경우에만 요청
if (!ticking) {
requestAnimationFrame(() => {
updateElements(scrollPosition)
ticking = false
})
ticking = true
}
})
function updateElements(scrollPos) {
const parallaxElements = document.querySelectorAll('.parallax')
parallaxElements.forEach((element) => {
const speed = element.dataset.speed || 0.5
const yPos = -(scrollPos * Number(speed)) // Number()로 명시적 형변환
// 브라우저 렌더링 사이클에 맞춰 한 번만 실행
element.style.transform = `translate3d(0, ${yPos}px, 0)`
})
}
여기서 ticking이라는 변수가 중요한 역할을 합니다. 스크롤 이벤트가 발생했을 때, 이미 requestAnimationFrame으로 업데이트 함수(updateElements) 실행을 예약해 뒀다면 (즉, ticking이 true라면) 또 예약하지 않는 거죠. 이렇게 하면 불필요한 함수 호출을 막고, 브라우저가 다음 프레임을 그릴 때 딱 한 번만 updateElements 함수가 실행되도록 보장할 수 있습니다. 훨씬 효율적이죠?
데이터 시각화 애니메이션도 부드럽게
차트 같은 데이터 시각화에서 값이 변할 때 부드럽게 애니메이션을 주고 싶을 때도 requestAnimationFrame이 유용합니다. 보통 setInterval을 사용해서 일정한 간격으로 값을 조금씩 변경하면서 애니메이션 효과를 주려고 하죠.
// setInterval을 사용한 일반적인 차트 애니메이션
function animateChart(chartElement, targetValues) {
let currentValues = Array(targetValues.length).fill(0)
const duration = 1000 // 1초
const fps = 60
const frames = (duration / 1000) * fps
let frame = 0
const interval = setInterval(() => {
frame++
// 각 값 업데이트
for (let i = 0; i < currentValues.length; i++) {
currentValues[i] = easeOutCubic(frame, 0, targetValues[i], frames)
}
// DOM 업데이트 (updateChartBars 함수는 차트의 막대를 실제로 그리는 함수라고 가정)
updateChartBars(chartElement, currentValues)
if (frame >= frames) {
clearInterval(interval)
}
}, 1000 / fps)
}
function easeOutCubic(t, b, c, d) {
// t: current time, b: beginning value, c: change in value, d: duration
return c * ((t = t / d - 1) * t * t + 1) + b
}
// 예시: function updateChartBars(element, values) { /* ... */ })
이것도 앞서 스크롤 예제와 비슷한 문제가 있습니다. setInterval의 타이밍이 부정확할 수 있고, 불필요한 렌더링을 유발할 수 있죠.
requestAnimationFrame을 사용하면 이렇게 바꿀 수 있습니다.
// requestAnimationFrame을 사용한 차트 애니메이션
function animateChartOptimized(chartElement, targetValues) {
const startValues = Array(targetValues.length).fill(0)
const startTime = performance.now()
const duration = 1000 // 1초
function animationStep(currentTime) {
// 경과 시간 계산
const elapsedTime = currentTime - startTime
const progress = Math.min(elapsedTime / duration, 1)
// easing 함수는 동일하게 사용 (progress가 0에서 1 사이 값이므로, b=0, c=1, d=1로 적용)
const easedProgress = easeOutCubic(progress * duration, 0, 1, duration)
// 현재 값 계산
const currentValues = startValues.map(
(startVal, i) => startVal + (targetValues[i] - startVal) * easedProgress
)
// DOM 업데이트
updateChartBars(chartElement, currentValues)
// 애니메이션이 완료되지 않았으면 다음 프레임 요청
if (progress < 1) {
requestAnimationFrame(animationStep)
}
}
// 첫 프레임 요청
requestAnimationFrame(animationStep)
}
// easing 함수는 이전과 동일
// function easeOutCubic(t, b, c, d) { ... }
// updateChartBars 함수는 차트의 막대(bar)를 실제로 그리는 함수라고 가정합니다.
// (예: function updateChartBars(element, values) { /* ... */ })
requestAnimationFrame 콜백 함수는 현재 시간(currentTime)을 인자로 받습니다. 이걸 사용해서 애니메이션 시작 시간부터 얼마나 지났는지 정확히 계산하고, 그에 맞춰 진행 상태(progress)를 업데이트합니다. 그리고 애니메이션이 끝나지 않았으면 스스로 다음 프레임에 animationStep 함수를 다시 호출하도록 예약하죠. 이렇게 하면 브라우저 렌더링 주기에 딱 맞춰 필요한 만큼만 DOM을 업데이트하게 됩니다.
한 걸음 더: 레이아웃 스래싱(Layout Thrashing) 피하기
requestAnimationFrame을 쓴다고 모든 성능 문제가 마법처럼 해결되는 건 아니에요. 특히 복잡한 애니메이션을 다룰 때는 '레이아웃 스래싱'이라는 골치 아픈 문제를 만날 수 있습니다.
간단히 말하면, 한 프레임 안에서 DOM의 크기나 위치를 읽는 작업(예: offsetHeight, getBoundingClientRect())과 쓰는 작업(예: element.style.width = '100px')을 마구 섞어서 하면 브라우저가 정신을 못 차리는 현상입니다. 스타일을 변경하면 브라우저는 레이아웃을 다시 계산해야 하는데, 그 계산이 끝나기도 전에 또 다른 스타일 변경을 요청하거나 값을 읽으려고 하면 불필요한 계산이 반복되면서 성능이 뚝 떨어지죠.
이걸 피하려면 읽기 작업은 모아서 한 번에, 쓰기 작업도 모아서 한 번에 처리하는 게 좋습니다. requestAnimationFrame 콜백 안에서도 이 원칙을 지키면 좋아요.
// 읽기/쓰기 작업을 분리해서 레이아웃 스래싱을 방지하는 예시
class AnimationOptimizer {
constructor() {
this.readTasks = []
this.writeTasks = []
this.scheduled = false
}
// DOM 읽기 작업을 예약합니다.
read(readFn) {
this.readTasks.push(readFn)
this.scheduleProcessing()
return this
}
// DOM 쓰기 작업을 예약합니다.
write(writeFn) {
this.writeTasks.push(writeFn)
this.scheduleProcessing()
return this
}
// 실제 작업을 requestAnimationFrame으로 스케줄링합니다.
scheduleProcessing() {
if (!this.scheduled) {
this.scheduled = true
// bind(this)를 통해 processTaskQueue 내부의 this가 AnimationOptimizer 인스턴스를 가리키도록 합니다.
requestAnimationFrame(this.processTaskQueue.bind(this))
}
}
processTaskQueue() {
// 1. 모든 읽기 작업을 먼저 실행합니다.
// 이 단계에서 DOM 변경을 유발하는 작업은 피해야 합니다.
const readResults = this.readTasks.map((readFn) => readFn())
// 2. 그 다음 모든 쓰기 작업을 실행합니다. (필요하다면 읽기 결과를 활용)
// 이 단계에서 DOM의 값을 읽는 작업은 피해야 합니다.
this.writeTasks.forEach((writeFn, i) => writeFn(readResults[i]))
// 작업 큐를 비우고 다음 프레임을 준비합니다.
this.readTasks = []
this.writeTasks = []
this.scheduled = false
}
}
// 사용 예
const animator = new AnimationOptimizer()
window.addEventListener('scroll', () => {
animator
.read(() => window.scrollY) // 스크롤 위치 읽기
.write((scrollY) => updateParallaxElements(scrollY)) // 읽은 값으로 DOM 업데이트
})
// updateParallaxElements 함수는 스크롤 위치에 따라 여러 요소의 스타일을 변경하는 함수라고 가정합니다.
// (예: function updateParallaxElements(scrollY) { /* ...여러 요소 스타일 변경... */ })
위 AnimationOptimizer 클래스는 간단한 예시지만, 핵심 아이디어는 read 작업과 write 작업을 분리해서 관리하고, requestAnimationFrame 콜백 안에서 read를 먼저 모두 실행한 후 write를 실행하는 것입니다. 이렇게 하면 한 프레임 내에서 불필요한 레이아웃 계산 반복을 줄일 수 있습니다.
requestAnimationFrame 쓸 때 기억할 것들
requestAnimationFrame은 확실히 웹 애니메이션과 인터랙션 성능을 개선하는 데 아주 유용한 도구입니다. 브라우저의 렌더링 흐름에 자연스럽게 올라타서, 사용자에게 부드러움을, 개발자에게는 코드의 효율성을 선물하죠.
실무에서 requestAnimationFrame을 사용할 때는 몇 가지 점을 더 고려하면 좋습니다.
- 재귀 호출과 취소: 애니메이션 루프를 만들 때
requestAnimationFrame을 재귀적으로 호출하게 되는데, 애니메이션이 더 이상 필요 없을 때는cancelAnimationFrame(animationFrameId)을 사용해서 꼭 취소해줘야 합니다. 그렇지 않으면 보이지 않는 곳에서 계속 리소스를 잡아먹을 수 있어요.requestAnimationFrame은 호출 시 ID를 반환하므로, 이 ID를 잘 저장해두었다가 필요할 때 사용하세요. - 시간 기반 애니메이션: 프레임 기반이 아닌 시간 기반으로 애니메이션을 제어하는 것이 좋습니다 (위 차트 예제처럼
performance.now()나 콜백의currentTime인자 활용). 이렇게 하면 다양한 주사율을 가진 환경에서도 일관된 속도로 애니메이션을 보여줄 수 있습니다. - 디버깅: 브라우저 개발자 도구의 'Performance' 탭이나 (브라우저에 따라) 'Animations' 탭을 활용하면
requestAnimationFrame의 작동 방식이나 성능 병목 지점을 파악하는 데 도움이 됩니다. 프레임 드랍 여부 등을 확인할 수 있죠. - 복잡도 관리: 애니메이션 로직이 너무 복잡해지면
AnimationOptimizer예시처럼 작업을 분리하거나, 상태 관리 라이브러리(예: React, Vue의 경우 내부적으로 최적화된 애니메이션 처리 방식을 제공하기도 합니다) 등과 함께 사용하는 것도 고려해볼 수 있습니다. 때로는 CSS 애니메이션이나 트랜지션으로 충분한 경우도 있으니, 상황에 맞는 기술을 선택하는 것이 중요합니다.
결국 requestAnimationFrame은 브라우저와 '대화'하며 화면을 그려나가는 방법 중 하나입니다. 이 친구와 친해지면 사용자에게 훨씬 쾌적한 웹 경험을 선사할 수 있을 거예요. 복잡한 인터랙션이나 애니메이션 구현을 앞두고 있다면, requestAnimationFrame을 꼭 한번 써보시길 추천드립니다!