๋ณธ๋ฌธ ๋ฐ”๋กœ๊ฐ€๊ธฐ
๐Ÿ’ป ํ”„๋กœ์ ํŠธ/๐Ÿฐํ† ๋ผ์™€ ๊ฑฐ๋ถ์ด ๊ฒฝ์ฃผ๊ฒŒ์ž„๐Ÿข

[Vue, VITE] GSAP ๋ผ์ด๋ธŒ๋Ÿฌ๋ฆฌ ์„ค์น˜ํ•˜๊ณ  ์ด์šฉํ•ด๋ณด๊ธฐ

by hyeong._.ing 2026. 5. 31.

 

 

ํ† ๋ผ์™€ ๊ฑฐ๋ถ์ด ๊ฒฝ์ฃผ๊ฒŒ์ž„์„ ๋งŒ๋“ค์—ˆ๋‹ค.
๊ธฐ์กด์— ์žˆ๋˜ CSS๋ณด๋‹ค ๋” ์ƒ๋™๊ฐ์žˆ๊ฒŒ ๋งŒ๋“ค๊ธฐ ์œ„ํ•ด
GSAP ๋ผ์ด๋ธŒ๋Ÿฌ๋ฆฌ๋ฅผ ์„ค์น˜ํ•˜๊ณ  ์ฝ”๋“œ๋ฅผ ์ž‘์„ฑํ•ด๋ณด์ž.

 

 

 

1. ๋™์ž‘ ํ™”๋ฉด

 

 

 

 

 


 

 

 

2. ๋ผ์ด๋ธŒ๋Ÿฌ๋ฆฌ ์„ค์น˜

  • ํ”„๋กœ์ ํŠธ ํ„ฐ๋ฏธ๋„
npm์ด๋ฉด

npm install gsap

 

 

 

 


 

 

 

 

3. ์ฝ”๋“œ

  • GameView.vue
<img ref="rabbitEl" src="@/assets/rabbit2.png" class="runner runner-rabbit" />
<img ref="turtleEl" src="@/assets/turtle2.png" class="runner runner-turtle" />
const rabbitEl = ref(null)
const turtleEl = ref(null)

const {
  countdown,
  message,
  reset,
  running,
  start,
  winner,
} = useRaceGame({ rabbitEl, turtleEl })

 

GSAP์€ ์‹ค์ œ DOM์„ ์ง‘์–ด์„œ ๊ทธ ์Šคํƒ€์ผ์„ ์‹ค์‹œ๊ฐ„์œผ๋กœ ๋ฐ”๊ฟ”์ค€๋‹ค. ๊ทธ๋ž˜์„œ GSAP์„ ์‚ฌ์šฉํ•˜๋ ค๋ฉด ์ง„์งœ DOM์„ ๋„˜๊ฒจ์ค˜์•ผํ•œ๋‹ค. ๊ทธ๋ ‡๊ธฐ์— ์ž๋ฐ”์Šคํฌ๋ฆฝํŠธ๋กœ ์กฐ์ข…ํ•  ์ˆ˜ ์žˆ๋„๋ก GameView.vue์—์„œ ref๋ฅผ ๋ถ™์—ฌ์„œ useRaceGame์œผ๋กœ ๋„˜๊ฒจ์ค€๋‹ค.

GameView.vue๋Š” ๊ฒŒ์ž„ ํ™”๋ฉด๋งŒ ๋ณด์—ฌ์ฃผ๋Š” ์—ญํ• ์ด๊ณ , ์‹ค์ œ ์›€์ง์ด๋Š” ๋ชจ๋“  ๋กœ์ง์€ useRaceGame.js์—์„œ ์ง„ํ–‰ํ•œ๋‹ค. GameView์—์„œ rabbitEl, turtleEl์„ useRaceGame์—๊ฒŒ ํŒŒ๋ผ๋ฏธํ„ฐ๋กœ ๋ณด๋‚ด๋ฉด, GameView๋Š” DOM(ํ™”๋ฉด)๋งŒ ๋„์šฐ๊ณ  useRaceGame์€ ์ด ์š”์†Œ๋ฅผ ๋ฐ›์•„์„œ GSAP์œผ๋กœ ์›€์ง์ด๋Š” ๊ถŒํ•œ์„ ๊ฐ–๋Š”๋‹ค.

 

 

  • useRaceGame.js (GSAP๊ณผ ๊ด€๋ จ๋œ ์ฝ”๋“œ๋งŒ)
