웹 애니메이션 최적화

Posted: Updated:
#Web#Rendering#Canvas
실제 작업 내용 보러가기

동아리 홈페이지 TF 팀은 Vercel에서 개발한 v0를 활용해 초안 UI를 잡고, 그 위에 실제 데이터를 입히는 방식으로 프로젝트를 진행했습니다.

문제 분석

데이터를 입히는 과정에서 React와 Framer Motion으로 구현된 히어로 섹션의 애니메이션 성능 문제가 발견되었습니다. React Developer Tools과 크롬의 성능 모니터 기능을 통해 확인한 결과, 배경의 별 입자들이 매번 불필요하게 재렌더링되고 있었습니다.

원인을 파악하기 위해 크롬 디버그 콘솔과 코드를 대조해 본 결과, useState를 이용해 수많은 별을 각각의 <div> 태그로 생성하는 방식이 근본적인 문제였습니다. 구체적으로는 다음 3가지 문제를 발견했습니다.

  • DOM 부하: 별 하나당 하나의 <div>를 생성하고 Framer Motion으로 제어하다 보니, 상태 변화 시마다 React는 리렌더링을 시도하고 Framer Motion은 직접 DOM 속성을 변경해야 했습니다.
  • 높은 CPU 점유율: setInterval을 통해 30ms마다 상태를 업데이트하며 초당 약 33번의 리렌더링이 발생했습니다.
  • 메모리 누수: setTimeout으로 별을 제거하고 있었으나, 컴포넌트 언마운트 시 타이머가 적절히 해제되지 않는 구조였습니다.

// [기존 코드] 문제의 원인이 된 React + Framer Motion 방식
export function HeroSection() {
  const [stars, setStars] = useState<Star[]>([])
  const nextIdRef = useRef(0)

  const removeStar = useCallback((id: number) => {
    setStars((stars) => stars.filter((star) => star.id !== id))
  }, [])

  const addStar = useCallback(() => {
    const newId = nextIdRef.current++
    const star = createStar(newId)
    setStars((stars) => [...stars, star])
    setTimeout(() => removeStar(newId), star.duration * 1000)
  }, [removeStar])

  useEffect(() => {
    const interval = setInterval(() => {
      addStar()
    }, 30)
    return () => clearInterval(interval)
  }, [addStar])
  
  return (
    <section>
      <AnimatePresence>
        {stars.map((star) => (
          <motion.div
            key={star.id}
            className="absolute w-1 h-1 bg-accent/50 rounded-full"
            style={{ left: `${star.left}%`, top: `${star.top}%` }}
            initial={{ opacity: 0, scale: 0 }}
            animate={{ opacity: 1, scale: 1 }}
            exit={{ opacity: 0, scale: 0 }}
          />
        ))}
      </AnimatePresence>
    </section>
  )
}

문제 해결

이 문제를 해결하기 위해 React 상태가 DOM을 직접 생성하는 방식이 아닌, Canvas API를 이용해 픽셀을 직접 그리는 방식으로 수정하는 것이 가장 적절하다고 판단했습니다. 또한, 단순히 데이터를 그리는 것에 그치지 않고, requestAnimationFrame을 통해 클라이언트의 주사율에 맞춰 최적의 타이밍에 Canvas에서 독자적으로 렌더링하도록 개선했습니다.

// [개선 코드] Canvas API와 requestAnimationFrame 적용
export function HeroSection() {
  const canvasRef = useRef<HTMLCanvasElement | null>(null)

  useEffect(() => {
    const canvas = canvasRef.current
    if (!canvas) return
    const ctx = canvas.getContext('2d')
    if (!ctx) return
    
    let animationFrameId: number
    let stars: Star[] = []
    let lastTime = 0 
    
    const animate = (timestamp: number) => {
      if (!lastTime) lastTime = timestamp
      const deltaTime = (timestamp - lastTime) / 1000
      lastTime = timestamp

      ctx.clearRect(0, 0, canvas.width, canvas.height)
      // deltaTime 기반으로 별 생성 및 렌더링 로직 (생략)
      animationFrameId = requestAnimationFrame(animate)
    }
    
    animationFrameId = requestAnimationFrame(animate)
    return () => cancelAnimationFrame(animationFrameId)
  }, [])
  
  return (
    <section>
      <canvas ref={canvasRef} className="absolute inset-0 w-full h-full" />
    </section>
  )
}

이 수정을 통해 기존 문제를 아래와 같이 해결할 수 있었습니다.

  • 구조 변경: 수많은 <div> 대신 단 하나의 <canvas>만 사용하여 픽셀 데이터만 출력.
  • 주사율 동기화: useState 대신 requestAnimationFrame을 사용해 각 환경에 맞는 적절한 프레임 보장.
  • 렌더링 최소화: 가변 데이터(좌표, 수명 등)를 React State가 아닌 내부 변수(stars 배열)와 useRef로 관리.
  • 안정적인 순환: Math.sin으로 부드러운 투명도 변화를 구현하고, 프레임 단위 수명 체크로 메모리 누수 방지.

