mason
mason-log.

GitHub 로그인 중

스프라이트 애니메이션 패키지, mason-sprite.
mason

masonfe-hyunsu

🎞️ 1. 개요 : 프레임을 직접 제어하는 패키지.

  • mason-sprite는 PNG, WebP 스프라이트 시트를 프레임 애니메이션으로 재생하는 npm 패키지다.

  • React, Vue, Svelte 컴포넌트와 프레임워크 없이 사용하는 코어 API를 함께 제공한다.

  • 처음에는 rows, cols, fps만 받는 재생기로 시작했지만 실제 소개 페이지에 적용하며 구간 재생, 반응형 크기, 이미지 로딩 상태까지 다루게 됐다.

  • 이 포스팅에서는 기능 목록보다 왜 구조와 API가 바뀌었는지, 첫 방문에서만 발생한 버그를 어떻게 고쳤는지 중심으로 정리해 본다.

mason-sprite Sprite Sheet Animation

🧱 2. 하나의 코어를 세 프레임워크에서 사용하기.

  • 프레임워크마다 재생 로직을 구현하면 같은 버그를 React, Vue, Svelte에서 각각 고쳐야 한다.

  • 그래서 프레임 계산과 상태를 SpriteAnimator 공통 코어에 두고, 각 컴포넌트는 생명주기와 ref만 연결하는 얇은 어댑터로 만들었다.

mason-sprite 공통 코어와 프레임워크 어댑터 구조
import { SpriteAnimator } from 'mason-sprite';
import { MasonSprite } from 'mason-sprite/react';
import { MasonSprite } from 'mason-sprite/vue';
import { MasonSprite } from 'mason-sprite/svelte';
  • 애니메이션 타이밍은 requestAnimationFrame으로 관리한다.

  • 코어에서 로딩, 구간 완료, 역재생을 수정하면 모든 프레임워크가 같은 동작을 공유한다.

  • npm export는 코어와 프레임워크별 subpath로 나눠 사용자가 내부 빌드 방식을 알 필요가 없게 했다.

공통 동작은 코어에 모으고, 프레임워크 코드는 연결 역할만 맡기는 것이 유지보수에 효과적이었다.


📦 3. 패키지 구조와 반응형 크기 정리하기.

  • 초기 저장소에는 패키지와 데모 사이트, Vercel 설정이 한 모노레포에 섞여 있었다.

  • 단일 패키지만 남은 뒤에는 불필요한 workspace 계층을 제거하고 다음 항목을 정리했다.

    • ESM, CJS와 타입 선언 export

    • React, Vue, Svelte의 optional peer dependency

    • dist, README.md, LICENSE 중심의 배포 파일

    • prepublishOnly 빌드와 repository, homepage, license 메타데이터

  • 공개 API도 흔한 Sprite 대신 MasonSprite, useMasonSprite로 바꿔 import 충돌과 검색 혼선을 줄였다.

초기 width, height는 숫자만 받아 픽셀로 해석했다. 실제 UI에서는 rem, %, vw가 필요해 number 또는 CSS length를 받도록 확장했다.

export type SpriteSize = number | string;

export function toCssLength(size: SpriteSize): string {
  return typeof size === 'number' ? `${size}px` : size;
}
  • CSS 렌더러는 변환한 길이를 바로 사용한다.

  • Canvas는 표시 크기에 devicePixelRatio를 반영하고 ResizeObserver로 크기 변경을 감지한다.

  • 단순한 prop 타입 변경처럼 보였지만 코어, 두 렌더러, 세 프레임워크의 크기 기준을 함께 맞춰야 했다.


⚖️ 4. Animated WebP와 사용 조건 구분하기.

  • 단순 반복만 필요하면 브라우저가 직접 재생하는 animated WebP <img>가 더 간단할 수 있다.

  • 반대로 재생 정지, 특정 프레임 이동, 구간 전환이 필요하면 mason-sprite의 제어 API가 적합하다.

단순 loop
→ animated WebP <img>

play / pause / goToFrame / segment
→ mason-sprite

첫 이미지는 10프레임을 담은 2×5 원본 시트고, 두 번째 이미지는 이를 순서대로 재생한 결과다.

10프레임으로 구성된 고양이 스프라이트 시트고양이 스프라이트 시트 재생 결과
  • 소개 사이트의 이 샘플에서는 animated WebP가 110KB, 스프라이트용 WebP가 49KB였다.

  • 다만 이는 한 샘플의 결과이므로 스프라이트가 항상 더 작거나 빠르다고 일반화하지 않았다.

  • Lottie는 벡터 모션과 복잡한 타임라인에 강하고, mason-sprite는 이미 만들어진 raster sprite sheet를 직접 제어하는 데 집중한다.