import { gsap } from 'gsap'
์„ค์น˜ํ•œ ๋ผ์ด๋ธŒ๋Ÿฌ๋ฆฌ๋ฅผ ๋ถˆ๋Ÿฌ์˜จ๋‹ค.
let timeline
์—ฌ๋Ÿฌ ์• ๋‹ˆ๋ฉ”์ด์…˜์„ ์‹œ๊ฐ„ ์ˆœ์„œ๋Œ€๋กœ ๋ฌถ์–ด์ค„ ํƒ€์ž„๋ผ์ธ ๊ฐ์ฒด๋ฅผ ๋‹ด์„ ๋ณ€์ˆ˜์ด๋‹ค. ์ƒˆ๋กœ์šด ๊ฒฝ์ฃผ๊ฐ€ ์‹œ์ž‘๋  ๋•Œ๋งˆ๋‹ค ์ด์ „ ํƒ€์ž„๋ผ์ธ์„ ๋ฒ„๋ฆฌ๊ณ  ์ƒˆ๋กœ์šด ํƒ€์ž„๋ผ์ธ์„ ๋ฎ์–ด๋„์›Œ์•ผํ•ด์„œ const(์ƒ์ˆ˜)๊ฐ€ ์•„๋‹Œ let(๋ณ€์ˆ˜)์„ ์‚ฌ์šฉํ–ˆ๋‹ค.
  function clear() {
    timeline?.kill()
    timeline = null

    if (rabbitEl.value) gsap.killTweensOf(rabbitEl.value)
    if (turtleEl.value) gsap.killTweensOf(turtleEl.value)
  }
  onBeforeUnmount(clear)
์ƒˆ๋กœ์šด ๊ฒฝ์ฃผ๊ฐ€ ์‹œ์ž‘ํ•˜๊ธฐ ์ง์ „ ํ˜น์€ ์‚ฌ์šฉ์ž๊ฐ€ ์ด ๊ฒŒ์ž„ ํ™”๋ฉด์„ ์•„์˜ˆ ๋– ๋‚˜๋Š” ๊ฒฝ์šฐ clear๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค. ์‚ฌ์šฉ์ž๊ฐ€ ์‹œ์ž‘ ๋ฒ„ํŠผ์„ ๋ˆ„๋ฅด๋ฉด ์ฝ”๋“œ๋Š” [ start -> reset -> clear ] ์ด๋ ‡๊ฒŒ ํ˜๋Ÿฐ๊ฐ„๋‹ค.

์ด์ „ ๊ฒŒ์ž„์—์„œ ํ† ๋ผ๋‚˜ ๊ฑฐ๋ถ์ด๊ฐ€ ๊ฒฐ์Šน์„ ์— ๋“ค์–ด์™€ ์žˆ๋Š” ์ƒํƒœ์ผํ…๋ฐ, ์ด๋•Œ ์•„์ง ์• ๋‹ˆ๋ฉ”์ด์…˜ ์ฐŒ๊บผ๊ธฐ๊ฐ€ ๋‚จ์•„์žˆ์„ ์ˆ˜ ์žˆ๋‹ค. ํ˜น์€ ์‚ฌ์šฉ์ž๊ฐ€ ๋’ค๋กœ๊ฐ€๊ธฐ๋ฅผ ๋ˆ„๋ฅด๊ฑฐ๋‚˜ ๋‹ค๋ฅธ ๋ฉ”๋‰ด๋กœ ์ด๋™ํ–ˆ๋‹ค๊ณ  ๊ฐ€์ •ํ•˜์ž. ๊ฒŒ์ž„ ํ™”๋ฉด์€ ์‚ฌ๋ผ์กŒ์ง€๋งŒ ๋ฐฑ๊ทธ๋ผ์šด๋“œ์—์„œ๋Š” ํ† ๋ผ๊ฐ€ ๊ณ„์† ๋‹ฌ๋ฆฌ๊ณ  ์žˆ์„ ์ˆ˜๋„ ์žˆ๋‹ค. ๊ทธ๋ž˜์„œ vue๊ฐ€ ์ด ์ปดํฌ๋„ŒํŠธ๊ฐ€ ๊บผ์ง€๊ธฐ ์ง์ „ onBeforeUnmount)์— ๋ฐฑ๊ทธ๋ผ์šด๋“œ ์• ๋ฏธ๋„ค์ด์…˜๊นŒ์ง€ ์‹น ๋‹ค ์ฃฝ์ด๋„๋ก ์ฝ”๋“œ๋ฅผ ์ž‘์„ฑํ–ˆ๋‹ค.
  function reset() {
    clear()
    countdown.value = ''
    winner.value = ''
    status.value = 'ready'

    gsap.set([rabbitEl.value, turtleEl.value].filter(Boolean), {
      x: 0,
      y: 0,
      rotate: 0,
      scale: 1,
    })
  }
