← 목록으로

챕터 16: CSS 애니메이션 기초

서론

정적인 웹페이지는 이제 과거의 일입니다. 현대 웹은 움직이고, 변화하고, 사용자와 상호작용합니다. CSS 애니메이션은 웹페이지에 생명을 불어넣는 마법과 같습니다. 버튼이 부드럽게 색이 변하고, 카드가 우아하게 뒤집히며, 메뉴가 자연스럽게 슬라이드되는 것 - 이 모든 것이 CSS 애니메이션으로 가능합니다.

이번 챕터에서는 CSS 애니메이션의 기본 원리부터 시작하여, transition, transform, 그리고 @keyframes를 활용한 다양한 애니메이션 기법을 배워보겠습니다. 애니메이션을 적절히 사용하면 사용자 경험을 크게 향상시킬 수 있습니다.

본론

Transition - 부드러운 상태 변화

Transition은 CSS 속성이 변할 때 부드럽게 전환되도록 만들어줍니다:

'use client'

import { useState } from 'react'

export default function TransitionBasics() {
  const [isHovered, setIsHovered] = useState(false)
  const [isClicked, setIsClicked] = useState(false)
  const [activeBox, setActiveBox] = useState(null)
  
  return (
    <div className="p-8">
      <h2 className="text-2xl font-bold mb-6">Transition 기초</h2>
      
      {/* 기본 transition */}
      <div className="mb-8">
        <h3 className="font-bold mb-3">색상 전환</h3>
        <button className="bg-blue-500 hover:bg-purple-600 text-white px-6 py-3 rounded transition-colors duration-300">
          마우스를 올려보세요 (300ms)
        </button>
      </div>
      
      {/* 여러 속성 transition */}
      <div className="mb-8">
        <h3 className="font-bold mb-3">여러 속성 전환</h3>
        <div className="flex gap-4">
          <div className="bg-gray-200 hover:bg-blue-500 hover:scale-110 hover:rotate-3 p-8 rounded transition-all duration-500 cursor-pointer">
            <p className="text-center">전체 속성</p>
          </div>
          
          <div className="bg-gray-200 p-8 rounded cursor-pointer transition-transform hover:scale-110 duration-300">
            <p className="text-center">크기만</p>
          </div>
          
          <div className="bg-gray-200 p-8 rounded cursor-pointer hover:shadow-xl transition-shadow duration-700">
            <p className="text-center">그림자만</p>
          </div>
        </div>
      </div>
      
      {/* Timing Functions */}
      <div className="mb-8">
        <h3 className="font-bold mb-3">Timing Functions</h3>
        <div className="space-y-2">
          <div 
            className="bg-red-500 text-white p-4 rounded transition-all duration-1000 ease-linear"
            style={{ marginLeft: isHovered ? '200px' : '0px' }}
            onMouseEnter={() => setIsHovered(true)}
            onMouseLeave={() => setIsHovered(false)}
          >
            Linear (일정한 속도)
          </div>
          
          <div 
            className="bg-green-500 text-white p-4 rounded transition-all duration-1000 ease-in"
            style={{ marginLeft: isHovered ? '200px' : '0px' }}
          >
            Ease-in (천천히 시작)
          </div>
          
          <div 
            className="bg-blue-500 text-white p-4 rounded transition-all duration-1000 ease-out"
            style={{ marginLeft: isHovered ? '200px' : '0px' }}
          >
            Ease-out (천천히 끝)
          </div>
          
          <div 
            className="bg-purple-500 text-white p-4 rounded transition-all duration-1000 ease-in-out"
            style={{ marginLeft: isHovered ? '200px' : '0px' }}
          >
            Ease-in-out (양쪽 천천히)
          </div>
        </div>
      </div>
      
      {/* Delay 효과 */}
      <div className="mb-8">
        <h3 className="font-bold mb-3">Delay 효과</h3>
        <div 
          className="group"
          onMouseEnter={() => setActiveBox('delay')}
          onMouseLeave={() => setActiveBox(null)}
        >
          <div className="flex gap-2">
            {[0, 100, 200, 300, 400].map((delay, index) => (
              <div
                key={index}
                className="w-16 h-16 bg-gradient-to-r from-blue-400 to-purple-500 rounded transition-transform duration-300"
                style={{
                  transitionDelay: `${delay}ms`,
                  transform: activeBox === 'delay' ? 'translateY(-20px)' : 'translateY(0)'
                }}
              />
            ))}
          </div>
          <p className="text-sm text-gray-600 mt-2">마우스를 올려보세요</p>
        </div>
      </div>
      
      {/* 상태 변화 예제 */}
      <div>
        <h3 className="font-bold mb-3">클릭 상태 변화</h3>
        <button
          onClick={() => setIsClicked(!isClicked)}
          className={`px-8 py-4 rounded-lg font-bold transition-all duration-500 ${
            isClicked 
              ? 'bg-green-500 text-white scale-110 rotate-3' 
              : 'bg-gray-200 text-gray-700 scale-100 rotate-0'
          }`}
        >
          {isClicked ? '활성화됨!' : '클릭하세요'}
        </button>
      </div>
    </div>
  )
}