🎮 5. 구간과 상태를 제어하는 API.

  • 패키지의 쓰임을 프레임 제어로 정한 뒤 핵심 API를 세 가지로 좁혔다.

    • 구간 재생: playSegment로 시트의 일부 프레임만 재생한다.

    • 이름 기반 맵: idle, walk, attack 같은 이름으로 구간을 선택한다.

    • 역재생: reverse에 따라 시작점, 반복 경계와 완료 지점을 반대로 계산한다.

const animator = new SpriteAnimator({
  src: '/hero.webp',
  rows: 2,
  cols: 6,
  animations: {
    walk: { start: 0, end: 5, loop: true },
    attack: { start: 6, end: 11 },
  },
});

animator.playAnimation('walk');
animator.playAnimation('attack');
  • 한 시트에 여러 동작을 넣으면 이미지를 다시 요청하지 않고 앱 상태에 맞춰 구간만 바꿀 수 있다.

  • playback rate, 한 프레임 이동, 화면 밖 자동 pause는 당장 필요한 핵심이 아니어서 미뤘다.

  • 기능 수를 늘리기보다 상태 전환에 필요한 API를 먼저 안정화하는 편을 선택했다.


🐛 6. 첫 방문에서만 애니메이션이 사라진 이유.

  • 실제 소개 페이지에서 새로고침하면 보이지만 캐시가 없는 첫 방문에는 autoPlay가 보이지 않는 문제가 발생했다.

  • 원인은 이미지가 준비되기 전에 play()가 실행되는 로드 경쟁 상태였다.

이미지 로드 전 play()
→ isPlaying = true
→ render()는 이미지가 없어 종료
→ onload에서 play()
→ 이미 재생 중으로 판단해 요청 무시

상태값은 재생 중이지만 첫 프레임도 그릴 수 없는 상태였다. 해결 기준을 로드되지 않은 애니메이션은 재생 중일 수 없다로 정했다.

이미지 로딩 상태와 재생 시작 순서
  • isLoaded === false이면 재생 루프를 시작하지 않는다.

  • onload에서 0번 프레임을 먼저 그린 뒤 autoPlay를 시작한다.

  • src가 빠르게 바뀌면 load token으로 이전 요청의 응답을 무시한다.

  • 로드 전에는 frame callback과 complete callback을 실행하지 않는다.

  • onLoad, onError를 공개해 사용자가 로딩 결과를 받을 수 있게 했다.

상태 변수가 존재하는 것보다 모든 상태 전이가 loading → loaded → playing 순서를 지키는 것이 중요했다.


🖼️ 7. 렌더러와 테스트 함께 다듬기.

  • CSS 렌더러는 background-position 대신 한 프레임 크기의 viewport 안에서 전체 이미지를 translate3d로 이동하는 구조로 바꿨다.

  • 매 프레임 background를 다시 설정하지 않고 transform만 갱신하는 것이 구체적인 목표였다.

  • transform도 레이어와 메모리 비용이 있어 모든 환경에서 무조건 빠르다고 설명하지는 않았다.

  • Canvas 렌더러는 drawImage와 bitmap 접근이 필요해 기본 crossOrigin'anonymous'로 두고, CSS <img>에는 기본값을 강제하지 않았다.

첫 방문 버그를 고치면서 Vitest와 happy-dom을 도입했다. 테스트는 다음 회귀에 집중했다.

  • 이미지 load 전에는 재생과 callback이 시작되지 않는가

  • 캐시된 이미지와 느린 이미지가 모두 0번 프레임부터 시작하는가

  • 빠른 src 변경에서 마지막 요청만 반영되는가

  • 구간과 역재생의 시작점, 반복 경계, 완료 시점이 맞는가

  • CSS renderer가 프레임마다 transform만 변경하는가

React 훅에서는 이전 render의 callback을 계속 호출하는 stale closure도 발견했다. callback ref를 사용해 animator를 재생성하지 않고 최신 함수를 호출하도록 수정했다.

pnpm test
pnpm typecheck
pnpm build

✅ 8. 정리.

  • 사용 조건: 단순 loop라면 native animated image가 더 적합할 수 있다.

  • 비동기 상태: 리소스 준비 여부와 재생 상태를 하나의 흐름으로 관리한다.

  • 공통 코어: 프레임워크별 코드는 얇게 두고 같은 동작을 공유한다.

  • 성능 설명: “항상 빠르다”보다 무엇을 덜 갱신하는지 구체적으로 적는다.

mason-sprite의 핵심은 이미지를 움직이는 일이 아니라 스프라이트 프레임을 앱 상태와 연결하는 일이다.

참고.

이전 글
하네스 엔지니어링.

댓글

불러오는 중...
목록으로