const COUNTDOWN_STEPS = ['3', '2', '1', '์ถœ๋ฐœ!']
์ถœ๋ฐœ์„ ์„ ์„ธํŒ…ํ•˜๋Š” reset ํ•จ์ˆ˜์ด๋‹ค. ๊ฒฝ์ฃผ๊ฐ€ ์‹œ์ž‘๋˜๊ธฐ ์ „, gsap.set์„ ์ด์šฉํ•ด์„œ ํ† ๋ผ์™€ ๊ฑฐ๋ถ์ด ์œ„์น˜, ๊ฐ๋„๋ฅผ 0์ดˆ ๋งŒ์— x:0 y:0์œผ๋กœ ๋˜๋Œ๋ ค ๋†“๋Š”๋‹ค. reset์ด ๋˜๋ฉด, clear ํ•จ์ˆ˜๊ฐ€ ์‹คํ–‰๋œ๋‹ค. ๊ทธ๋ฆฌ๊ณ  countdown.value์— 3,2,1,์ถœ๋ฐœ! ๊ฐ™์€ ๊ธ€์ž ๋ฐ์ดํ„ฐ๋ฅผ ์™„์ „ํžˆ ์ง€์šด๋‹ค. ๊ฒŒ์ž„์˜ ๋งˆ์ง€๋ง‰์—์„œ ์šฐ์Šน์ž ๋ฉ”์‹œ์ง€๊ฐ€ ๋œจ๋Š”๋ฐ ๊ทธ๊ฒƒ๋„ ์ง€์šด๋‹ค. status๋Š” ready๋กœ ๋ฐ”๊พผ๋‹ค. 
function pickWinner() {
  const currentSeconds = new Date().getSeconds()
  return currentSeconds % 2 === 0 ? 'rabbit' : 'turtle'
}
๋จผ์ € ์Šน์ž๋ฅผ ๋ฝ‘๋Š”๋‹ค. ๋กœ์ง์€ ์ฒ˜์Œ ์ง ๊ฑฐ๋ž‘ ๋˜‘๊ฐ™๋‹ค. ํ˜„์žฌ ์ดˆ๊ฐ€ ์ง์ˆ˜๋ฉด rabbit์˜ ์Šน๋ฆฌ, ํ™€์ˆ˜๋ฉด turtle์˜ ์Šน๋ฆฌ์ด๋‹ค. 
function run(nextWinner) {
    const finishX = () => window.innerWidth * 0.82

    timeline = gsap.timeline({
      defaults: {
        ease: 'power1.inOut',
      },
    })
๊ฒฐ์Šน์„  ์œ„์น˜๋Š” finishX, ํ™”๋ฉด ์ „์ฒด ๋„ˆ๋น„(window.innerWidth)์—์„œ 82% ์ง€์ ์„ ๊ฒฐ์Šน์„ ์œผ๋กœ ์„ค์ •ํ–ˆ๋‹ค. ํ•จ์ˆ˜ () => ํ˜•ํƒœ๋กœ ์ž‘์„ฑํ•ด์„œ ์ฐฝ์˜ ํฌ๊ธฐ๊ฐ€ ๋ณ€ํ•ด๋„ ํ•ญ์ƒ ๋™์ ์œผ๋กœ ๋งž์ถฐ์ง€๋„๋ก ํ–ˆ๋Š”๋ฐ, ์ž˜ ๋ ์ง€ ๋ชจ๋ฅด๊ฒ ๋‹ค. (๋‚ด๊ฒ ํ™”๋ฉด์ด ํ•˜๋‚˜์ด๋‹ค!)

gsap.timeline( )์€ ์—ฌ๋Ÿฌ ์• ๋‹ˆ๋ฉ”์ด์…˜์„ ์—ฐ๊ฒฐํ•ด์„œ ์žฌ์ƒํ•˜๊ธฐ ์œ„ํ•ด ํƒ€์ž„๋ผ์ธ์„ ๋งŒ๋“ค์—ˆ๋‹ค. ๊ธฐ๋ณธ ์›€์ง์ž„์ธ ease ์†์„ฑ๊ฐ’์ธ power1.inOut์„ ์‚ฌ์šฉํ–ˆ๋‹ค. ์ด๊ฑด ๋‚˜์ค‘์— gsap ๋ผ์ด๋ธŒ๋Ÿฌ๋ฆฌ๋ฅผ ์ž‘์„ฑํ•˜๋ฉด์„œ ๋” ์ž์„ธํžˆ ์„ค๋ช…ํ•˜๊ฒ ๋‹ค.
  if (nextWinner === 'rabbit') {
      timeline
        .to(rabbitEl.value, {
          x: () => window.innerWidth * 0.42,
          y: -10,
          rotate: -4,
          duration: 2,
          ease: 'power2.out',
        }, 0)
        .to(rabbitEl.value, {
          y: 4,
          rotate: -8,
          duration: 0.65,
          ease: 'sine.inOut',
        })
        .to(rabbitEl.value, {
          y: -8,
          rotate: 3,
          duration: 0.3,
          ease: 'power2.out',
        })
        .to(rabbitEl.value, {
          x: finishX,
          y: -12,
          rotate: 3,
          duration: 2.2,
          ease: 'power3.out',
          onComplete: () => end('rabbit'),
        })
        .to(turtleEl.value, {
          x: () => window.innerWidth * 0.62,
          y: 5,
          duration: 5.8,
        }, 0)
    }
ํ† ๋ผ๊ฐ€ ์ด๊ธธ๋•Œ ์ฝ”๋“œ์ด๋‹ค. ์ถœ๋ฐœํ•˜์ž๋งˆ์ž ํ™”๋ฉด์˜ 42% ์ง€์ ๊นŒ์ง€ ๋›ฐ์–ด๊ฐ„๋‹ค. ์ด๊ฒŒ 2์ดˆ ๊ฑธ๋ฆฌ๋„๋ก ํ–ˆ๋‹ค. ๋‚˜๋ฌด ๋ถ€๊ทผ๊นŒ์ง€ ๊ฐ€๋ฉด ๋ฉˆ์ถฐ์„œ ์•ฝ๊ฐ„์˜ ์›€์ง์ž„์„ ์ฃผ์—ˆ๋‹ค. ์‚ด์ง ๋ชธ์„ ํ”๋“œ๋Š” ๋™์ž‘์ธ๋ฐ, ๋ณ„ ๋‹ค๋ฅธ ์˜๋ฏธ๋Š” ์—†๊ณ  ๋‚˜๋ฆ„ ์žˆ์–ด๋ณด์ด๊ณ  ์‹ถ์–ด์„œ ์ถ”๊ฐ€ํ–ˆ๋‹ค. ๊ทธ๋ฆฌ๊ณ  ๊ฒฐ์Šน์„ ์œผ๋กœ ๋น ๋ฅด๊ฒŒ ๋‹ฌ๋ จ๊ฐ„๋‹ค. ์ด๋•Œ power3.out์„ ์ผ๋‹ค. ๋„์ฐฉํ•˜๋ฉด onComplete๊ฐ€ ๋ฐœ๋™ํ•ด์„œ end('rabbit') ํ•จ์ˆ˜๋ฅผ ๋ถ€๋ฅด๊ณ  ๊ฒฝ์ฃผ๊ฐ€ ๋๋‚œ๋‹ค.

๊ฑฐ๋ถ์ด๋Š” ํ† ๋ผ์™€ ๋™์‹œ์— ์ถœ๋ฐœํ•˜์ง€๋งŒ 5.8์ดˆ๋™์•ˆ ํ™”๋ฉด์˜ 62% ์ง€์ ๊นŒ์ง€ ๊ฐ€๋„๋ก ์„ค์ •ํ–ˆ๋‹ค. ๊ฒฐ์Šน์„ ์— ๋‹ฟ์ง€ ๋ชปํ•˜๊ณ  ๋๋‚œ๋‹ค.
 else {
      timeline
        .to(rabbitEl.value, {
          x: () => window.innerWidth * 0.38,
          y: -10,
          duration: 2,
          ease: 'power2.out',
        }, 0)
        .to(rabbitEl.value, {
          rotate: -8,
          y: 2,
          duration: 1.1,
          ease: 'sine.inOut',
          yoyo: true,
          repeat: 1,
        })
        .to(rabbitEl.value, {
          x: () => window.innerWidth * 0.68,
          y: -6,
          rotate: 2,
          duration: 2.8,
        })
        .to(turtleEl.value, {
          x: finishX,
          y: 2,
          duration: 5.7,
          ease: 'power1.out',
          onComplete: () => end('turtle'),
        }, 0)
    }
  }
