기술 이야기
home
Programming
home

유저의 눈길을 사로잡는 웹 애니메이션

Upload date
2024/08/22
Tag
프론트엔드
개발자
웹애니메이션
그래픽
Editor
개인화콘텐츠팀_공태민
Editor is
그래픽 다루기를 좋아하는 프론트엔드 개발자 공태민입니다.
3 more properties

들어가며

안녕하세요. 인텔리전스랩스 마케팅개발실 개인화콘텐츠팀에서 웹 프론트엔드 개발을 담당하고 있는 공태민입니다. 저희 팀은 유저들에게 개인화된 데이터를 다양한 형태와 매체를 통해 전달하는 업무를 맡고 있는데요. 그 중에서도 CEM (Content Exposure Management) 서비스를 활용하여 로그인 직후나 게임 종료 직후에 웹으로 콘텐츠를 전달하고 있습니다.
그림 1: FC 온라인 게임 종료 직후 팝업되는 6주년 어워즈 기념 콘텐츠

1. CEM(Content Exposure Management)이란?

CEM넥슨에서 제공하는 다양한 지면에 이미지, 동영상, 웹 뷰(Web view) 등 여러 형태의 콘텐츠를 노출할 수 있도록 해주는 서비스입니다.
사전에 제공된 템플릿을 사용하거나 직접 마크업과 스크립트를 작성하는 방식으로, 각 게임과 지면의 톤앤매너를 준수하여 유저들에게 적절한 콘텐츠를 전달하는 데 활용됩니다.
예시의 두 사진은 각각 넥슨 홈페이지 로그인 직후와 게임 종료 직후에 CEM을 통해서 노출되는 화면을 캡처한 것입니다.
그림 2: 넥슨 홈페이지 로그인 직후 노출되는 콘텐츠 화면
그림 3: FC 온라인 게임 종료 후 노출되는 콘텐츠 화면
예시의 두 사진처럼 콘텐츠의 타깃 유저 범위가 넓을 때도 있지만, 그 범위가 더 좁혀지거나 보다 개인화된 타깃팅이 필요할 경우, 관련 데이터를 활용해 노출 대상을 선별할 수 있어 그 활용도가 매우 높습니다.

1-1. CEM의 클릭률

이러한 콘텐츠를 제공한 후에는 클릭률 을 통해 콘텐츠가 효과적이었는지, 목적을 달성했는지, 유저의 반응은 어떤지 등을 평가합니다.
웹사이트에서 여백 영역이나 글귀의 서두, 중간, 말미에 광고가 배치된 모습을 볼 수 있는데, CEM을 통해 노출되는 콘텐츠도 이와 유사합니다.
글로벌 포털사가 집행하는 광고의 평균 클릭률은 1.52%인 반면, 넥슨에서 로그인 직후 노출되는 콘텐츠의 경우, 최근 3개월 동안 12개 콘텐츠의 클릭률이 최소 15%에서 최대 49%까지, 평균적으로 20%대를 기록하고 있습니다.
반면, 게임 종료 직후 노출되는 팝업의 클릭률은 평균적으로 10%를 넘지 못했습니다. 이는 타 플랫폼의 광고 클릭률보다는 월등히 높은 수치이지만, 대상이 ‘넥슨 유저’라는 점을 감안하면 더 높은 기준으로 평가되어야 하며, 이로 인해 다소 아쉬운 결과로 볼 수 있습니다. 특히, 로그인 지면을 제외한 경우 이러한 아쉬움이 더 두드러집니다.

1-2. 로그인 지면의 한계와 종료 지면 공략