리뷰를 통한 고도화

구현 후 팀원들과 Gemini Code Assist의 리뷰를 거치며 미처 고려하지 못한 문제들을 발견하고 해결할 수 있었습니다.

1. 고해상도 디스플레이 대응 (Retina 문제)

Gemini는 고해상도 환경에서 Canvas가 흐릿하게 보일 수 있음을 지적했습니다. 실제 4K 모니터(dpr 1.75)에서 확인해보니 미세한 번짐이 발견되었습니다. 이를 해결하기 위해 devicePixelRatioCanvasRenderingContext2D.scale 함수를 이용해 해상도를 보정했습니다.

const resizeCanvas = () => {
  const dpr = window.devicePixelRatio || 1 
  const width = window.innerWidth
  const height = window.innerHeight

  canvas.width = width * dpr
  canvas.height = height * dpr
  canvas.style.width = `${width}px`
  canvas.style.height = `${height}px`

  ctx.scale(dpr, dpr)
}

실제로 코드를 적용한 뒤에는 4K 환경에서도 번짐 없는 선명한 별을 확인할 수 있었습니다.

(좌) 변경 전 4K 환경
(좌) 변경 전 4K 환경
(우) 변경 후 4K 환경
(우) 변경 후 4K 환경

2. 구현 로직의 논리 오류 수정

팀원분의 리뷰를 통해 죽은 별을 대체하는 로직에서 배열의 길이가 최대값(MAX_STARS)에 도달하면 새로운 별이 생성되지 않을 가능성을 발견했습니다. 이를 방지하기 위해 조건을 수정하여 안정적인 순환 구조를 만들었습니다.

if (Math.random() < STAR_CREATION_RATE * deltaTime) {
  const newStar = createStar(window.innerWidth, window.innerHeight)
  const deadStar = stars.find((s) => s.life >= s.maxLife)
  if (deadStar) { // 죽은 별이 있다면 교체
    Object.assign(deadStar, newStar)
  } else if (stars.length < MAX_STARS) { // 없다면 새로 추가
    stars.push(newStar)
  }
}

3. 성능 최적화: O(1) 탐색과 인덱스 관리

매 프레임마다 죽은 별을 찾는 find() 함수는 O(N) 연산이 필요합니다. 이를 최적화하기 위해 O(1) 로 해결할 수 있는 방법을 고민하다 두 가지 방안을 생각해볼 수 있었습니다.

구분방안 1: Swap & Pop (배열 편집)방안 2: Index Tracking (객체 재사용)
방법죽은 별을 배열 끝으로 보낸 뒤 pop()으로 제거죽은 별의 인덱스를 별도 배열에 기록 후 새 별 생성 시 덮어쓰기
장점살아있는 별만 배열에 남으므로 관리가 직관적임GC 발생을 최소화하며 CPU 작업량을 줄일 수 있음
단점잦은 객체 생성/삭제로 가비지 컬렉터(GC) 부하 발생최대 개수만큼의 객체가 페이지 이동 전까지 메모리를 상주함

1번 방식은 코드가 직관적이지만, 별의 생명 주기가 짧을 경우 초당 수십 개의 객체가 파괴되고 생성됩니다. 이는 저사양 기기나 모바일 환경에서 GC Thrashing(잦은 GC로 인한 성능 저하)을 유발할 수 있습니다.

반면 2번 방식은 메모리를 고정적으로 점유하지만, 한 번 생성된 객체를 Object.assign으로 계속 재활용하기 때문에 런타임 성능이 훨씬 안정적입니다. 모바일 환경까지 고려했을 때 메모리를 조금 더 사용하더라도 CPU 오버헤드를 줄이는 것이 더 효과적이라 판단하여 이 방식을 채택했습니다.

// [수정된 로직] 죽은 별의 인덱스를 관리하여 O(1)로 접근 및 재사용
let deadIndices: number[] = [] // 수명이 다한 별의 위치를 저장하는 스택

// ... animate 루프 내부 ...

if (Math.random() < STAR_CREATION_RATE * deltaTime) {
  const newStar = createStar(window.innerWidth, window.innerHeight)

  // 1. find() 대신 deadIndices 스택에서 인덱스를 바로 꺼내옴 (O(1))
  if (deadIndices.length > 0) {
    const targetIndex = deadIndices.pop()!
    // 2. 기존 객체에 새로운 데이터를 덮어씌워 메모리 할당 방지
    Object.assign(stars[targetIndex], { ...newStar, isDead: false })
  } else if (stars.length < MAX_STARS) {
    stars.push(newStar)
  }
}

