Vue.js 를 배워보자

Vue.js로 실시간 판매량 그리드에 부드러운 숫자 애니메이션 구현하기

_Blue_Sky_ 2025. 3. 14. 22:40
728x90
 

 

 
Vue의 watch를 활용해 수치가 변경될 때 부드러운 페이딩(또는 숫자 애니메이션) 효과를 구현하려면, CSS 전환(transition)이나 JavaScript로 보간(interpolation)을 조합해야 합니다. 단순히 watch만으로는 애니메이션이 적용되지 않으므로, Vue의 반응형 데이터와 CSS 애니메이션, 또는 외부 라이브러리(예: gsapanime.js)를 사용하는 방법이 있습니다.
여기서는 두 가지 방법을 제안합니다:
  1. CSS transition과 클래스 토글을 활용한 방법 (간단하고 가벼움)
  2. JavaScript로 숫자 보간 애니메이션을 구현한 방법 (더 정교한 제어 가능)
실무에서 간단히 적용할 수 있는 CSS 기반 방법을 먼저 보여드리고, 더 정교한 JavaScript 기반 방법도 추가로 설명하겠습니다.
728x90

방법 1: CSS transition과 클래스 토글로 페이딩 효과
watch로 값 변화를 감지하고, CSS 클래스를 토글해 페이딩 효과를 적용합니다. 숫자가 바뀔 때마다 DOM 요소를 잠깐 투명하게 만들었다가 다시 나타나게 하는 방식입니다.
코드 예제
<template>
  <div class="sales-grid">
    <h2>지역별 월별 판매량 (단위: 개)</h2>
    <table>
      <thead>
        <tr>
          <th>지역</th>
          <th v-for="month in months" :key="month">{{ month }}</th>
          <th>합계</th>
        </tr>
      </thead>
      <tbody>
        <tr v-for="(region, index) in salesData" :key="region.name">
          <td>{{ region.name }}</td>
          <td v-for="(sales, monthIdx) in region.sales" :key="monthIdx">
            <input
              type="number"
              v-model.number="salesData[index].sales[monthIdx]"
              min="0"
              @input="logChange(region.name, months[monthIdx], $event.target.value)"
            />
          </td>
          <td :class="{ 'fade': fadeStates[index] }">{{ rowTotals[index] }}</td>
        </tr>
      </tbody>
    </table>
  </div>
</template>

<script>
import { ref, computed, watch } from 'vue';

export default {
  setup() {
    const months = ['1월', '2월', '3월'];
    const salesData = ref([
      { name: '서울', sales: [120, 150, 180] },
      { name: '부산', sales: [90, 110, 130] },
      { name: '대구', sales: [70, 85, 95] }
    ]);
    const fadeStates = ref([false, false, false]); // 각 행의 페이딩 상태

    // 합계 계산
    const rowTotals = computed(() => {
      return salesData.value.map(region =>
        region.sales.reduce((sum, sales) => sum + sales, 0)
      );
    });

    // salesData 변경 감지 및 페이딩 효과 적용
    watch(
      () => salesData.value.map(r => r.sales.join()), // sales 배열의 변화 감지
      (newValues, oldValues) => {
        newValues.forEach((_, index) => {
          if (newValues[index] !== oldValues[index]) {
            fadeStates.value[index] = true; // 페이딩 시작
            setTimeout(() => {
              fadeStates.value[index] = false; // 페이딩 종료
            }, 500); // 애니메이션 지속 시간과 맞춤
          }
        });
      },
      { deep: true }
    );

    const logChange = (region, month, newValue) => {
      if (newValue < 0) {
        alert('판매량은 음수가 될 수 없습니다!');
        return;
      }
      console.log(`${region}의 ${month} 판매량이 ${newValue}로 변경됨`);
    };

    return {
      months,
      salesData,
      rowTotals,
      fadeStates,
      logChange
    };
  }
};
</script>

<style>
.sales-grid {
  padding: 20px;
  max-width: 800px;
}
table {
  width: 100%;
  border-collapse: collapse;
}
th, td {
  border: 1px solid #ccc;
  padding: 8px;
  text-align: center;
}
th {
  background-color: #f4f4f4;
}
input[type="number"] {
  width: 60px;
  padding: 4px;
  text-align: center;
}

/* 페이딩 애니메이션 */
.fade {
  animation: fadeEffect 0.5s ease-in-out;
}
@keyframes fadeEffect {
  0% { opacity: 0; }
  100% { opacity: 1; }
}
</style>
동작 방식
  1. fadeStates 배열로 각 행의 페이딩 상태를 관리.
  2. watchsalesData의 변화를 감지하면 해당 행의 fadeStatestrue로 설정.
  3. CSS의 @keyframesanimation 속성으로 숫자가 투명해졌다가 부드럽게 나타나는 효과 적용.
  4. 0.5초 후 fadeStatesfalse로 되돌려 애니메이션이 반복되지 않게 함.