๊ฑฐ๋ถ์ด๊ฐ€ ์ด๊ธธ๋•Œ ์ฝ”๋“œ์ด๋‹ค. ํ† ๋ผ๊ฐ€ ํ™”๋ฉด์˜ 38% ์ง€์ ๊นŒ์ง€ ๊ฐ„๋‹ค. ์•„๋งˆ ์ด ์ง€์ ์ด ๋‚˜๋ฌด ๋ถ€๊ทผ์ผ ๊ฒƒ์ด๋‹ค. ์ด ๋‹ค์Œ ์ฝ”๋“œ์— yoyo: true๊ฐ€ ์žˆ๋Š”๋ฐ, ๋‚˜๋ฌด ๋ถ€๊ทผ์—์„œ ํ† ๋ผ์˜ ์›€์ง์ž„์ด ์—ญ์žฌ์ƒ๋˜๋„๋ก ํ•œ ๊ฒƒ์ด๋‹ค. ๊ฒŒ์ž„์„ ์ง„ํ–‰ํ•  ๋•Œ ๋ณด๋ฉด ํ† ๋ผ๊ฐ€ ์ด๊ธธ๋• ํ† ๋ผ์˜ ๊ธฐ์šธ๊ธฐ๊ฐ€ ํ•œ ๋ฒˆ ์›€์ง์ธ๋‹ค. ๊ทธ๋Ÿฐ๋ฐ ๊ฑฐ๋ถ์ด๊ฐ€ ์ด๊ธธ ๋• ํ† ๋ผ์˜ ์›€์ง์ž„์ด 2๋ฒˆ ๋ฐœ์ƒํ•œ๋‹ค. ์ด๊ฒŒ yoyo๋ฅผ trueํ•ด์„œ ๊ทธ๋ ‡๋‹ค. repeat์„ 1๋กœ ์„ค์ •ํ•ด์„œ ํ•œ๋ฒˆ๋งŒ ์—ญ์žฌ์ƒ ๋˜๋„๋ก ํ–ˆ๋‹ค. ๊ทธ๋‹ค์Œ ํ† ๋ผ๋Š” ๋‹ค์‹œ ๊ฒฐ์Šน์„ ์„ ํ–ฅํ•ด ์›€์ง์ด์ง€๋งŒ 68%์—์„œ ๋ฉˆ์ถ”๊ฒŒ ๋  ๊ฒƒ์ด๋‹ค.

