ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • 시각 피드백의 시간차 — ripple·fly-up·confetti의 650/900/1100ms
    IT 2026. 5. 15. 21:00
    시각 피드백의 시간차 — ripple·fly-up·confetti의 650/900/1100ms

    음성 챗봇 화면은 글자가 아닌 소리 중심이라 시각 요소가 별로 없습니다. 마이크 버튼 하나, 점수 알약 하나, 답변 텍스트 한 줄. 그래서 작은 시각 피드백 — 버튼이 눌리는 ripple, 점수가 위로 떠오르는 fly-up, 배지 획득 시 confetti — 이 평소보다 큰 비중을 차지합니다.

    이 세 애니메이션이 동시에 발동되는 순간이 있습니다. 사용자가 마이크를 눌러 답을 받고, 그 답을 끝까지 들으면 +5점 보상이 들어가고, 그게 새 배지를 해금하는 경우입니다. 세 가지 시각 효과가 한꺼번에 터지면 화면이 어수선해지는데, 각 애니메이션의 길이를 달리해서 시간차로 정보 위계를 표현한 디자인이 의외로 효과적이었습니다. 650ms·900ms·1100ms 세 숫자에 담긴 발상을 정리합니다.

    왜 시각 피드백이 음성 챗봇에서 더 중요한가

    일반 채팅 앱에서 시각 피드백은 부수적입니다. 텍스트 자체가 주된 정보이고, 메시지가 도착했다·전송됐다 같은 상태는 글자 옆 작은 아이콘으로 충분합니다.

    음성 챗봇은 다릅니다. 사용자가 발화 중에는 화면을 안 봅니다(마이크에 가까이 갔거나 천장을 보고 있거나). 답을 듣는 동안에도 다른 일을 할 수 있습니다. 그래서 화면을 보는 순간이 짧고 띄엄띄엄합니다 — 마이크 버튼을 누르는 순간, 답이 도착했음을 알아차리는 순간, 점수가 들어갔는지 확인하는 순간.

    이 짧은 시선 안에서 "방금 무슨 일이 있었나"를 효과적으로 전달하려면 정적 텍스트보다 동적 시각 요소가 효과적입니다. 사용자가 화면을 0.5초만 봐도 ripple이 퍼지는 게 끝나기 전이라면 "방금 내가 눌렀구나"를 인지할 수 있고, fly-up이 진행 중이면 "방금 점수가 들어갔구나"를 인지합니다. 정적 점수 숫자는 변화 자체를 알아차리기 어렵습니다.

    세 애니메이션의 역할

    세 애니메이션 각각이 다른 종류의 정보를 전달합니다.

    1. 마이크 ripple (650ms) — 사용자 입력 응답. "내가 눌렀다"의 즉시 확인.
    2. 점수 fly-up (900ms) — 보상 도착. "+5"라는 숫자가 점수 알약에서 위로 떠오르며 사라짐.
    3. confetti (1100ms) — 배지 획득. 화려한 색종이가 사방으로 흩어짐.

    이 셋이 같은 turn에 모두 발동하면 — 마이크 누르고, 답 듣고, 그 turn에 새 배지가 풀리는 경우 — 1.1초 안에 세 효과가 차례로 마무리됩니다. 동시에 시작하면 어수선하지만, 시간차를 두면 자연스러운 흐름이 됩니다.

    세 애니메이션의 시간차 타임라인

    그림 설명 — 세 애니메이션이 같은 시각(0ms)에 시작되지만 끝나는 시각은 각각 650ms, 900ms, 1100ms입니다. 길이의 차이가 정보의 무게 차이를 시각적으로 표현합니다 — 짧은 ripple은 일상적인 입력, 긴 confetti는 특별한 순간(배지 획득)이라는 의미가 자연스럽게 전달됩니다. 동시에 시작해도 끝이 다르므로 사용자 시선이 짧은 것에서 긴 것으로 자연스럽게 옮겨갑니다.

    왜 동시 시작·다른 끝이 자연스러운가

    처음엔 세 애니메이션을 시간차 발동으로 짤까 했습니다 — ripple 끝나면 fly-up, fly-up 끝나면 confetti. 그렇게 짜면 1100ms + 900ms + 650ms = 2.65초 동안 화면이 정신 없이 움직입니다. 어수선하고, 사용자 입장에서는 "어 뭔가 많이 일어났네"라는 막연한 인상만 남습니다.

    동시 시작·다른 끝 디자인은 다릅니다. 모든 효과가 같은 순간에 시작하니 "지금 일어났다"는 신호가 강하게 전달됩니다. 그러나 끝나는 시각이 다르니, 사용자 시선이 화면에 머무는 시간에 따라 다른 정보가 전달됩니다. 0.3초만 보면 가장 짧은 ripple이 끝나가는 것만 인지하고, 1초 정도 보면 fly-up까지 인지하고, 1초 이상 머물면 confetti까지 인지합니다. 주의 시간 = 받는 정보의 깊이가 자연스럽게 매칭됩니다.

    또 하나의 효과는 "덜 중요한 효과를 일찍 사라지게 한다"는 것입니다. 마이크 ripple은 매 입력마다 발동되는 일상적 피드백이라 빨리 사라져야 합니다 — 길게 남으면 화면이 어수선합니다. confetti는 새 배지 해금이라는 특별한 순간이니 길게 남아도 부담스럽지 않고 오히려 축하의 무게가 살아납니다. 시간 길이가 곧 정보의 무게가 됩니다.

    코드 — setTimeout과 CSS 애니메이션

    // 1. 마이크 버튼 ripple — pointerdown 즉시 발동, 650ms 후 자동 제거
    function spawnRipple(e) {
      const btn = e.currentTarget;
      const rect = btn.getBoundingClientRect();
      const size = Math.max(rect.width, rect.height);
      const x = (e.clientX || rect.left + rect.width / 2) - rect.left - size / 2;
      const y = (e.clientY || rect.top + rect.height / 2) - rect.top - size / 2;
    
      const ripple = document.createElement('span');
      ripple.className = 'mic-ripple';
      ripple.style.width = ripple.style.height = `${size}px`;
      ripple.style.left = `${x}px`;
      ripple.style.top  = `${y}px`;
      btn.appendChild(ripple);
    
      setTimeout(() => ripple.remove(), 650);
    }
    micBtn.addEventListener('pointerdown', spawnRipple);
    

    코드 설명 — DOM 요소(span)를 동적으로 생성·삽입하고, 650ms 후 setTimeout으로 제거합니다. CSS 측에서 .mic-ripple { animation: ripple-anim 650ms ease-out; } 같이 애니메이션 이름과 길이를 일치시킵니다. JS의 setTimeout 길이와 CSS animation duration을 같이 두는 게 핵심 — 그래야 애니메이션 끝나는 순간 DOM에서도 자동 정리됩니다. 메모리 누수 없이 깔끔합니다.

    // 2. 점수 fly-up — bumpScore에서 +5 텍스트가 위로 떠오르며 사라짐
    function bumpScore(delta = 5) {
      const pill = todayPoints.closest('.score-pill');
      if (!pill) return;
    
      // 점수 알약 자체에 잠깐 강조
      pill.classList.remove('bumping');
      void pill.offsetWidth;          // 강제 리플로우 (애니메이션 재시작 트릭)
      pill.classList.add('bumping');
    
      // "+N" 텍스트가 위로 떠오르는 자식 요소
      const fly = document.createElement('span');
      fly.className = 'score-fly';
      fly.textContent = `+${delta}`;
      pill.appendChild(fly);
    
      setTimeout(() => fly.remove(), 900);
    }
    

    코드 설명 — 흥미로운 트릭이 있는 줄은 void pill.offsetWidth입니다. 같은 클래스를 두 번 추가하면 브라우저가 "이미 있다"고 판단해 애니메이션을 재시작하지 않습니다. 그래서 클래스를 잠깐 제거하고, offsetWidth를 읽어 강제 리플로우를 일으켜 DOM 상태를 갱신한 뒤, 다시 클래스를 추가하면 애니메이션이 처음부터 다시 시작됩니다. 빠른 연속 점수 증가에서도 매번 fresh한 애니메이션이 보장됩니다. 이 패턴은 "force reflow trick"이라고 부릅니다.

    // 3. confetti — 10개 색종이가 사방으로 흩어지며 1100ms 후 자동 제거
    function spawnConfetti(originEl) {
      const rect = (originEl || badgeToast).getBoundingClientRect();
      const cx = rect.left + rect.width / 2;
      const cy = rect.top + rect.height / 2;
      const colors = ['#A78BFA', '#FB7185', '#5EEAD4', '#FBBF24', '#6366F1'];
    
      for (let i = 0; i < 10; i++) {
        const dot = document.createElement('span');
        dot.className = 'confetti';
        dot.style.left = `${cx}px`;
        dot.style.top  = `${cy}px`;
        dot.style.background = colors[i % colors.length];
    
        const angle = (Math.PI * 2) * (i / 10);
        const dist  = 80 + Math.random() * 50;
        dot.style.setProperty('--cx', `${Math.cos(angle) * dist}px`);
        dot.style.setProperty('--cy', `${Math.sin(angle) * dist - 40}px`);
    
        document.body.appendChild(dot);
        setTimeout(() => dot.remove(), 1100);
      }
    }
    

    코드 설명 — 10개의 작은 dom 요소를 만들어 각자 다른 각도로 흩어지게 합니다. CSS Custom Property(--cx, --cy)로 각 요소의 최종 위치를 전달하고, CSS 측 @keyframes가 그 값을 읽어 translate 애니메이션을 만듭니다. 이 패턴이 좋은 이유는 모든 dot의 애니메이션 정의가 CSS 한 곳에 있고, JS는 위치 데이터만 넘긴다는 분리입니다. 애니메이션 곡선·timing function을 디자이너가 CSS만 바꿔서 조정할 수 있습니다.

    "60fps의 부담"을 피하는 한 가지 트릭

    여러 애니메이션이 동시에 도는 페이지는 모바일에서 끊길 수 있습니다. 60fps를 유지하려면 한 프레임당 16ms 안에 모든 그림 그리기가 끝나야 하는데, 페인트가 무거운 효과(그림자·블러·여러 합성 레이어)가 동시에 도면 한계를 넘기 쉽습니다.

    이걸 피하는 한 가지 트릭은 composited 속성만 쓰는 것입니다. transformopacity는 GPU 합성 계층에서 처리되어 페인트 비용 없이 움직일 수 있습니다. 그래서 위 세 애니메이션 모두 transform: translate/scaleopacity만으로 짰습니다. top·left·width·height를 직접 애니메이션하면 매 프레임 레이아웃 재계산이 일어나 60fps를 못 따라가는데, transform은 그렇지 않습니다.

    /* 좋은 패턴 — transform·opacity */
    @keyframes score-fly {
      0%   { opacity: 1; transform: translateY(0); }
      100% { opacity: 0; transform: translateY(-40px); }
    }
    
    /* 안 좋은 패턴 — top·opacity (top은 layout 재계산을 일으킴) */
    @keyframes score-fly-bad {
      0%   { opacity: 1; top: 0; }
      100% { opacity: 0; top: -40px; }
    }
    

    코드 설명 — 같은 시각 효과지만 위쪽은 GPU 합성으로 처리되고 아래쪽은 매 프레임 layout 재계산이 일어납니다. 모바일에서 둘의 차이는 60fps vs 30fps로 갈라질 수 있습니다. 작은 사양 차이지만 동시 애니메이션이 많은 페이지에서 누적되면 큰 차이를 만듭니다. confetti의 10개 dot이 각자 transform으로 흩어지는 게 부드러운 것도 이 덕분입니다.

    마치며 — 마이크로인터랙션의 누적 효과

    각 애니메이션은 1초 미만의 짧은 효과입니다. 그러나 이런 작은 마이크로인터랙션이 누적되면 사용자가 챗봇에 대해 갖는 인상이 크게 달라집니다. ripple이 없으면 "내가 눌렀나? 안 눌렸나?"라는 작은 의심이 생기고, 그게 누적되면 신뢰가 떨어집니다. fly-up이 없으면 점수가 들어갔는지 알기 어렵고, confetti가 없으면 새 배지를 받았다는 사실이 별것 아닌 일처럼 느껴집니다.

    아이의 사용 패턴을 보면 마이크를 누르고 ripple이 퍼지는 0.5초 동안 자기도 모르게 화면에 시선을 주는 게 보입니다. 그 0.5초가 "지금 챗봇이 내 입력을 받았다"라는 작은 확인이고, 그 확인이 다음 발화를 더 편안하게 만듭니다. 시각 피드백이 음성 챗봇이라는 비시각적 매체에서도 사용자 신뢰의 작은 부속품으로 기능하는 거죠.

    650/900/1100ms라는 숫자는 디자인 관행에서 가져온 출발점이고, 사용해보면서 살짝씩 조정한 결과입니다. 정답이 있는 숫자는 아니지만, "덜 중요한 건 짧게, 더 중요한 건 길게"라는 원칙은 여러 애니메이션이 공존하는 화면에서 강건하게 통합니다. 시각 정보의 위계를 시간 길이로 표현하는 디자인 — 작아 보이지만 의외로 통하는 패턴입니다.


    이 글은 생성형 AI의 도움을 받아 작성되었습니다. 원본 자료를 기반으로 AI가 초안을 생성하고, 작성자가 검토·편집하였습니다.

Designed by Tistory.