Transform - 요소 변형하기

Transform을 사용하면 요소를 회전, 확대/축소, 이동, 기울이기 등 다양한 변형을 할 수 있습니다:

'use client'

import { useState } from 'react'

export default function TransformEffects() {
  const [activeTransform, setActiveTransform] = useState('')
  
  return (
    <div className="p-8">
      <h2 className="text-2xl font-bold mb-6">Transform 효과</h2>
      
      {/* Transform 종류별 예제 */}
      <div className="grid grid-cols-3 gap-6 mb-8">
        {/* Rotate */}
        <div className="text-center">
          <h3 className="font-bold mb-3">Rotate (회전)</h3>
          <div className="bg-blue-500 text-white p-8 rounded inline-block hover:rotate-45 transition-transform duration-500">
            45도 회전
          </div>
        </div>
        
        {/* Scale */}
        <div className="text-center">
          <h3 className="font-bold mb-3">Scale (크기)</h3>
          <div className="bg-green-500 text-white p-8 rounded inline-block hover:scale-125 transition-transform duration-500">
            1.25배 확대
          </div>
        </div>
        
        {/* Translate */}
        <div className="text-center">
          <h3 className="font-bold mb-3">Translate (이동)</h3>
          <div className="bg-purple-500 text-white p-8 rounded inline-block hover:translate-x-8 hover:-translate-y-4 transition-transform duration-500">
            X:32px Y:-16px
          </div>
        </div>
        
        {/* Skew */}
        <div className="text-center">
          <h3 className="font-bold mb-3">Skew (기울이기)</h3>
          <div className="bg-red-500 text-white p-8 rounded inline-block hover:skew-x-12 transition-transform duration-500">
            X축 기울이기
          </div>
        </div>
        
        {/* 복합 Transform */}
        <div className="text-center">
          <h3 className="font-bold mb-3">복합 효과</h3>
          <div className="bg-gradient-to-r from-pink-500 to-yellow-500 text-white p-8 rounded inline-block hover:rotate-12 hover:scale-110 hover:translate-x-4 transition-transform duration-500">
            복합 변형
          </div>
        </div>
        
        {/* 3D Transform */}
        <div className="text-center">
          <h3 className="font-bold mb-3">3D 효과</h3>
          <div className="bg-indigo-500 text-white p-8 rounded inline-block hover:rotate-y-180 transition-transform duration-700" style={{ transformStyle: 'preserve-3d' }}>
            Y축 회전
          </div>
        </div>
      </div>
      
      {/* Transform Origin */}
      <div className="mb-8">
        <h3 className="font-bold mb-3">Transform Origin (변형 기준점)</h3>
        <div className="flex gap-4">
          <div className="relative">
            <div className="bg-orange-500 text-white p-6 rounded hover:rotate-45 transition-transform duration-500 origin-top-left">
              왼쪽 위
            </div>
            <p className="text-xs mt-1">origin-top-left</p>
          </div>
          
          <div className="relative">
            <div className="bg-orange-500 text-white p-6 rounded hover:rotate-45 transition-transform duration-500 origin-center">
              중앙
            </div>
            <p className="text-xs mt-1">origin-center</p>
          </div>
          
          <div className="relative">
            <div className="bg-orange-500 text-white p-6 rounded hover:rotate-45 transition-transform duration-500 origin-bottom-right">
              오른쪽 아래
            </div>
            <p className="text-xs mt-1">origin-bottom-right</p>
          </div>
        </div>
      </div>
      
      {/* 카드 플립 효과 */}
      <div>
        <h3 className="font-bold mb-3">카드 플립 효과</h3>
        <div className="flex gap-4">
          {['프로젝트 A', '프로젝트 B', '프로젝트 C'].map((title, index) => (
            <div 
              key={index}
              className="relative w-48 h-64 cursor-pointer group"
              style={{ perspective: '1000px' }}
            >
              <div className="absolute inset-0 transition-transform duration-700 group-hover:rotate-y-180" style={{ transformStyle: 'preserve-3d' }}>
                {/* 앞면 */}
                <div className="absolute inset-0 bg-gradient-to-br from-blue-500 to-purple-600 rounded-lg p-6 text-white backface-hidden">
                  <h4 className="text-xl font-bold mb-2">{title}</h4>
                  <p className="text-sm">마우스를 올려보세요</p>
                </div>
                
                {/* 뒷면 */}
                <div className="absolute inset-0 bg-gradient-to-br from-green-500 to-teal-600 rounded-lg p-6 text-white rotate-y-180 backface-hidden">
                  <h4 className="text-xl font-bold mb-2">상세 정보</h4>
                  <p className="text-sm">프로젝트 설명이 여기에 표시됩니다</p>
                </div>
              </div>
            </div>
          ))}
        </div>
      </div>
    </div>
  )
}

