애니메이션 디버깅 툴 만들기-for-gsap

나를 위한 애니메이션 디버깅 툴 만들기

문제의 발단: 완벽한 툴이 내 환경에는 쓸모없다는 현실

Theatre.js, anime.js, p5.js, gsap를 이리저리 둘러보았다. 그런데 직관적으로 애프터 이펙트마냥 키프레임을 삽입 후 내가 이리저리 드래그 해보며 맘에 드는 위치값을 지정하지 못한다는 것은 생각보다 더 번거롭다.

드래그 앤 드롭으로 요소들을 이리저리 옮기고, 타임라인을 시각적으로 조작하고, 실시간으로 애니메이션을 미세 조정할 수 있는 걸 구현한 건 theatre.js라서 이걸 좀 파봤다.

graph TD
    A[Theatre.js Studio] --> B[시각적 편집]
    A --> C[타임라인 제어]
    A --> D[실시간 미리보기]
    
    E[내 스택] --> F[Remotion]
    E --> G[GSAP]
    E --> H[P5.js]
    E --> I[Three.js]
    
    B -.-> J[변환 작업 필요]
    C -.-> J
    D -.-> J
    J --> K[비디오 렌더링]

문제는 이 완벽한 툴이 내 개발 환경과는 상극이라는 점이다. 내가 만드는 애니메이션들은 최종적으로 Remotion을 통해 비디오로 렌더링되어야 한다. Theatre.js에서 아무리 이상적인 위치값과 이징값을 찾아도, 그걸 Remotion + GSAP + P5 + Three.js 스택에 맞춰 변환하는 작업이 필요하다.

이게 얼마나 품이 드는 일인지…ㅠㅠ

그래서 만들었다: 맞춤형 디버깅 시스템

🎯 핵심 요구사항 정리

내가 필요로 하는 기능들을 정리해보면 아래와 같다.

기능 설명 우선순위
드래그 앤 드롭 요소를 마우스로 직접 이동 🔥🔥🔥
실시간 값 표시 변경되는 CSS 값을 팝업으로 표시 🔥🔥🔥
타임라인 제어 정확한 타이밍에 정지/재생 🔥🔥
디버그 박스 모든 요소에 경계선 표시 🔥

🛠️ 상태 관리부터 시작하자

먼저 디버깅에 필요한 상태들을 Zustand로 관리하는 스토어를 만들었다:

// debugStore.ts
import { create } from 'zustand';

interface DebugState {
  selectedElement: HTMLElement | null;
  currentTime: number;
  isPlaying: boolean;
  showDebugBoxes: boolean;
  elementProperties: {
    top: string;
    left: string;
    scale: number;
    rotation: number;
  };
}

export const useDebugStore = create<DebugState>((set) => ({
  selectedElement: null,
  currentTime: 0,
  isPlaying: false,
  showDebugBoxes: false,
  elementProperties: {
    top: '0%',
    left: '0%',
    scale: 1,
    rotation: 0
  },
  // ... 액션들
}));

상태 관리는 단순하게 가져갔다. 복잡한 건 나중에 필요할 때 추가하면 된다.

🎮 타임라인 컨트롤러: 프레임 단위로 조작하기

const TimelineControls = () => {
  const { currentTime, isPlaying, setCurrentTime, togglePlay } = useDebugStore();
  
  return (
    <div className={styles.timelineControls}>
      <input 
        type="range"
        min="0"
        max="5"
        step="0.1"
        value={currentTime}
        onChange={(e) => setCurrentTime(parseFloat(e.target.value))}
        className={styles.timelineSlider}
      />
      <div className={styles.controlButtons}>
        <button onClick={() => setCurrentTime(Math.max(0, currentTime - 0.1))}>
          
        </button>
        <button onClick={togglePlay}>
          {isPlaying ? '⏸️' : '▶️'}
        </button>
        <button onClick={() => setCurrentTime(Math.min(5, currentTime + 0.1))}>
          
        </button>
      </div>
      <span className={styles.timeDisplay}>
        {currentTime.toFixed(1)}s
      </span>
    </div>
  );
};

이 부분에서 중요한 건 GSAP 타임라인과의 동기화다. 디버그 컨트롤에서 시간을 조작하면 실제 애니메이션도 해당 지점으로 이동해야 한다.

GSAP와의 동기화: 여기서 진짜 문제가 시작된다

타임라인 동기화 구현

useEffect(() => {
  const timeline = gsap.timeline({ paused: true });
  
  // 기존 애니메이션 설정
  timeline
    .to(textRef.current, {
      duration: 1.5,
      top: '30%',
      left: '20%',
      scale: 1.5,
      fontSize: '30px',
      ease: 'power2.out'
    })
    .to(textRef.current, {
      duration: 1.5,
      top: '10%',
      left: '20%',
      scale: 1,
      fontSize: '20px',
      rotation: 360,
      ease: 'power2.inOut'
    });

  // 디버그 컨트롤과 동기화
  if (debugStore.isPlaying) {
    timeline.play();
  } else {
    timeline.pause();
  }
  
  timeline.seek(debugStore.currentTime);
  
}, [debugStore.isPlaying, debugStore.currentTime]);