넥슨 홈페이지 로그인 지면은 콘텐츠를 가장 효과적으로 보여줄 수 있으면서도, 해당 유저가 어떤 게임을 하기 위해 접속하는지 알 수 없다는 점에서 매우 제한적인 지면입니다.
여러 환경을 고려했을 때, 웹으로 제공되는 개인화된 콘텐츠는 게임 종료 직후 지면이 많이 활용되고 있는데요. 클릭률로 봤을 때나, 저도 한 명의 유저로서, 종료 직후 지면은 유저의 이목을 사로잡기 매우 어렵습니다. 게임을 플레이하고 난 후에는 피로감을 느끼거나, PC 방에서라면 게임 종료와 동시에 컴퓨터를 끄거나, 게임을 종료하지 않고 항상 켜놓는 유저들도 존재하니까요.
따라서 게임 종료 지면의 콘텐츠는 눈길을 사로잡을 수 있는 후킹 요소와 임팩트가 필요합니다. 물론 가장 중요한 것은 기획과 분석 단계에서의 세밀한 타깃팅과 콘텐츠 자체가 유저에게 유용하고 유익해야 한다는 점이지만요. 개발자로서 할 수 있는 일은 결과물에 생명을 불어넣고, 더 나은 UX를 고민하는 일이었습니다. (디자이너님과 함께)

1-3. 종료 지면 사례

앞서 첨부된 ‘FC 온라인 6주년 어워즈’ 콘텐츠는 인터렉티브 요소과 다양한 애니메이션으로 구성되어 30% 이상의 클릭률을 기록했습니다. 게임 종료 시점의 콘텐츠들의 평균적인 클릭률이 2~4% 대인 것을 감안하면, 이는 매우 높은 수치입니다. 이벤트 자체가 유저의 관심을 끌었다는 점도 있지만, 유저의 클릭을 유도하는 FC 온라인의 인게임 경험 중 하나인 팩 개봉을 웹에서 유사하게 구현한 것이 크게 기여한 것으로 보입니다.
다음 챕터에서는 이러한 경험을 웹에서 어떻게 구현했는지 그 과정을 공유해보겠습니다.

2. 웹 애니메이션

모든 예시 코드는 React 18≥ 기반으로 작성되었습니다.
동영상이나 게임에서는 화려한 효과와 소리, 진동(게임 패드) 등을 활용해 몰입도를 높이는데요. 브라우저에서도 이러한 요소들을 활용할 수 있도록 애니메이션, 음악, 픽셀 하나하나까지 조작 가능한 API 를 제공합니다. 이를 적재적소에 잘 활용하면 유저의 이탈을 막고 더 오랜 시간 동안 유저가 머물도록 할 수 있습니다.
이어지는 내용에서는 기술적으로 어떤 것들을 할 수 있는지 알아보겠습니다.

2-1. 트렌지션과 애니메이션

웹에서는 트렌지션애니메이션 두 가지를 활용할 수 있는데요. 아래와 같이 설명할 수 있습니다.
트렌지션 : 두 가지 상태를 자연스럽게 연결하는 기법 애니메이션 : 2개 이상의 상태를 시간에 따라 연결하는 기법
그림 4: CSS 트랜지션과 애니메이션 속성을 비교하는 예제
.transition-box { transition: all 2s; transform: translateX(100px); } @keyframes move-right { 0% { transform: translateX(0) translateY(0px); } 50% { transform: translateX(50px) translateY(50px); } 100% { transform: translateX(100px) translateY(0px); } } .animation-box { animation: move-right 2s; }
CSS
복사
useLayoutEffect(() => { setTimeout(() => { document.querySelector('.transition-box').classList.add('tr'); }, 100); }, []);
JavaScript
복사
위의 예시는 기본적인 CSS 속성을 사용하여 애니메이션과 트랜지션을 구현한 것입니다.
애니메이션은 DOM 요소가 렌더링된 후 자동으로 실행되지만, 트랜지션은 그렇지 않습니다. 트랜지션은 마우스 오버 등의 액션이 발생하거나, 트랜지션 속성이 영향을 미치는 항목이 최초 렌더링된 후 추가되어야만 실행됩니다. 예시에서는 애니메이션과 비교하기 위해 트랜지션이 100ms 뒤에 자동으로 적용되도록 설정했습니다.

2-2. 복잡한 애니메이션

