자바스크립트로 구현하는 웹 애니메이션

2025. 3. 4.

자바스크립트로 구현하는 웹 애니메이션
image by Firmbee

갈수록 세분화되고 다양화되면서 복잡도가 높아지고 있는 FE 생태계입니다. 새삼스럽죠. 저는 가급적이면 뭐 하나라도 디펜던시를 줄이려는 편입니다. 가급적 표준안에서 변화하지 않는 영역을 더 늘리려고 하죠. 자바스크립트 애니메이션도 그렇습니다. 공개된 오픈소스들을 이용하면 DOM상에서 복잡하고 나이스 한 움직임을 만들어낼 수 있습니다. 하지만 이렇게까지 애니메이션의 비중이 큰 웹 프로젝트는 드문 편입니다. 간단한 애니메이션을 구현하기 위해 또 다른 디펜던시를 늘리는 것은 꽤나 부담스러운 일이고요. 한두 번 사용하기 위해 디펜던시를 늘리는 것은 용량이나 학습비용 뿐 아니라 다양한 부분에서 비효율적입니다.

별도의 도구 없이 웹 애니메이션을 이용해서 애니메이션을 구현하고 컨트롤하는 것은 꽤 쉬운 편입니다. 아직 Working Draft 스펙 이긴 하지만 2022년부터 모든 모던 브라우저들이 대부분의 스펙을 지원하고 있습니다. 꽤 안정적인 스펙이기 때문에 이대로 Proposed Recommendation까지 도달할 것 같습니다.

큰 줄기를 설명드리면 Element.animate()을 이용해 CSS 키프레임으로 애니메이션을 정의하고 이것을 통해 얻은 Animation 객체로 애니메이션을 컨트롤할 수 있습니다. 모든 모던 브라우저가 지원합니다.

이제 같이 살펴보겠습니다.

키프레임 애니메이션 구현하기

자바스크립트 인터페이스를 사용하지만 움직임 자체를 구현하는 것은 CSS 키프레임을 이용합니다. CSS 문법을 활용하는 것이죠.

element.animate(keyframe, options);

엘리먼트에는 animate라를 메서드가 있습니다. 언제부터인지 모르겠지만 있네요 :). 간단하게 이미지 하나를 뱅글뱅글 돌리는 애니메이션을 구현하면 아래의 코드로 구현할 수 있습니다. 여기서 엘리먼트 image<img> 거나 이미지를 배경으로 갖고 있는 엘리먼트라고 가정할게요.

const anim = image.animate(
	[{ transform: 'rotate(0)' }, { transform: 'rotate(360deg)' }], // (1)
	{ 
		duration: 1000,
		iterations: Infinity,
	} // (2)
); 

anim.pause(); // (3), 멈춤

매개변수 keyframe(1)은 배열로 CSS 속성정의 합니다. 배열의 요소가 두 개 있다면 fromto 형식으로 첫 번째 요소 즉 from CSS 속성에서 두번째  to 요소로 애니메이션을 구현할 수 있습니다. 여기서는 rotate를 활용해서 한 바퀴 돌렸죠.

두 번째 매개변수 options (2)는 키프레임으로 정의된 애니메이션을 어떻게 플레이할지에 대한 정보를 전달 할 수 있습니다. duration은 키프레임이 온전히 플레이되는 시간으로 여기서는 1000 밀리 세컨드 즉 1초로 줬고요. 이러면 1초에 한 바퀴씩 돌겠죠. iterations는  애니메이션의 반복 타입으로 infinity 즉 무한으로 반복하도록 했습니다.

옵션은 KeyframeEffect() 여기서 더 자세히 확인 할 수 있어요. 사실 animate() 함수는 편의를 위해 KeyframeEffect()Animation() 생성 과정을 축약한 일종의 퍼사드 같은 겁니다.

1초에 한 바퀴씩 무한으로 돕니다.

무한으로 돌지만 animate()가 리턴하는 Animation 객체를 사용해서 멈추거나 재생할 수도 있어요. CSS로 적용한 애니메이션과 동일하게 자바스크립트로 정의한 애니메이션도 animate()가 실행된 시점에 자동으로 재생됩니다. animate()Animation 을 리턴하는데 이 객체(3)를 받아서 pause()와 같은 메서드들을 사용해 애니메이션을 컨트롤할 수 있습니다. Animation 객체에 대해서는 밑에서 다시 다룰게요.