여기서 핵심은 timeline.seek(debugStore.currentTime)이다. 이 한 줄로 타임라인의 특정 지점으로 점프할 수 있다.

드래그 앤 드롭: 직관적 조작의 구현

드래그 기능은 생각보다 까다롭다. 단순히 마우스 이벤트만 처리하면 되는 게 아니라, 애니메이션 중인 요소의 현재 상태를 정확히 파악해야 한다.

const makeDraggable = (element: HTMLElement) => {
  let isDragging = false;
  let startX: number, startY: number;
  let elementX: number, elementY: number;

  const handleMouseDown = (e: MouseEvent) => {
    isDragging = true;
    debugStore.setSelectedElement(element);
    
    // 현재 transform 값 파싱
    const computedStyle = window.getComputedStyle(element);
    const matrix = new DOMMatrix(computedStyle.transform);
    
    startX = e.clientX;
    startY = e.clientY;
    elementX = matrix.m41; // translateX
    elementY = matrix.m42; // translateY
    
    document.addEventListener('mousemove', handleMouseMove);
    document.addEventListener('mouseup', handleMouseUp);
  };

  const handleMouseMove = (e: MouseEvent) => {
    if (!isDragging) return;
    
    const deltaX = e.clientX - startX;
    const deltaY = e.clientY - startY;
    
    const newX = elementX + deltaX;
    const newY = elementY + deltaY;
    
    // 퍼센트로 변환 (1080p 기준)
    const leftPercent = (newX / 1920) * 100;
    const topPercent = (newY / 1080) * 100;
    
    // 실시간 업데이트
    gsap.set(element, {
      left: `${leftPercent}%`,
      top: `${topPercent}%`
    });
    
    // 상태 업데이트
    debugStore.updateElementProperties({
      left: `${leftPercent.toFixed(1)}%`,
      top: `${topPercent.toFixed(1)}%`
    });
  };
};

디버그 박스: 모든 요소를 한눈에

// debug.module.scss
.debug {
  * {
    border: 2px solid #{randomColor()} !important;
    position: relative;
    
    &::before {
      content: attr(class);
      position: absolute;
      top: -20px;
      left: 0;
      background: rgba(0, 0, 0, 0.8);
      color: white;
      font-size: 10px;
      padding: 2px 4px;
      z-index: 9999;
    }
  }
}

@function randomColor() {
  $colors: #ff6b6b, #4ecdc4, #45b7d1, #96ceb4, #ffeaa7, #dda0dd, #98d8c8;
  @return nth($colors, random(length($colors)));
}

이 CSS 트릭으로 모든 요소에 랜덤한 색상의 경계선을 표시할 수 있다. 디버그 모드를 켜면 레이아웃이 한눈에 보인다.

실제 사용 예시: Day55Sketch 개선하기

const Day55Sketch = () => {
  const containerRef = useRef<HTMLDivElement>(null);
  const textRef = useRef<HTMLHeadingElement>(null);
  const debugStore = useDebugStore();

  return (
    <>
      <div 
        className={`${styles.scaleContainer} ${debugStore.showDebugBoxes ? styles.debug : ''}`}
        ref={containerRef}
      >
        <div className={styles.videoContainer}>
          <GsapControlWrapper targetRef={textRef}>
            <h1 
              ref={textRef}
              className={styles.mainHeading}
              style={{
                position: 'absolute',
                top: '10%',
                left: '20%',
                fontSize: '20px'
              }}
            >
              애니메이션 테스트
            </h1>
          </GsapControlWrapper>
        </div>
      </div>
      
      <DebugPanel />
    </>
  );
};

한계와 개선점

🚨 현재 문제점들

  1. 복잡한 Transform 계산: CSS transform과 GSAP의 transform이 겹치면 계산이 복잡해진다
  2. 성능 이슈: 실시간 드래그 시 렌더링 부하
  3. 중첩된 애니메이션: 여러 타임라인이 겹치면 동기화가 어렵다

💡 개선 방향

// 향후 개선 계획
interface EnhancedDebugState {
  // 다중 선택 지원
  selectedElements: HTMLElement[];
  
  // 키프레임 편집
  keyframes: {
    time: number;
    properties: CSSProperties;
  }[];
  
  // 애니메이션 프리셋
  presets: {
    name: string;
    timeline: GSAPTimeline;
  }[];
}

결론: 완벽하지 않아도 일단은 내가 쓸 만하니 걍 쓰기로

Theatre.js만큼 완벽하지는 않지만, 내 워크플로우에는 딱 맞는 툴이 나왔다. 가장 중요한 건 실제로 사용할 수 있다는 점이다.

다른 팀 멤버가 “여기 텍스트 좀 더 위로 올려주세요”라고 하면, 이제 코드를 뒤져가며 픽셀값을 찾을 필요가 없다. 그냥 드래그해서 옮기고, 나온 값을 복사해서 붙여넣으면 끝이다.

완벽한 툴을 기다리느라 시간을 낭비하지 말고, 지금 당장 필요한 기능부터 만들어 쓰는 게 답이다. 어차피 개발이란 게 그런 거 아닌가.