이미지 시퀀스 기반 스크롤 인터렉션 구현하기

하늘·2026년 3월 17일

투명 배경의 이미지 300장 이상을 스크롤할 때마다 바꾸어야 하는 이미지 시퀀스 기반 스크롤 인터렉션을 작업한 적이 있다. 당시에는 position: sticky 를 건 DOM Element의 height 값만큼 progress를 계산해서 img src 만 바꿔 주는 작업을 하려고 했으나, 투명 배경을 사용해야 했기 때문에 png 확장자로 테스트를 해 봤다. 하지만 이미지 용량이 총 368MB로 무거워, 버벅거림과 스크롤을 다 내리기 전에 브라우저 환경을 가리지 않고 사이트가 다운되었다.

첫 번째 해결 방법으로는 WebP 확장자로 교체해 용량 문제는 약 90% 줄였지만 (368MB → 32MB) 투명 배경이 아닌 검은색 배경으로 깜빡거리는 문제가 있었다. 결국 img src 교체 방식을 버리고 drawImage() 라는 Canvas API를 사용하여 이미지 용량 문제와 알파 채널 보존도 같이 챙겨 일단락된 것으로 보고 프로덕션 페이지에 배포했다.

당시에는 개발 기간도 짧았고, 프로젝트 사정상 일정을 더 늘릴 수도 없기 때문에 조금 더 고민할 겨를도 없었다. 하지만 다시 생각해 보면 drawImage 가 최선이었을까? 라는 생각이 든다.

왜 Canvas를 사용했을까?

현재 이미지 사라짐 (src 교체)
↓
새 이미지 로드  (빈 프레임)
↓
브라우저가 빈 영역을 검정색으로 채움 (여기에서 깜빡임)
↓
새 이미지 표시

progress 값에 따라서 img src 값을 바꾸면 이전 이미지가 사라지고 새 이미지가 로드되기 전까지의 공백이 생긴다. 스크롤 속도가 빠를 수록 이 공백이 자주 노출되어 검은 배경이 깜빡거리는 것처럼 보인다.

이 문제를 Canvas에서 해결이 가능하다.

const canvas = document.querySelector("canvas")
const ctx = canvas.getContext("2d")

...

function render (index) {
	ctx.clearRect(0, 0, canvas.width, canvas.height)
	
	...
	
	ctx.drawImage(image, 0, 0, width, height)
}

먼저 clearRect 로 지정된 영역을 투명하게 초기화하고, drawImage 를 이용하여 또 지정된 영역에 이미지를 그려 주면 된다.

이전 프레임 픽셀들
[🟥🟥🟥🟥🟥]
      ↓ clearRect
[⬜⬜⬜⬜⬜]  // 투명
      ↓ drawImage
[🖼️🖼️🖼️🖼️🖼️] // 새 프레임
      ↓ clearRect
[⬜⬜⬜⬜⬜]  // 투명
      ↓ drawImage
[🖼️🖼️🖼️🖼️🖼️] // 새 프레임

clearRect 로 이전 프레임을 지우고 새 프레임을 drawImage 로 그려주는 게 동기적으로 실행되어 img src 의 브라우저 이미지 로드 타이밍(검정색 배경 깜빡거림)과 알파 채널 보존을 동시에 챙길 수 있었다.

new Image() 객체 → drawImage 의 문제점

다시 당시에 작성했던 코드를 가지고 와서 보면,

const canvas = document.querySelector("canvas")
const ctx = canvas.getContext("2d")

...

const render = (index) => {
	const winW = ...
	const winH = ... // window width, height값 
	
	ctx.clearRect( ... )
	
	...
	
	ctx.drawImage(img, ...)
}

window.addEventListener("scroll", event => {
	...
	
	render(progress)
})

이미지 시퀸스 기반 스크롤 애니메이션을 보여 줄 Key Visual DOM의 height와 스크롤값을 계산해, progress를 render 함수에 넘겨 주고, render 함수에서는 rect을 초기화한 다음 drawImage 를 실행시킨다. 문제점은 이 render 함수가 스크롤 이벤트 안에 있다는 것이다.

img.src = '/frame1.webp'

브라우저에서 WebP 파일 다운로드
↓
WebP 압축 파일 해제하고 RGBA 픽셀 데이터로 디코딩 (CPU에서)
↓
그 픽셀 데이터를 CPU 메모리(RAM)에 보관
↓
ctx.drawImage(img, 0, 0) <- 이 순간
↓
RAM에 있는 픽셀 데이터를 GPU 메모리(VRAM)로 복사 <- 매 프레임마다 비용 발생
↓
GPU가 화면에 렌더링

new Image() 로 만든 이미지를 drawImage() 에 넣으면 이미지가 바뀔 때마다 매 프레임 픽셀 계산을 다시 한다. 또 Image 객체 디코딩이 메인 스레드에서 일어나 버벅임을 발생시킬 수 있다.

그러면 매 프레임마다 비용을 발생시키는 VRAM으로 복사하는 과정을 미리 할 수는 없을까?

createImageBitmap으로 이미지를 먼저 VRAM에 복사해 두기

const res = await fetch('/frame1.webp')
const blob = await res.blob() // 파일의 이진 데이터 덩어리
const bitmap = await createImageBitmap(blob) 

브라우저에서 WebP 파일 다운로드
↓
WebP 압축 파일 해제하고 RGBA 픽셀 데이터로 디코딩 (CPU에서)
↓
그 픽셀 데이터를 CPU 메모리(RAM)에 보관하고 GPU 업로드 준비가 완료된 ImageBitmap 객체로 보관
↓
ctx.drawImage(img, 0, 0) <- 이 순간
↓
VRAM에 있던 픽셀 데이터를 꺼내서 바로 화면에 렌더링

Image 객체 대신 createImageBitmap으로 만든 ImageBitmapdrawImage에 넘기면
디코딩이 백그라운드 스레드에서 사전에 완료되고, drawImage 시점엔 렌더링만 하면 된다.

// 사전 디코딩
const bitmaps = await Promise.all(
  frames.map(async (url) => {
    const res = await fetch(url)
    const blob = await res.blob()
    return createImageBitmap(blob)
  })
)

// render는 그리기만 하기
const render = (index) => {
  ctx.clearRect(0, 0, canvas.width, canvas.height)
  ctx.drawImage(bitmaps[index], 0, 0)
}
profile
아무튼 어찌저찌 하고 있습니다.... 🫠

0개의 댓글