Keyframe format

CSS에서 애니메이션을 정의할 때 사용하는 키프레임은 자바스크립트에서도 객체의 형태로 사용할 수 있는데요. animate()에서 첫 번째 인자로 사용했던 것이 바로 Keyframe Format(키프레임 포맷) 배열입니다. 

배열의 길이가 두 개면 첫 번째 요소는 from 키프레임 두 번째 요소는 to 키프레임으로 사용되는데요. CSS로 키프레임을 정의할 때도 동일하죠. 이와 더불어 여러 개의 키프레임을 활용해서 애니메이션을 더 풍부하게 정의할 수 있습니다. CSS 키프레임에서 퍼센테이지를 사용해서 여러 개의 키프레임을 정의한 것과 비슷하죠.

아래와 같이 CSS로 정의한 키프레임이 있다고 할게요.

@keyframes identifier {
  25% {
    transform: translate3d(100px, 0, 0);
  }
  50% {
    transform: translate3d(100px, 100px, 0);
  }
  75% {
    transform: translate3d(0, 100px, 0);
  }
  100% {
    transform: translate3d(0, 0, 0);
  }
}

이 애니메이션의 duration이 4초라고 하면 1초에는 오른쪽으로 100px, 2초에는 밑으로 100px, 3초에는 왼쪽으로 100px 그리고 4초에는 원래 위치로 돌아가는 애니메이션입니다. 주어진 duration 시간을 25%씩 4등분 해서 움직임을 만들었죠.

이것을 키프레임 포맷으로 정의하면,

const keyframes = [
	{ transform: 'translate3d(100px, 0, 0)', offset: 0.25 },
	{
	  transform: 'translate3d(100px, 100px, 0)',
	  offset: 0.5,
	},
	{
	  transform: 'translate3d(0, 100px, 0)',
	  offset: 0.75,
	},
	{
	  transform: 'translate3d(0, 0, 0)',
	  offset: 1,
	},
];

이렇게 정의할 수 있습니다. 퍼센테이지 대신 offset이란 속성을 사용합니다. 0%offset: 0인 것이고 100%offset: 1입니다. 그외에는 똑같다고 보시면 됩니다.

그런데 위와 같이 시간을 안분해서 사용하는 경우에는 offset을 생략해도 자동으로 duration을 4로 나누어서 사용하게 됩니다. offset이 유용하게 사용되는 케이스는 시간의 흐름이 균등하지 않은 애니메이션이겠죠? 딱히 애니메이션 예제를 만드느라 시간을 사용하진 않고 넘어가겠습니다 :)

키프레임 포맷은 easing도 사용할 수 있습니다.

// ...
{ transform: 'translate3d(100px, 0, 0)', offset: 0.25, easing: "ease-in" },
// ...

다양한 easing-function 활용해 키프레임 단위로 변화시키면서 역동적인 표현도 사용할 수 있습니다.

애니메이션 컨트롤 하기

엘리먼트의 animate() 메서드는 Animation 의 인스턴트를 리턴합니다. 애니메이션을 CSS로 생성하더라도 내부적으로 인스턴스를 생성하는데요. CSS로 정의한 애니메이션을 엘리먼트에 적용한 경우 CSSAnimation 이란 인스턴스를 생성합니다. 이 녀석은 Animation의 서브 타입으로 CSS를 정의할 때 사용했던 애니메이션 이름 필드 정도만 추가된 겁니다.

Animation 객체는 엘리먼트에 적용된 애니메이션에 대한 정보를 담고 있으며 애니메이션을 컨트롤할 수 있습니다. 마치 Video 객체와 같이 애니메이션을 멈추거나 플레이할 수 있는 거죠. 특정 시점에 대한 이벤트도 받을 수 있고요. 그동안 CSS로 적용한 애니메이션이 종료됐을 때 이벤트를 받아서 무언가를 실행했던 적이 있다면 간접적으로 Animation을 이용했던 것입니다.

const anim = image.animate(...);
						   
anim.play();
anim.pause();
anim.cancel(); // 애니메이션 실행 종료, 진행 내용 초기화 하고 대기 상태로 전환
anim.currentTime // 애니메이션의 현자 진행 시간입니다. 플레이 된적이 없다면 null 입니다.
anim.playState // 현재 에니메이션 상태 (idle, running, paused, finished)