이제 더 다양한 애니메이션을 구현해 보겠습니다.
예를 들어, 객체가 이리저리 움직이다가 마지막에 크기가 커지도록 만들고 싶은데요. 다만, 애니메이션을 추가한다는 개념으로 기존의 애니메이션을 수정하는 것이 아니라, 새로운 애니메이션을 정의하여 연속적으로 실행시키고자 합니다.
CSS 속성으로는 한 번에 하나의 애니메이션만 적용할 수 있기 때문에, 애니메이션이 끝나는 시점에 이를 교체해야 합니다. 이를 위해 Javascript 에서 애니메이션 종료 이벤트를 활용할 수 있습니다.
하지만 연속적으로 동작할 애니메이션에서는 첫 번째 애니메이션이 끝난 시점의 상태를 알 수 없기 때문에, move-right 애니메이션의 마지막 상태인 translateX(200px)scale-up 애니메이션에 항상 추가해야 합니다.
그림 5: 보다 복잡한 애니메이션의 예시
@keyframes move-right { 0% { transform: translateX(0) translateY(0px); } 30% { transform: translateX(50px) translateY(-50px); } 40% { transform: translateX(100px) translateY(0px); } 50% { transform: translateX(100px) translateY(-100px); } 70% { transform: translateX(100px) translateY(0px); } 100% { transform: translateX(200px) translateY(0px); } } @keyframes scale-up { 0% { transform: translateX(200px) scale(1); } 100% { transform: translateX(200px) scale(1.5); } } .animation-box { animation: move-right 2s forwards; }
CSS
복사
useLayoutEffect(() => { const box = document.querySelector('.animation-box'); box.addEventListener('animationend', () => { box.style.animation = 'scale-up 1s forwards'; }); }, []);
JavaScript
복사
여기서 조금만 더 복잡해지면 어떤 형태가 될까요? 상상만 해도 끔찍합니다!

2-3. Web Animation API

결과적으로, 여러 애니메이션을 구성하려면 이벤트 기반으로 Javascript에서 제어하는 것이 효율적입니다. 이를 위해 keyframe 선언부터 애니메이션의 라이프사이클 메서드까지 제공하는 Web Animation API를 활용할 수 있습니다. 앞서 CSS로 구성한 것과 비슷한 방식으로 애니메이션을 구현할 수 있으며, 다음과 같습니다.
그림 6: Web Animation Api로 그림 5와 동일한 애니메이션을 구현
useLayoutEffect(() => { const box = document.querySelector('.anim') as HTMLDivElement; const moveAnim = box.animate( [ { transform: 'translateX(0px)', offset: 0 }, { transform: 'translateX(50px) translateY(-50px)', offset: 0.3 }, { transform: 'translateX(100px)', offset: 0.4 }, { transform: 'translateX(100px) translateY(-100px)', offset: 0.5 }, { transform: 'translateX(100px)', offset: 0.7 }, { transform: 'translateX(200px)', offset: 1 }, ], { duration: 2000, iterations: 1, fill: 'forwards', }, ); moveAnim.onfinish = () => { box.animate( [ { transform: 'translateX(200px) scale(1)', offset: 0 }, { transform: 'translateX(200px) scale(1.5)', offset: 1 }, ], { duration: 2000, iterations: 1, fill: 'forwards', }, ); }; }, []);
JavaScript
복사
CSS와 Javascript를 혼합해서 작업한 것보다는 유지 보수하기 훨씬 좋은 형태가 된 것 같습니다.
하지만 여전히 확장성에 한계가 있는데요. 각 애니메이션은 이전 애니메이션의 마지막 상태를 여전히 알 수 없기 때문에 다음 애니메이션에 직접 추가해야 하며, callback 기반으로 연속적인 애니메이션을 추가하는 것도 여전히 어려운 점이 남아있습니다.

2-4. Javascript 애니메이션 라이브러리

그림 7: npm trends를 통해 비교한 최근 1년간 Javascript 애니메이션 라이브러리 다운로드 수
애니메이션을 구성하다 보면, 앞서 언급한 문제들 외에도 다양한 도전 과제에 직면하게 됩니다. ‘애니메이션’이라는 것을 브라우저에서 구현하려면 고려해야 할 사항들이 많습니다. 필요한 부분만 만들어서 가볍게 유지하는 것도 좋지만, 저희는 기능 구현보다는 결과물의 완성도에 집중하기로 했습니다.
React 환경에서는 크게 4가지 선택지가 있습니다. React 라이프사이클에 맞춰 개발된 Framer-Motion , react-spring 과 범용 Javascript 라이브러리인 GSAP, Anime.js입니다. 각 라이브러리는 장단점이 뚜렷하기 때문에 프로젝트 성격에 맞게 선택할 수 있습니다. 저는 React 외에 바로 HTML에서 작업하는 경우가 많고, 선언하는 방식보다는 순차적이고 세밀한 연출을 선호하기 때문에 GSAP를 자주 사용하고 있습니다.