@keyframes 애니메이션

더 복잡한 애니메이션을 만들려면 @keyframes를 사용합니다:

'use client'

import { useState } from 'react'

export default function KeyframesAnimation() {
  const [isAnimating, setIsAnimating] = useState(false)
  
  return (
    <div className="p-8">
      <h2 className="text-2xl font-bold mb-6">Keyframes 애니메이션</h2>
      
      <style jsx>{`
        @keyframes slide {
          0% { transform: translateX(0); }
          50% { transform: translateX(200px); }
          100% { transform: translateX(0); }
        }
        
        @keyframes rainbow {
          0% { background-color: red; }
          16% { background-color: orange; }
          33% { background-color: yellow; }
          50% { background-color: green; }
          66% { background-color: blue; }
          83% { background-color: indigo; }
          100% { background-color: violet; }
        }
        
        @keyframes heartbeat {
          0% { transform: scale(1); }
          25% { transform: scale(1.1); }
          50% { transform: scale(1); }
          75% { transform: scale(1.1); }
          100% { transform: scale(1); }
        }
        
        @keyframes shake {
          0%, 100% { transform: translateX(0); }
          10%, 30%, 50%, 70%, 90% { transform: translateX(-10px); }
          20%, 40%, 60%, 80% { transform: translateX(10px); }
        }
        
        @keyframes typing {
          from { width: 0; }
          to { width: 100%; }
        }
        
        @keyframes blink {
          50% { opacity: 0; }
        }
        
        .animate-slide {
          animation: slide 3s ease-in-out infinite;
        }
        
        .animate-rainbow {
          animation: rainbow 5s linear infinite;
        }
        
        .animate-heartbeat {
          animation: heartbeat 1s ease-in-out infinite;
        }
        
        .animate-shake {
          animation: shake 0.5s ease-in-out;
        }
        
        .typing-effect {
          width: 0;
          overflow: hidden;
          white-space: nowrap;
          border-right: 3px solid;
          animation: typing 3s steps(30) forwards, blink 0.5s step-end infinite;
        }
      `}</style>
      
      {/* 기본 애니메이션 */}
      <div className="mb-8">
        <h3 className="font-bold mb-3">기본 애니메이션</h3>
        <div className="space-y-4">
          {/* 슬라이드 */}
          <div className="bg-gray-100 p-4 rounded">
            <div className="animate-slide bg-blue-500 text-white p-4 rounded inline-block">
              왕복 이동
            </div>
          </div>
          
          {/* 무지개 색상 */}
          <div className="animate-rainbow text-white p-6 rounded font-bold text-center">
            무지개 색상 변화
          </div>
        </div>
      </div>
      
      {/* 실용적인 애니메이션 */}
      <div className="mb-8">
        <h3 className="font-bold mb-3">실용적인 애니메이션</h3>
        
        {/* 하트비트 */}
        <div className="mb-4">
          <button className="animate-heartbeat bg-red-500 text-white px-6 py-3 rounded-full">
            ❤️ 좋아요
          </button>
        </div>
        
        {/* 흔들기 */}
        <div className="mb-4">
          <button
            onClick={() => {
              setIsAnimating(true)
              setTimeout(() => setIsAnimating(false), 500)
            }}
            className={`bg-green-500 text-white px-6 py-3 rounded ${
              isAnimating ? 'animate-shake' : ''
            }`}
          >
            클릭하면 흔들림
          </button>
        </div>
        
        {/* 타이핑 효과 */}
        <div className="bg-gray-900 text-green-400 p-6 rounded font-mono">
          <div className="typing-effect">
            Hello, World! 타이핑 효과입니다...
          </div>
        </div>
      </div>
      
      {/* 로딩 애니메이션 */}
      <div className="mb-8">
        <h3 className="font-bold mb-3">로딩 애니메이션</h3>
        <div className="flex gap-8">
          {/* 스피너 */}
          <div className="text-center">
            <div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-500 mx-auto"></div>
            <p className="text-sm mt-2">스피너</p>
          </div>
          
          {/* 펄스 */}
          <div className="text-center">
            <div className="animate-pulse bg-blue-500 h-12 w-12 rounded-full mx-auto"></div>
            <p className="text-sm mt-2">펄스</p>
          </div>
          
          {/* 바운스 */}
          <div className="text-center">
            <div className="animate-bounce bg-green-500 h-12 w-12 rounded-full mx-auto"></div>
            <p className="text-sm mt-2">바운스</p>
          </div>
          
          {/* 핑 */}
          <div className="text-center">
            <div className="relative h-12 w-12 mx-auto">
              <div className="animate-ping absolute inset-0 bg-purple-500 rounded-full opacity-75"></div>
              <div className="relative bg-purple-500 h-12 w-12 rounded-full"></div>
            </div>
            <p className="text-sm mt-2">핑</p>
          </div>
        </div>
      </div>
      
      {/* 복잡한 애니메이션 시퀀스 */}
      <div>
        <h3 className="font-bold mb-3">애니메이션 시퀀스</h3>
        <style jsx>{`
          @keyframes sequence {
            0% { 
              transform: translateX(0) scale(1);
              background-color: #3B82F6;
            }
            25% { 
              transform: translateX(100px) scale(1.2);
              background-color: #10B981;
            }
            50% { 
              transform: translateX(100px) translateY(50px) scale(1);
              background-color: #F59E0B;
            }
            75% { 
              transform: translateX(0) translateY(50px) scale(0.8);
              background-color: #EF4444;
            }
            100% { 
              transform: translateX(0) translateY(0) scale(1);
              background-color: #3B82F6;
            }
          }
          
          .animate-sequence {
            animation: sequence 4s ease-in-out infinite;
          }
        `}</style>
        
        <div className="bg-gray-100 p-8 rounded relative" style={{ height: '150px' }}>
          <div className="animate-sequence text-white p-4 rounded inline-block">
            복잡한 시퀀스
          </div>
        </div>
      </div>
    </div>
  )
}