๊ฑฐ๋ถ์ด๋Š” 5.7ํฌ ๋™์•ˆ ํ•œ๊ฒฐ๊ฐ™์€ ๊ฑธ์Œ์œผ๋กœ ๊ฒฐ์Šน์„ ์„ ํ–ฅํ•ด ์›€์ง์ธ๋‹ค.

 

 

 


 

 

 

4. ์ „์ฒด์ฝ”๋“œ

  • useRaceGame.js
import { computed, nextTick, onBeforeUnmount, ref } from 'vue'
import { gsap } from 'gsap'
import confetti from 'canvas-confetti'

const COUNTDOWN_STEPS = ['3', '2', '1', '์ถœ๋ฐœ!']

const delay = (duration) => new Promise((resolve) => window.setTimeout(resolve, duration))

function pickWinner() {
  const currentSeconds = new Date().getSeconds()
  return currentSeconds % 2 === 0 ? 'rabbit' : 'turtle'
}

export function useRaceGame({ rabbitEl, turtleEl }) {
  const countdown = ref('')
  const winner = ref('')
  const status = ref('ready')

  let timeline

  const running = computed(() => status.value === 'countdown' || status.value === 'racing')
  const message = computed(() => {
    if (winner.value === 'rabbit') return 'ํ† ๋ผ ์Šน๋ฆฌ!'
    if (winner.value === 'turtle') return '๊ฑฐ๋ถ์ด ์Šน๋ฆฌ!'
    if (status.value === 'countdown') return '์ž ์‹œ ํ›„ ์ถœ๋ฐœํ•ฉ๋‹ˆ๋‹ค'
    if (status.value === 'racing') return '๊ฒฝ์ฃผ ์ค‘์ž…๋‹ˆ๋‹ค'
    return '์ค€๋น„ ์™„๋ฃŒ'
  })

  function clear() {
    timeline?.kill()
    timeline = null

    if (rabbitEl.value) gsap.killTweensOf(rabbitEl.value)
    if (turtleEl.value) gsap.killTweensOf(turtleEl.value)
  }

  function reset() {
    clear()
    countdown.value = ''
    winner.value = ''
    status.value = 'ready'

    gsap.set([rabbitEl.value, turtleEl.value].filter(Boolean), {
      x: 0,
      y: 0,
      rotate: 0,
      scale: 1,
    })
  }

  async function count() {
    status.value = 'countdown'

    for (const step of COUNTDOWN_STEPS) {
      countdown.value = step
      await delay(step === '์ถœ๋ฐœ!' ? 50 : 650)
    }

    countdown.value = ''
  }

  function celebrate(nextWinner) {
    const origin = nextWinner === 'rabbit'
      ? { x: 0.88, y: 0.25 }
      : { x: 0.88, y: 0.72 }

    confetti({
      particleCount: 110,
      spread: 70,
      startVelocity: 36,
      origin,
      colors: ['#d692c8', '#8caa97', '#f8d0e5', '#fbfbb4', '#ffffff'],
    })
  }

  function end(nextWinner) {
    winner.value = nextWinner
    status.value = 'finished'
    celebrate(nextWinner)
  }

  function run(nextWinner) {
    const finishX = () => window.innerWidth * 0.82

    timeline = gsap.timeline({
      defaults: {
        ease: 'power1.inOut',
      },
    })

    if (nextWinner === 'rabbit') {
      timeline
        .to(rabbitEl.value, {
          x: () => window.innerWidth * 0.42,
          y: -10,
          rotate: -4,
          duration: 2,
          ease: 'power2.out',
        }, 0)
        .to(rabbitEl.value, {
          y: 4,
          rotate: -8,
          duration: 0.65,
          ease: 'sine.inOut',
        })
        .to(rabbitEl.value, {
          y: -8,
          rotate: 3,
          duration: 0.3,
          ease: 'power2.out',
        })
        .to(rabbitEl.value, {
          x: finishX,
          y: -12,
          rotate: 3,
          duration: 2.2,
          ease: 'power3.out',
          onComplete: () => end('rabbit'),
        })
        .to(turtleEl.value, {
          x: () => window.innerWidth * 0.62,
          y: 5,
          duration: 5.8,
        }, 0)
    } else {
      timeline
        .to(rabbitEl.value, {
          x: () => window.innerWidth * 0.38,
          y: -10,
          duration: 2,
          ease: 'power2.out',
        }, 0)
        .to(rabbitEl.value, {
          rotate: -8,
          y: 2,
          duration: 1.1,
          ease: 'sine.inOut',
          yoyo: true,
          repeat: 1,
        })
        .to(rabbitEl.value, {
          x: () => window.innerWidth * 0.68,
          y: -6,
          rotate: 2,
          duration: 2.8,
        })
        .to(turtleEl.value, {
          x: finishX,
          y: 2,
          duration: 5.7,
          ease: 'power1.out',
          onComplete: () => end('turtle'),
        }, 0)
    }
  }

  async function start() {
    if (running.value) return

    reset()
    await nextTick()
    await count()

    status.value = 'racing'
    run(pickWinner())
  }

  onBeforeUnmount(clear)

  return {
    countdown,
    message,
    reset,
    running,
    start,
    status,
    winner,
  }
}