3. GSAP

3-1. 설치

npm install gsap yarn add gsap pnpm install gsap
Bash
복사

3-2. 사용 예시

GSAP에서는 개별 애니메이션을 tween, 여러 tween의 집합을 timeline으로 정의합니다. 이 두 단위를 자유롭게 결합하여 실행, 멈춤, 취소, 역재생 등 라이프사이클을 제어하는 것이 GSAP의 핵심입니다.
그렇다면 GSAP를 사용해 동일한 애니메이션을 구현해보겠습니다.
그림 8: GSAP를 활용해 구현한 애니메이션
import gsap from 'gsap'; // ... useLayoutEffect(() => { const tl = gsap.timeline(); tl.to('.animation-box', { x: 100, duration: 0.5, }); tl.to('.animation-box', { y: -50, duration: 0.3, }); tl.to('.animation-box', { y: 0, duration: 0.3, }); tl.to('.animation-box', { x: '+=100', // 현재 상태를 기준으로 더 한다. duration: 0.5, }); tl.to('.animation-box', { scale: 1.5, duration: 0.3, }); return () => { tl.kill(); // 컴포넌트가 언마운트될 때 timeline 을 완전히 제거. }; }, []);
JavaScript
복사
앞서 작성한 코드보다 훨씬 명확하고, 수정 관리가 편해진 형태가 된 것 같지 않나요? 그리고 원본 코드와 완전히 분리되어 있어, GSAP 코드만 제거하면 언제든지 애니메이션이 없는 상태로 돌아갈 수 있다는 것이 가장 큰 장점입니다

3-3. @gsap/react

gsap.to(selector) 가 내부적으로 document.querySelector 를 사용하여 React 라이프사이클과 별개로 동작하기 때문에, GSAP를 React와 같은 SPA에서 그대로 사용하기에는 안정성에 문제가 있을 수 있습니다.
기본적으로 GSAP 애니메이션 타이머는 전역으로 관리됩니다. 하지만 컴포넌트 기반 작업에서는 애니메이션이 동작하는 스코프를 제한하고, 컴포넌트의 라이프사이클에 맞춰 정리 작업(Cleanup)을 해야 합니다. 이를 위해 @gsap/react 패키지를 통해 간편하게 Hook 형태로 사용할 수 있습니다.
useGSAP(()⇒{},[]) 내부는 GSAP 애니메이션 타이머를 context로 안전하게 래핑되어 있고, 인터렉션을 통해 선언된 이벤트들 역시 반환 값의 contextSafe() 함수를 사용해 등록하면, 컴포넌트가 언마운트 될 때 안전하게 정리 작업이 이뤄집니다.
그림 9: @gsap/react 사용 예시
function App (){ const [show, setShow] = useState(false); return ( <> <button onClick={() => setShow((prev) => !prev)}>toggle</button> {show && <SampleBox />} </> ) } function SampleBox() { const rootScope = useRef<HTMLDivElement>(null); const { context, contextSafe } = useGSAP( () => { const tl = gsap.timeline(); tl.to('.box', { x: 100, duration: 0.5, }); tl.to('.box', { y: -50, duration: 0.3, }); tl.to('.box', { y: 0, duration: 0.3, }); tl.to('.box', { x: '+=100', duration: 0.5, }); tl.to('.box', { scale: 1.5, duration: 0.3, }); }, { scope: rootScope, }, ); const pump = contextSafe(() => { const tl = gsap.timeline(); tl.to('.box', { scale: '+=0.5', backgroundColor: 'red', duration: 0.1, }); tl.to('.box', { scale: '-=0.5', duration: 0.2, backgroundColor: '#fff', }); }); return ( <div ref={rootScope}> <div className={'box'} onClick={pump}></div> </div> ); }
JavaScript
복사
<SampleBox /> 컴포넌트가 마운트될 때 내부의 context와 scope 내에서만 유효한 애니메이션이 실행되고, 언마운트될 때 안전하게 정리됩니다. 즉, 컴포넌트 내부에서 애니메이션을 위해 사용된 리소스들이 컴포넌트와 함께 활성화 되었다가 함께 사라지게 됩니다.