실습: 인터랙티브 애니메이션 갤러리

애니메이션을 활용한 실용적인 갤러리를 만들어봅시다:

'use client'

import { useState } from 'react'

export default function AnimatedGallery() {
  const [selectedImage, setSelectedImage] = useState(null)
  const [filter, setFilter] = useState('all')
  
  const images = [
    { id: 1, category: 'nature', title: '산', color: 'from-green-400 to-blue-500' },
    { id: 2, category: 'city', title: '도시', color: 'from-purple-400 to-pink-500' },
    { id: 3, category: 'nature', title: '바다', color: 'from-blue-400 to-cyan-500' },
    { id: 4, category: 'abstract', title: '추상', color: 'from-yellow-400 to-red-500' },
    { id: 5, category: 'city', title: '야경', color: 'from-indigo-400 to-purple-500' },
    { id: 6, category: 'abstract', title: '패턴', color: 'from-pink-400 to-rose-500' }
  ]
  
  const filteredImages = filter === 'all' 
    ? images 
    : images.filter(img => img.category === filter)
  
  return (
    <div className="p-8 max-w-6xl mx-auto">
      <h2 className="text-3xl font-bold mb-8 text-center">
        애니메이션 갤러리
      </h2>
      
      <style jsx>{`
        @keyframes fadeInUp {
          from {
            opacity: 0;
            transform: translateY(30px);
          }
          to {
            opacity: 1;
            transform: translateY(0);
          }
        }
        
        @keyframes zoomIn {
          from {
            opacity: 0;
            transform: scale(0.3);
          }
          to {
            opacity: 1;
            transform: scale(1);
          }
        }
        
        .fade-in-up {
          animation: fadeInUp 0.6s ease-out forwards;
        }
        
        .zoom-in {
          animation: zoomIn 0.5s ease-out;
        }
        
        @keyframes float {
          0%, 100% { transform: translateY(0); }
          50% { transform: translateY(-10px); }
        }
        
        .float {
          animation: float 3s ease-in-out infinite;
        }
      `}</style>
      
      {/* 필터 버튼 */}
      <div className="flex justify-center gap-4 mb-8">
        {['all', 'nature', 'city', 'abstract'].map((category) => (
          <button
            key={category}
            onClick={() => setFilter(category)}
            className={`px-6 py-2 rounded-full font-medium transition-all duration-300 ${
              filter === category
                ? 'bg-blue-500 text-white scale-110'
                : 'bg-gray-200 hover:bg-gray-300'
            }`}
          >
            {category === 'all' ? '전체' : 
             category === 'nature' ? '자연' :
             category === 'city' ? '도시' : '추상'}
          </button>
        ))}
      </div>
      
      {/* 이미지 그리드 */}
      <div className="grid grid-cols-3 gap-6">
        {filteredImages.map((image, index) => (
          <div
            key={image.id}
            className="fade-in-up relative group cursor-pointer"
            style={{ animationDelay: `${index * 0.1}s` }}
            onClick={() => setSelectedImage(image)}
          >
            <div className={`h-64 bg-gradient-to-br ${image.color} rounded-lg transition-all duration-500 group-hover:scale-105 group-hover:shadow-2xl`}>
              <div className="absolute inset-0 flex items-center justify-center">
                <h3 className="text-white text-2xl font-bold opacity-0 group-hover:opacity-100 transition-opacity duration-300">
                  {image.title}
                </h3>
              </div>
            </div>
            
            {/* 호버 효과 오버레이 */}
            <div className="absolute inset-0 bg-black opacity-0 group-hover:opacity-20 transition-opacity duration-300 rounded-lg"></div>
            
            {/* 카테고리 태그 */}
            <span className="absolute top-4 right-4 bg-white px-3 py-1 rounded-full text-sm font-medium opacity-0 group-hover:opacity-100 transition-opacity duration-300">
              {image.category}
            </span>
          </div>
        ))}
      </div>
      
      {/* 모달 */}
      {selectedImage && (
        <div 
          className="fixed inset-0 bg-black bg-opacity-75 flex items-center justify-center z-50 p-8"
          onClick={() => setSelectedImage(null)}
        >
          <div 
            className="zoom-in bg-white rounded-lg p-8 max-w-2xl w-full"
            onClick={(e) => e.stopPropagation()}
          >
            <div className={`h-96 bg-gradient-to-br ${selectedImage.color} rounded-lg mb-4 float`}></div>
            <h3 className="text-2xl font-bold mb-2">{selectedImage.title}</h3>
            <p className="text-gray-600 mb-4">카테고리: {selectedImage.category}</p>
            <button
              onClick={() => setSelectedImage(null)}
              className="bg-gray-500 text-white px-6 py-2 rounded hover:bg-gray-600 transition-colors"
            >
              닫기
            </button>
          </div>
        </div>
      )}
      
      {/* 플로팅 버튼 */}
      <div className="fixed bottom-8 right-8">
        <button className="bg-blue-500 text-white p-4 rounded-full shadow-lg hover:bg-blue-600 transition-colors float">
          <svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
            <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
          </svg>
        </button>
      </div>
    </div>
  )
}