// ... 별 렌더링 루프 ...
for (let i = stars.length - 1; i >= 0; i--) {
  const star = stars[i]
  if (star.isDead) continue;

  star.life += deltaTime
  
  if (star.life >= star.maxLife) {
    star.isDead = true
    deadIndices.push(i) // 수명이 다한 별의 인덱스 기록
    continue
  }
  // 그리기 로직...
}

4. 불필요한 스크롤 기능 및 이벤트 제거

AI가 제안한 초기 코드에 포함된 useState 기반 스크롤 추적 기능이 성능 저하를 일으키고 있었습니다. 비즈니스 요구사항을 재검토한 결과, 해당 기능이 오히려 로고와 텍스트를 겹치게 만드는 문제를 확인하여 과감히 삭제했습니다.

5. 백그라운드 렌더링 차단

팀원의 리뷰를 통해 페이지 하단으로 스크롤하여 히어로 섹션이 보이지 않는 순간에도 캔버스가 계속 그려지는 문제를 확인하였고, 실제 문제를 파악하기 위해 디버그 도구를 활용하였습니다.

크롬 페인트 플래시 기능을 통해 확인했을 때, 화면 밖에서는 픽셀이 그려지지 않아 겉보기에는 안전해 보였습니다. 하지만 디버그 도구로 확인해 보니 화면 영역 밖임에도 requestAnimationFrame 루프는 멈추지 않고 계속해서 별의 좌표를 계산하고 있었습니다.

즉, 브라우저가 시각적 렌더링 비용은 아꼈지만, CPU를 사용하는 메인 스레드의 연산 리소스는 여전히 낭비되고 있었습니다. 이를 해결하기 위해 Intersection Observer를 도입하여 실제 캔버스가 화면에 보일 때만 애니메이션 함수가 실행되도록 수정했습니다.

const observer = new IntersectionObserver(
  ([entry]) => {
    if (entry.isIntersecting) {
      startAnimation() // 화면에 보일 때만 실행
    } else {
      stopAnimation() // 보이지 않으면 중단
    }
  },
  {
	    threshold: 0, // 1 픽셀이라도 보이면 즉시 실행
	    rootMargin: '200px', // 스크롤 속도를 고려한 여유 공간
	  }
	)
observer.observe(canvas)

해당 코드를 적용한 결과, 화면에 보이는 경우에만 렌더링 함수가 호출되고 화면에 보이지 않는 경우 연산 자체가 완전히 멈추는 것을 확인할 수 있었습니다.

(도입 전) 스크롤을 내려도 animate 함수가 계속 실행되고 있음을 확인할 수 있음
(도입 전) 스크롤을 내려도 animate 함수가 계속 실행되고 있음을 확인할 수 있음
(도입 후) 스크롤을 내린 상황에서 animate 함수 실행이 멈춘 것을 확인할 수 있음
(도입 후) 스크롤을 내린 상황에서 animate 함수 실행이 멈춘 것을 확인할 수 있음

결과

이번 최적화 과정을 통해 단순히 작동하는 코드에서 성능을 고려한 코드로 발전시킬 수 있었습니다.

  • 수백 개의 별 입자를 다루면서도 React의 리렌더링 횟수를 0회로 줄였습니다. 또한, useState와 DOM 재생성을 제거하고 Canvas를 도입하여 자원을 효율적으로 관리하게 되었습니다.
  • O(N)이었던 별 탐색 로직을 O(1) 인덱스 관리 방식으로 개선하여, 불필요한 CPU 점유율 상승을 억제했습니다.
  • IntersectionObserver를 도입하여 사용자가 보지 않는 화면에서의 연산을 중단시켰습니다. 이를 통해 브라우저의 메인 스레드 부하를 낮출 수 있었습니다.
  • devicePixelRatio 보정을 통해 Retina 및 4K 모니터에서도 번짐 없는 선명한 그래픽을 제공할 수 있게 되었습니다.

마치며

처음에는 AI가 제안한 코드를 그대로 사용하는 데 그칠 뻔했지만, 팀원들과의 코드 리뷰와 디버깅 도구를 활용한 검증을 통해 문제를 찾아낼 수 있었습니다.

기술적인 해결책을 찾는 과정도 즐거웠지만, "화면 밖에서도 함수가 실행되고 있는가?"와 같은 세밀한 부분까지 고민해야 한다는 지점을 알 수 있었습니다. 브라우저의 동작 원리를 고민하여 웹 페이지가 너무 많은 리소스를 사용하고 있는 것은 아닌지 고민하는 경험을 해볼 수 있었습니다.

최종적으로 수정된 결과는 동아리 홈페이지 히어로 섹션에서 직접 확인하실 수 있습니다!

Comments (0)

댓글을 불러오는 중...