4. GSAP를 활용해 카드 오픈 연출하기

마지막으로 GSAP를 활용해 카드 오픈 이펙트를 연출한 방법을 공유하며 마무리하겠습니다.
사실 애니메이션을 어떻게 연출하느냐는 아이디어와 감각이 90%, 그리고 10%는 활용할 수 있는 CSS 속성을 이해하는 것입니다. 따라서 기술적인 내용보다는 “이런 식으로 활용할 수 있구나~” 정도로 참고하시면 좋을 것 같습니다.

4-1. 카드 모으기

그림 10: 카드를 가운데로 모으는 연출
const card = { width: 230, height: 320, gap: 40 }; function FC6Event(){ const scope = useRef(null); const { contextSafe } = useGSAP(()=>{},{ scope }) const openCards = contextSafe(()=>{ /* 카드 한군데 모으기 */ const combine = gsap.timeline(); const { width: w , gap } = card; tl.to('.card', { duration: 0.5, x: (i) => { if (i === 0) return `${(w + gap) * 2}px`; if (i === 1) return `${w + gap}px`; if (i === 2) return '0'; if (i === 3) return `-${w + gap}px`; if (i === 4) return `-${(w + gap) * 2}px`; return '0'; }, ease: 'power2.in', } ); }) return ( <div ref={scope}> <div className="flash"/> <Card/> <Card/> <Card/> <Card/> <Card/> <div> <button onClick={openCards}>카드 확인하기</button> </div> </div> ) } function Card(){ return ( <div className="card"> <div className="cover"/> <div className="front"> // ... </div> <div className="back"> // ... </div> </div> ) }
JavaScript
복사
GSAP에서 selector 의 결과가 2개 이상인 경우 , 각각의 선택자에 개별적으로 값을 지정할 수 있습니다.
카드의 너비와 여백을 계산하여 각 인덱스마다 중앙으로 이동하기 위한 x값을 계산하고, 변경해야 하는 값으로 반환합니다. ease 값은 CSS와 동일한 속성으로, GSAP 고유의 easing 값도 사용할 수 있습니다.
tl.to('.card', { duration: 0.5, x: (i) => { if (i === 0) return `${(w + gap) * 2}px`; if (i === 1) return `${w + gap}px`; if (i === 2) return '0'; if (i === 3) return `-${w + gap}px`; if (i === 4) return `-${(w + gap) * 2}px`; return '0'; }, ease: 'power2.in', } );
JavaScript
복사

4-2. 카드 광원 효과 추가하기

FC 온라인에서 카드나 기타 확률형 아이템을 오픈할 때의 광원 효과를 추가해 보겠습니다. 이번에는 모든 카드가 가운데로 모이면서 동시에 흰색으로 변하게 할 예정입니다.
그림 11: 카드 모으기에 광원 효과가 추가된 모습
gsap.timeline 은 기본적으로 애니메이션을 순차적으로 실행합니다. 이전 단계의 애니메이션이 끝나야 다음 애니메이션이 실행되지만, 애니메이션이 겹치거나 더 지연되도록 하려면 세 번째 인자를 사용해 조절할 수 있습니다.
.cover { position: absolute; content: ''; inset: 0; border-radius: 8px; background-color: #fff; opacity: 0; box-shadow: 0 0 10px 3px #fff; }
CSS
복사
미리 카드를 덮는 요소를 흰색과 box-shadow 로 네온 효과를 내어 opacity 만 0로 세팅해놓습니다.
const openCards = contextSafe(()=>{ /* 카드 한군데 모으기 */ // ... /* 빛나는 효과 */ tl.to( '.card .cover', { opacity: 1, duration: 0.5, ease: 'power2.in', }, '-=0.5', // 타임라인의 선행 애니메이션이 끝나기 0.5초 전에 시작합니다. ); })
JavaScript
복사

4-3. 터질 것 같은 Shaking 효과 추가하기

카드가 다 모인 후에는, 곧 카드가 터질 것 같은 연출로 긴장감을 높입니다. 이를 위해 x와 y값을 짧게 0.1초간 움직이는 애니메이션을 8번 반복하도록 설정했습니다.
그림 12: 카드에 흔들림 효과 추가
const openCards = contextSafe(()=>{ /* 카드 한군데 모으기 */ // ... /* 빛나는 효과 */ // ... /* shaking */ const shakeRange = 2; tl.to('.card', { duration: 0.1, x: `+=${shakeRange}`, y: `+=${shakeRange}`, repeat: 8, ease: 'linear', }); })
JavaScript
복사

4-4. 카드 뒤집으면서 Flash Bang 효과 내기

Shaking 효과를 통해 터질 것 같은 연출을 했으니, 클라이맥스에는 폭발 효과를 더해 강한 빛이 나타나도록 하고, 이어서 결과를 보여줍니다.
그림 13: Flash Bang 효과 추가
.flash { position: fixed; inset: 0; background: #fff; z-index: 5; opacity: 0; pointer-events: none; }
CSS
복사
const openCards = contextSafe(()=>{ /* 카드 한군데 모으기 */ // ... /* 빛나는 효과 */ // ... /* shaking */ // ... /* 전체 화면 플레시 */ tl.to('.flash', { opacity: 1, duration: 0.1, ease: 'power4.out', }); tl.to('.flash', { opacity: 0, duration: 2, ease: 'power4.out', }); })
JavaScript
복사

4-5. 카드 뒤집으면서 결과 보여주기

카드가 구체적으로 구현된 모습은 생략했지만, 두 개의 <div/> 태그를 서로 맞닿게 배치하여 rotationY 값으로 앞면과 뒷면을 구성했습니다. 기본값은 rotationY(180deg) 로 설정하여 뒤집힌 상태이며, 이 속성을 변경하여 앞면으로 뒤집었습니다.
카드의 위치는 모두 x값을 0으로 초기화하여 처음 배치된 상태로 돌아가게 하고, GSAP의 3번째 인자에 <를 사용하여 이전 애니메이션과 병렬로 실행되도록 합니다.
그림 14: 카드가 뒤집히며 최종 결과를 보여주는 장면
const openCards = contextSafe(()=>{ /* 카드 한군데 모으기 */ // ... /* 빛나는 효과 */ // ... /* shaking */ // ... /* 전체 화면 플레시 */ // ... /* 각 카드 위치 복구 */ tl.to( '.card', { duration: 0.5, x: 0, ease: 'power4.out', }, '<', ); /* 카드 180도 뒤집기 */ tl.to( '.card', { duration: 0.5, rotationY: 0, ease: 'power4.out', }, '<', ); /* 흰색 cover 제거 */ tl.to( '.card .back .cover', { opacity: 0, duration: 0.1, ease: 'power2.in', }, '<', ); })
JavaScript
복사
아래 최종 결과물에는 디테일한 효과와 콘페티를 추가했습니다. 이는 GSAP가 아닌 <canvas> 를 사용해 구현했는데요. 축하의 의미와 폭발하는 모션이 잘 어울려 그 효과를 더 극대화할 수 있었습니다 <canvas>는 CSS를 활용한 애니메이션보다 더 다양한 표현이 가능하며, 픽셀 단위로 조작하기 때문에 숙련도에 따라 어떤 화면이든 구현할 수 있습니다.
그림 15: FC 온라인 6주년 어워즈의 카드 오픈 이펙트

나가며

웹페이지 개발에서 이렇게 적극적으로 애니메이션을 활용할 기회가 흔치 않은데요. 너무 좋은 기회를 가지게 되어 정말 기쁩니다 디자인이나 기획서를 보고 결과물만 만들어 내는 것뿐만 아니라 웹에서 활용할 수 있는 기술을 통해 평범함을 넘는 가치를 창출할 수 있다는 사실이 정말 즐겁고 설렙니다!

Reference

함께 읽으면 좋은 콘텐츠
Related Sites
 넥슨 게임 포탈
회사 소개
인재 영입
인텔리전스랩스 블로그 운영 정책
 테크블로그 문의 gs_site_contents@nexon.co.kr