성능 최적화 팁

애니메이션 성능을 최적화하는 방법:

'use client'

export default function PerformanceTips() {
  return (
    <div className="p-8 max-w-4xl mx-auto">
      <h2 className="text-2xl font-bold mb-6">애니메이션 성능 최적화</h2>
      
      {/* Transform vs Position */}
      <div className="mb-8">
        <h3 className="font-bold mb-3">✅ Transform 사용 (GPU 가속)</h3>
        <div className="bg-green-100 p-4 rounded mb-4">
          <div className="bg-green-500 text-white p-4 rounded inline-block hover:translate-x-20 transition-transform duration-500">
            transform: translateX() 사용
          </div>
        </div>
        
        <h3 className="font-bold mb-3">❌ Position 변경 (리플로우 발생)</h3>
        <div className="bg-red-100 p-4 rounded">
          <div className="bg-red-500 text-white p-4 rounded inline-block relative hover:left-20 transition-all duration-500">
            left 속성 사용 (비효율적)
          </div>
        </div>
      </div>
      
      {/* will-change 속성 */}
      <div className="mb-8">
        <h3 className="font-bold mb-3">will-change 속성</h3>
        <p className="text-gray-600 mb-3">
          브라우저에게 변경될 속성을 미리 알려줍니다
        </p>
        <div className="bg-blue-500 text-white p-4 rounded inline-block hover:scale-110 transition-transform duration-500" style={{ willChange: 'transform' }}>
          will-change: transform
        </div>
      </div>
      
      {/* 애니메이션 최적화 팁 */}
      <div className="bg-gray-100 rounded-lg p-6">
        <h3 className="font-bold mb-4">성능 최적화 체크리스트</h3>
        <ul className="space-y-2">
          <li className="flex items-start">
            <span className="text-green-500 mr-2">✓</span>
            <span>transform과 opacity만 애니메이션하기</span>
          </li>
          <li className="flex items-start">
            <span className="text-green-500 mr-2">✓</span>
            <span>will-change 속성 활용하기</span>
          </li>
          <li className="flex items-start">
            <span className="text-green-500 mr-2">✓</span>
            <span>60fps 유지를 위해 16ms 내에 애니메이션 완료</span>
          </li>
          <li className="flex items-start">
            <span className="text-green-500 mr-2">✓</span>
            <span>불필요한 애니메이션 줄이기</span>
          </li>
          <li className="flex items-start">
            <span className="text-green-500 mr-2">✓</span>
            <span>모바일에서는 더 단순한 애니메이션 사용</span>
          </li>
        </ul>
      </div>
    </div>
  )
}

결론

CSS 애니메이션은 웹페이지에 생동감을 더하는 강력한 도구입니다. 이번 챕터에서 우리는 transition을 통한 부드러운 상태 변화, transform을 활용한 요소 변형, 그리고 @keyframes를 사용한 복잡한 애니메이션까지 다양한 기법을 배웠습니다.

애니메이션을 사용할 때 중요한 원칙들:

  1. 목적이 있는 애니메이션: 단순히 화려함을 위한 것이 아닌, UX를 개선하는 애니메이션
  2. 성능 고려: transform과 opacity를 우선 사용하여 60fps 유지
  3. 일관성: 사이트 전체에서 일관된 애니메이션 스타일 유지
  4. 접근성: 애니메이션을 선호하지 않는 사용자를 위한 옵션 제공
  5. 적절한 타이밍: 너무 빠르거나 느리지 않은 자연스러운 속도

다음 챕터에서는 Tailwind CSS의 애니메이션 유틸리티를 활용하여 더욱 쉽고 빠르게 인터랙티브한 효과를 만드는 방법을 배워보겠습니다!