주로 사용하게 될 맴버는 이정도일 것 같습니다. 더 다양한 정보는 MDN: Animation 에서 확인하실 수 있습니다.

Animation은 엘리먼트와 동일하게 EventTarget을 상속하기 때문에 엘리먼트와 동일한 방법으로 이벤트를 받을 수 있습니다.

anim.addEventListener('finish', (event) => {})
anim.addEventListener('cancel', (event) => {})
anim.addEventListener('remove', (event) => {})

remove 이벤트는 브라우저가 애니메이션을 제거했을 때 발생하는데요. 브라우저가 왜 애니메이션을 제거하냐면 마우스 포인터를 따라가는 애니메이션같이 대량으로 발생할 수 있는 애니메이션은 움직임이 중복되어서 의미 없는 애니메이션이 생성될 수도 있는데 이 경우 메모리 누수가 발생할 수 있기에 브라우저 단에서 불필요한 애니메이션은 제거합니다. 그때 발생하는 이벤트입니다. 똑똑하죠?

animate() 대신 KeyframeEffectAnimation을 직접 만들면 조금 더 디테일한 애니메이션을 구현할 수 있는데요. CSS와 animate()는 디폴트로 자동 플레이지만 이렇게 만들면 디폴트로 애니메이션이 플레이되지 않고 필요한 시점에 직접 play()로 플레이해야 합니다. 물론 CSS animation-play-state로 디폴트도 변경할 수 있죠.

const keyframes = new KeyframeEffect(
	image,
	[{ transform: 'rotate(0)' }, { transform: 'rotate(360deg)' }],
	{
	  duration: 1000,
	  iterations: Infinity,
	}
);

const anim = new Animation(keyframes, document.timeline);

anim.play() // 직접 실행하기 전까지는 대기 상태

이렇게 상황에 따라 애니메이션을 미리 준비해두고 필요한 시점에 정교하게 애니메이션을 조작할 수 있습니다. 상태에 따라 수시로 애니메이션을 변경해서 플레이 해야 한다면 매번 animate()를 실행할 필요 없이 Animation 객체로 컨트롤 할 수 있습니다.

Animation을 생성할 때 두 번째 인자로 document.timeline를 사용했는데요. 타임라인은 CSS 애니메이션의 진행 기준인데요. 디폴트는 document.timeline 로 시간의 흐름에 따라 애니메이션이 진행되고, 여기에 스크롤 타임라인을 넣으면 스크롤 값에 따라 애니메이션이 컨트롤됩니다. 아직은 실험적인 기능인데요. MDN 에 예제가 있습니다.

Animation

엘리먼트에 애니메이션이 적용된다는 것은 사용하든 사용하지 않든 Animation 객체가 만들어졌다는 뜻입니다. 그래서 적용된 애니메이션 개수만큼 만들어집니다. 엘리먼트에 CSS나 자바스크립트 API를 사용해 만들어진 Animation객체는 getAnimation() 메서드를 통해 얻을 수 있습니다.

element.getAnimations(); // Animation[]
document.getAnimations() // 문서의 모든 애니메이션을 가져올 수도 있어요.

그리고 애니메이션이 종료된 시점은 finish 이벤트로 알 수 있는데요. 아마 사용하셨던 분도 많으실 거에요. 이벤트대신 프로미스를 활용하는 방법도 있습니다. animation.finished를 이용하면 되는데요. finished 속성은 애니메이션이 플레이 될때 생성되는 프로미스로 애니메이션의 실행이 종료되면 해결(resolve)됩니다.

그래서 잠깐 나왔다 사라지는 아이콘이나 이미지같이 오직 잠깐 쓰일 에니메이션만을 위해 존재하는 엘리먼트같은 경우는 모든 애니메이션이 종료되면 DOM 상에 더 이상 남을 필요가 없으니 제거하면 좋은데요. 이때 활용될 수 있습니다.

Promise.all(elem.getAnimations().map((animation) => animation.finished)).then(
  () => elem.remove(),
);

finish 이벤트를 받아서 처리할 수도 있겠지만 엘리먼트에 적용된 모든 애니메이션이 종료된 것을 확인하는 코드가 추가로 필요하겠죠. 프로미스를 이용하면 이렇게 간단하게 처리됩니다.