한계
  • 숫자가 직접적으로 보간되지 않고, 단순히 페이드 인/아웃만 적용됨.
  • 합계 값 자체가 부드럽게 증가/감소하는 느낌은 없음.
728x90

방법 2: JavaScript로 숫자 보간 애니메이션
숫자가 시작값에서 끝값까지 부드럽게 변하도록 하려면, JavaScript로 값을 보간해야 합니다. 이를 위해 requestAnimationFrame을 사용하거나, 외부 라이브러리 없이 간단히 구현할 수 있습니다.
코드 예제
<template>
  <div class="sales-grid">
    <h2>지역별 월별 판매량 (단위: 개)</h2>
    <table>
      <thead>
        <tr>
          <th>지역</th>
          <th v-for="month in months" :key="month">{{ month }}</th>
          <th>합계</th>
        </tr>
      </thead>
      <tbody>
        <tr v-for="(region, index) in salesData" :key="region.name">
          <td>{{ region.name }}</td>
          <td v-for="(sales, monthIdx) in region.sales" :key="monthIdx">
            <input
              type="number"
              v-model.number="salesData[index].sales[monthIdx]"
              min="0"
              @input="logChange(region.name, months[monthIdx], $event.target.value)"
            />
          </td>
          <td>{{ animatedTotals[index] }}</td>
        </tr>
      </tbody>
    </table>
  </div>
</template>

<script>
import { ref, computed, watch } from 'vue';

export default {
  setup() {
    const months = ['1월', '2월', '3월'];
    const salesData = ref([
      { name: '서울', sales: [120, 150, 180] },
      { name: '부산', sales: [90, 110, 130] },
      { name: '대구', sales: [70, 85, 95] }
    ]);
    const animatedTotals = ref([0, 0, 0]); // 애니메이션용 합계 값

    // 실제 합계 계산
    const rowTotals = computed(() => {
      return salesData.value.map(region =>
        region.sales.reduce((sum, sales) => sum + sales, 0)
      );
    });

    // 숫자 애니메이션 함수
    const animateValue = (start, end, duration, callback) => {
      let startTime = null;
      const step = (timestamp) => {
        if (!startTime) startTime = timestamp;
        const progress = Math.min((timestamp - startTime) / duration, 1);
        const value = Math.floor(start + (end - start) * progress);
        callback(value);
        if (progress < 1) requestAnimationFrame(step);
      };
      requestAnimationFrame(step);
    };

    // salesData 변경 시 애니메이션 적용
    watch(
      () => rowTotals.value,
      (newTotals, oldTotals) => {
        newTotals.forEach((total, index) => {
          if (total !== oldTotals[index]) {
            animateValue(oldTotals[index] || 0, total, 500, (value) => {
              animatedTotals.value[index] = value;
            });
          }
        });
      },
      { immediate: true } // 초기 값 설정
    );

    const logChange = (region, month, newValue) => {
      if (newValue < 0) {
        alert('판매량은 음수가 될 수 없습니다!');
        return;
      }
      console.log(`${region}의 ${month} 판매량이 ${newValue}로 변경됨`);
    };

    return {
      months,
      salesData,
      animatedTotals,
      logChange
    };
  }
};
</script>

<style>
.sales-grid {
  padding: 20px;
  max-width: 800px;
}
table {
  width: 100%;
  border-collapse: collapse;
}
th, td {
  border: 1px solid #ccc;
  padding: 8px;
  text-align: center;
}
th {
  background-color: #f4f4f4;
}
input[type="number"] {
  width: 60px;
  padding: 4px;
  text-align: center;
}
</style>
동작 방식
  1. animatedTotals는 화면에 표시될 애니메이션 값.
  2. rowTotals는 실제 합계를 계산.
  3. watchrowTotals 변화를 감지하면, animateValue 함수로 이전 값(oldTotals)에서 새 값(newTotals)까지 0.5초 동안 보간.
  4. requestAnimationFrame으로 부드러운 애니메이션 구현.
장점
  • 숫자가 실제로 시작값에서 끝값까지 스무스하게 변함.
  • 애니메이션 속도와 타이밍을 정밀하게 조정 가능.
단점
  • 코드가 약간 복잡해지고, 성능에 약간의 영향을 줄 수 있음.

추천
  • 간단한 페이딩: 방법 1(CSS)을 사용하면 구현이 빠르고 가볍습니다.
  • 숫자 보간 애니메이션: 방법 2(JavaScript)를 사용하면 더 프로페셔널한 UI를 제공할 수 있습니다. 실무에서 사용자 경험을 중시한다면 이 방법을 추천합니다.
추가로 외부 라이브러리(예: gsap)를 사용하면 더 쉽게 구현할 수도 있습니다. 어떤 방향으로 더 발전시키고 싶으신지 알려주시면 추가로 도와드리겠습니다!
728x90