finished 속성은 프로미스이기 때문에 play()가 실행될 때마다 다시 만들어집니다. 즉 한번 실행된 thenable 콜백은 다시 실행되지 않으니 play() 할 때마다 다시 정의해야 합니다.

React, tailwindCSS과 함께 사용하기

요즘은 테일윈드를 많이 사용하는데요. 테일윈드로 적용한 애니메이션도 쉽게 Animation으로 컨트롤할 수 있습니다. 테일윈드도 키프레임으로 애니메이션을 정의하는 유틸리티 클래스는 따로 없고 순수 CSS로 정의한 뒤 사용합니다. 트랜지션만 지원하죠. 테일윈드가 미리 정의해둔 animate-spin, animate-ping, animate-pulse, animate-bounce 들은 간단히 쓸 때 유용합니다.

테일윈드로 사용된 애니메이션의 경우라고 뭔가 특별한 것은 없고요. CSS로 적용한 애니메이션을 컨트롤하는 방법과 동일합니다. 애니메이션은 animate()가 아닌 CSS로 적용하고 이후 컨트롤만 Animation 객체를 사용하는 것이죠.

테일윈드의 animate-spin을 적용하고 Animation로 컨트롤하는 예제를 보여드리겠습니다.

const ref = useRef<HTMLImageElement>(null);

useEffect(() => {
    if (ref.current) {
        const anims = ref.current.getAnimations() as CSSAnimation[]; // (1)
        const spinAnim = anims.find((ani) => ani.animationName === 'spin'); // (2)

        spinAnim.pause();
    }
}, []);

return (
    <img
        className="animate-spin"
        ref={ref}
        src="/logo.jpg"
        />
);

여기서 핵심은 CSS 클래스로 사용된 애니메이션의 Animation 객체를 어떻게 빼오냐 인데요.

위에서 설명드렸듯 엘리먼트에는 getAnimations() 라는 메서드가 언제부터인가 있었습니다. 이 메서드를 이용하면 엘리먼트에 적용된 모든 애니메이션의 Animation 객체의 배열을 얻을 수가 있는데요. 애니메이션은 여러 개가 동시에 적용될 수 있으니 이 배열에서 컨트롤하고 싶은 애니메이션을 찾아야 합니다.

찾고자 하는 애니메이션은 animationName 속성을 통해서 찾을 수 있습니다.(2) animationNameAnimation 타입의 속성이 아니라 서브타입인 CSSAnimation의 속성입니다. 말씀드렸듯 자바스크립트 Web Animation API로 애니메이션을 정의해 사용하면 Animation 객체고 CSS로 애니메이션을 정의하면 CSSAnimation입니다. 타입스크립트는 이를 추론할 수 없으니 타입 캐스팅을 해줘야겠죠.(1) 타입스크립트에서 캐스팅이 허락된 몇 안 되는 케이스 중 하나입니다.

animationName은 CSS로 키프레임을 정의할 때 사용된 키프레임 명입니다.

@keyframes spin {  // 여기서 `spin`이 `animationName`의 값입니다.
	from { transform: rotate(0deg); } 
	to { transform: rotate(360deg); } 
}

실제로 위 코드는 테일윈드의 anmiate-spin의 코드입니다. 이름만 알면 테일윈드 뿐 아니라 그 어떤 CSS 애니메이션도 찾아서 컨트롤할 수 있습니다.

마무리

이렇게 웹 에니메이션에 대해서 조금 알아봤는데요. 그동안 오픈소스 프레임웍이나 라이브러리 바뀌는 것만 생각했지 이렇게 유용한 새로운 표준 API가 나오는 것은 덜 관심 갖았던 것 같습니다. MDN을 뒤지다 보면 참 "엥? 이런게 있었어?" 싶은 게 많은 요즘입니다. 그런 것을 발견하면 저도 공부할 겸 이렇게 정리를 하고 있는데요. 다른 분들도 도움이 되셨으면 좋겠습니다.

♥ Support writer ♥
with kakaopay

크리에이티브 커먼즈 라이선스이 저작물은 크리에이티브 커먼즈 저작자표시-비영리-변경금지 4.0 국제 라이선스에 따라 이용할 수 있습니다.

shiren • © 2025Sungho Kim