← 목록으로

Next.js 미디어 삽입

Next.js와 Tailwind CSS로 이미지 및 비디오 삽입 완벽 가이드

서론

현대 웹사이트에서 이미지와 비디오는 콘텐츠의 핵심 요소입니다. 하지만 단순히 HTML의 <img> 태그나 <video> 태그를 사용하는 것만으로는 최적의 사용자 경험을 제공하기 어렵습니다. Next.js의 최적화된 Image 컴포넌트와 Tailwind CSS의 유틸리티 클래스를 활용하면, 성능과 디자인을 모두 잡을 수 있습니다. 이 글에서는 Next.js와 Tailwind CSS를 사용하여 이미지와 비디오를 효과적으로 삽입하고 최적화하는 방법을 다루겠습니다.

1. Next.js Image 컴포넌트 기본 사용법

1.1 Image 컴포넌트란?

Next.js의 Image 컴포넌트는 HTML의 <img> 태그를 확장한 것으로, 자동 이미지 최적화, 지연 로딩, 반응형 이미지 생성 등의 기능을 제공합니다.

import Image from 'next/image';

export default function MyComponent() {
  return (
    <Image
      src="/images/hero.jpg"
      alt="Hero image"
      width={800}
      height={600}
    />
  );
}

1.2 기본 속성

import Image from 'next/image';

function ImageExample() {
  return (
    <Image
      src="/images/photo.jpg"
      alt="설명 텍스트"
      width={1200}
      height={800}
      priority={false}        // 우선 로딩 여부
      placeholder="blur"      // 블러 효과
      blurDataURL="data:..."   // 블러 이미지 URL
      quality={75}             // 이미지 품질 (1-100)
      sizes="(max-width: 768px) 100vw, 50vw"
    />
  );
}

1.3 외부 이미지 사용

외부 도메인의 이미지를 사용하려면 next.config.js에 도메인을 추가해야 합니다.

// next.config.js
module.exports = {
  images: {
    domains: ['example.com', 'cdn.example.com'],
    // 또는 더 유연한 패턴 사용
    remotePatterns: [
      {
        protocol: 'https',
        hostname: '**.example.com',
        pathname: '/images/**',
      },
    ],
  },
};
import Image from 'next/image';

function ExternalImage() {
  return (
    <Image
      src="https://example.com/images/photo.jpg"
      alt="External image"
      width={800}
      height={600}
    />
  );
}

2. Tailwind CSS로 이미지 스타일링

2.1 기본 스타일링

import Image from 'next/image';

function StyledImage() {
  return (
    <div className="relative w-full h-64">
      <Image
        src="/images/hero.jpg"
        alt="Hero"
        fill
        className="object-cover rounded-lg shadow-lg"
      />
    </div>
  );
}

2.2 반응형 이미지

import Image from 'next/image';

function ResponsiveImage() {
  return (
    <div className="w-full md:w-1/2 lg:w-1/3">
      <Image
        src="/images/gallery.jpg"
        alt="Gallery"
        width={600}
        height={400}
        className="w-full h-auto rounded-xl"
      />
    </div>
  );
}

2.3 이미지 오버레이 효과

import Image from 'next/image';

function ImageWithOverlay() {
  return (
    <div className="relative w-full h-96 group">
      <Image
        src="/images/background.jpg"
        alt="Background"
        fill
        className="object-cover"
      />
      <div className="absolute inset-0 bg-black/50 group-hover:bg-black/70 transition-all duration-300">
        <div className="absolute inset-0 flex items-center justify-center">
          <h2 className="text-white text-4xl font-bold">오버레이 텍스트</h2>
        </div>
      </div>
    </div>
  );
}

2.4 이미지 갤러리

import Image from 'next/image';

const images = [
  { src: '/images/1.jpg', alt: 'Image 1' },
  { src: '/images/2.jpg', alt: 'Image 2' },
  { src: '/images/3.jpg', alt: 'Image 3' },
];

function ImageGallery() {
  return (
    <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
      {images.map((image, index) => (
        <div key={index} className="relative aspect-square overflow-hidden rounded-lg">
          <Image
            src={image.src}
            alt={image.alt}
            fill
            className="object-cover hover:scale-110 transition-transform duration-300"
          />
        </div>
      ))}
    </div>
  );
}

3. 고급 Image 컴포넌트 활용

3.1 블러 플레이스홀더

import Image from 'next/image';

// 작은 블러 이미지 생성 (예: 10x10 픽셀)
const blurDataUrl = 'data:image/jpeg;base64,/9j/4AAQSkZJRgABAQAAAQABAAD/2wBDAAYEBQYFBAYGBQYHBwYIChAKCgkJChQODwwQFxQYGBcUFhYaHSUfGhsjHBYWICwgIyYnKSopGR8tMC0oMCUoKSj/2wBDAQcHBwoIChMKChMoGhYaKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCj/wAARCAAIAAoDASIAAhEBAxEB/8QAFQABAQAAAAAAAAAAAAAAAAAAAAv/xAAhEAACAQMDBQAAAAAAAAAAAAABAgMABAUGIWGRkqGx0f/EABUBAQEAAAAAAAAAAAAAAAAAAAMF/8QAGhEAAgIDAAAAAAAAAAAAAAAAAAECEgMRkf/aAAwDAQACEQMRAD8AltJagyeH0AthI5xdrLcNM91BF5pX2HaH9bcfaSXWGaRmknyJckliyjqTzSlT54b6bk+h0R//2Q==';

function BlurImage() {
  return (
    <Image
      src="/images/photo.jpg"
      alt="Photo"
      width={800}
      height={600}
      placeholder="blur"
      blurDataURL={blurDataUrl}
      className="rounded-lg"
    />
  );
}

3.2 우선 로딩 이미지

import Image from 'next/image';

function HeroSection() {
  return (
    <div className="relative w-full h-screen">
      <Image
        src="/images/hero.jpg"
        alt="Hero"
        fill
        priority
        className="object-cover"
      />
    </div>
  );
}

3.3 이미지 최적화 설정

// next.config.js
module.exports = {
  images: {
    formats: ['image/avif', 'image/webp'],
    deviceSizes: [640, 750, 828, 1080, 1200, 1920, 2048, 3840],
    imageSizes: [16, 32, 48, 64, 96, 128, 256, 384],
    minimumCacheTTL: 60,
  },
};

4. 비디오 삽입 방법

4.1 HTML5 Video 태그

function VideoPlayer() {
  return (
    <div className="w-full max-w-4xl mx-auto">
      <video
        className="w-full rounded-lg shadow-lg"
        controls
        autoPlay
        muted
        loop
      >
        <source src="/videos/video.mp4" type="video/mp4" />
        <source src="/videos/video.webm" type="video/webm" />
        브라우저가 비디오 태그를 지원하지 않습니다.
      </video>
    </div>
  );
}

4.2 Tailwind CSS로 비디오 스타일링

function StyledVideo() {
  return (
    <div className="relative w-full aspect-video bg-black rounded-xl overflow-hidden shadow-2xl">
      <video
        className="w-full h-full object-cover"
        controls
        poster="/images/video-thumbnail.jpg"
      >
        <source src="/videos/presentation.mp4" type="video/mp4" />
      </video>
    </div>
  );
}

4.3 반응형 비디오

function ResponsiveVideo() {
  return (
    <div className="container mx-auto px-4">
      <div className="relative w-full aspect-video max-w-4xl mx-auto">
        <video
          className="w-full h-full rounded-lg"
          controls
        >
          <source src="/videos/demo.mp4" type="video/mp4" />
        </video>
      </div>
    </div>
  );
}

4.4 비디오 오버레이와 컨트롤

'use client';

import { useState } from 'react';

function VideoWithCustomControls() {
  const [isPlaying, setIsPlaying] = useState(false);
  const videoRef = useRef(null);

  const togglePlay = () => {
    if (videoRef.current) {
      if (isPlaying) {
        videoRef.current.pause();
      } else {
        videoRef.current.play();
      }
      setIsPlaying(!isPlaying);
    }
  };

  return (
    <div className="relative w-full aspect-video group">
      <video
        ref={videoRef}
        src="/videos/video.mp4"
        className="w-full h-full object-cover rounded-lg"
        loop
      />
      <button
        onClick={togglePlay}
        className="absolute inset-0 flex items-center justify-center bg-black/30 group-hover:bg-black/50 transition-all"
      >
        {isPlaying ? (
          <div className="w-16 h-16 bg-white/20 rounded-full flex items-center justify-center">
            <svg className="w-8 h-8 text-white" fill="currentColor" viewBox="0 0 20 20">
              <path d="M6 4h2v12H6V4zm6 0h2v12h-2V4z" />
            </svg>
          </div>
        ) : (
          <div className="w-20 h-20 bg-white/20 rounded-full flex items-center justify-center">
            <svg className="w-8 h-8 text-white ml-1" fill="currentColor" viewBox="0 0 20 20">
              <path d="M6.3 2.841A1.5 1.5 0 004 4.11V15.89a1.5 1.5 0 002.3 1.269l9.344-5.89a1.5 1.5 0 000-2.538L6.3 2.84z" />
            </svg>
          </div>
        )}
      </button>
    </div>
  );
}

5. YouTube 및 Vimeo 비디오 임베드

5.1 YouTube 비디오 임베드

function YouTubeVideo({ videoId }) {
  return (
    <div className="relative w-full aspect-video">
      <iframe
        className="absolute top-0 left-0 w-full h-full rounded-lg"
        src={`https://www.youtube.com/embed/${videoId}`}
        title="YouTube video player"
        frameBorder="0"
        allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
        allowFullScreen
      />
    </div>
  );
}

// 사용 예시
<YouTubeVideo videoId="dQw4w9WgXcQ" />

5.2 Vimeo 비디오 임베드

function VimeoVideo({ videoId }) {
  return (
    <div className="relative w-full aspect-video">
      <iframe
        className="absolute top-0 left-0 w-full h-full rounded-lg"
        src={`https://player.vimeo.com/video/${videoId}`}
        frameBorder="0"
        allow="autoplay; fullscreen; picture-in-picture"
        allowFullScreen
      />
    </div>
  );
}

5.3 반응형 임베드 비디오 컴포넌트

function ResponsiveEmbed({ url, title }) {
  return (
    <div className="relative w-full aspect-video max-w-4xl mx-auto">
      <iframe
        className="absolute top-0 left-0 w-full h-full rounded-lg shadow-xl"
        src={url}
        title={title}
        frameBorder="0"
        allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
        allowFullScreen
      />
    </div>
  );
}

6. 이미지와 비디오 혼합 레이아웃

6.1 이미지와 비디오 그리드

import Image from 'next/image';

const mediaItems = [
  { type: 'image', src: '/images/1.jpg', alt: 'Image 1' },
  { type: 'video', src: '/videos/1.mp4', thumbnail: '/images/v1-thumb.jpg' },
  { type: 'image', src: '/images/2.jpg', alt: 'Image 2' },
  { type: 'video', src: '/videos/2.mp4', thumbnail: '/images/v2-thumb.jpg' },
];

function MediaGrid() {
  return (
    <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
      {mediaItems.map((item, index) => (
        <div key={index} className="relative aspect-square overflow-hidden rounded-xl group">
          {item.type === 'image' ? (
            <Image
              src={item.src}
              alt={item.alt}
              fill
              className="object-cover group-hover:scale-110 transition-transform duration-300"
            />
          ) : (
            <video
              src={item.src}
              poster={item.thumbnail}
              className="w-full h-full object-cover"
              controls
            />
          )}
        </div>
      ))}
    </div>
  );
}

6.2 히어로 섹션 (이미지 + 비디오 배경)

import Image from 'next/image';

function HeroSection() {
  return (
    <section className="relative w-full h-screen">
      {/* 비디오 배경 */}
      <video
        className="absolute inset-0 w-full h-full object-cover"
        autoPlay
        muted
        loop
        playsInline
      >
        <source src="/videos/hero-background.mp4" type="video/mp4" />
      </video>
      
      {/* 오버레이 */}
      <div className="absolute inset-0 bg-black/40" />
      
      {/* 콘텐츠 */}
      <div className="relative z-10 flex items-center justify-center h-full">
        <div className="text-center text-white">
          <h1 className="text-5xl md:text-7xl font-bold mb-4">환영합니다</h1>
          <p className="text-xl md:text-2xl">최고의 경험을 제공합니다</p>
        </div>
      </div>
    </section>
  );
}

7. 성능 최적화

7.1 이미지 지연 로딩

import Image from 'next/image';

function LazyLoadedImage({ src, alt }) {
  return (
    <Image
      src={src}
      alt={alt}
      width={800}
      height={600}
      loading="lazy"
      className="rounded-lg"
    />
  );
}

7.2 비디오 지연 로딩

'use client';

import { useState, useRef, useEffect } from 'react';

function LazyVideo({ src, poster }) {
  const [shouldLoad, setShouldLoad] = useState(false);
  const videoRef = useRef(null);

  useEffect(() => {
    const observer = new IntersectionObserver(
      (entries) => {
        if (entries[0].isIntersecting) {
          setShouldLoad(true);
          observer.disconnect();
        }
      },
      { threshold: 0.1 }
    );

    if (videoRef.current) {
      observer.observe(videoRef.current);
    }

    return () => observer.disconnect();
  }, []);

  return (
    <div ref={videoRef} className="w-full aspect-video">
      {shouldLoad ? (
        <video
          src={src}
          poster={poster}
          className="w-full h-full rounded-lg"
          controls
        />
      ) : (
        <Image
          src={poster}
          alt="Video thumbnail"
          fill
          className="object-cover rounded-lg"
        />
      )}
    </div>
  );
}

7.3 이미지 프리로딩

import Image from 'next/image';
import { useEffect } from 'react';

function PreloadImage({ src }) {
  useEffect(() => {
    const link = document.createElement('link');
    link.rel = 'preload';
    link.as = 'image';
    link.href = src;
    document.head.appendChild(link);

    return () => {
      document.head.removeChild(link);
    };
  }, [src]);

  return (
    <Image
      src={src}
      alt="Preloaded"
      width={800}
      height={600}
    />
  );
}

8. 접근성 고려사항

8.1 이미지 접근성

import Image from 'next/image';

function AccessibleImage({ src, alt, description }) {
  return (
    <figure className="w-full">
      <Image
        src={src}
        alt={alt}
        width={800}
        height={600}
        className="w-full rounded-lg"
      />
      {description && (
        <figcaption className="mt-2 text-sm text-gray-600">
          {description}
        </figcaption>
      )}
    </figure>
  );
}

8.2 비디오 접근성

function AccessibleVideo({ src, title, transcript }) {
  return (
    <div className="w-full">
      <video
        src={src}
        controls
        className="w-full rounded-lg"
        aria-label={title}
      >
        <track
          kind="captions"
          srcLang="ko"
          src="/videos/captions.vtt"
          default
        />
      </video>
      {transcript && (
        <details className="mt-4">
          <summary className="cursor-pointer font-semibold">전체 대본 보기</summary>
          <div className="mt-2 p-4 bg-gray-100 rounded">
            {transcript}
          </div>
        </details>
      )}
    </div>
  );
}

9. 실전 예제: 갤러리 페이지

9.1 이미지 갤러리

'use client';

import Image from 'next/image';
import { useState } from 'react';

const galleryImages = [
  { id: 1, src: '/images/gallery/1.jpg', alt: 'Gallery image 1' },
  { id: 2, src: '/images/gallery/2.jpg', alt: 'Gallery image 2' },
  { id: 3, src: '/images/gallery/3.jpg', alt: 'Gallery image 3' },
  { id: 4, src: '/images/gallery/4.jpg', alt: 'Gallery image 4' },
];

function GalleryPage() {
  const [selectedImage, setSelectedImage] = useState(null);

  return (
    <div className="container mx-auto px-4 py-8">
      <h1 className="text-4xl font-bold mb-8">갤러리</h1>
      
      <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
        {galleryImages.map((image) => (
          <div
            key={image.id}
            className="relative aspect-square overflow-hidden rounded-lg cursor-pointer group"
            onClick={() => setSelectedImage(image)}
          >
            <Image
              src={image.src}
              alt={image.alt}
              fill
              className="object-cover group-hover:scale-110 transition-transform duration-300"
            />
            <div className="absolute inset-0 bg-black/0 group-hover:bg-black/20 transition-all" />
          </div>
        ))}
      </div>

      {/* 모달 */}
      {selectedImage && (
        <div
          className="fixed inset-0 bg-black/90 z-50 flex items-center justify-center p-4"
          onClick={() => setSelectedImage(null)}
        >
          <div className="relative max-w-4xl max-h-full">
            <Image
              src={selectedImage.src}
              alt={selectedImage.alt}
              width={1200}
              height={800}
              className="rounded-lg"
            />
            <button
              onClick={() => setSelectedImage(null)}
              className="absolute top-4 right-4 text-white bg-black/50 rounded-full p-2 hover:bg-black/70"
            >
              ✕
            </button>
          </div>
        </div>
      )}
    </div>
  );
}

9.2 비디오 플레이리스트

'use client';

import { useState } from 'react';

const videos = [
  { id: 1, title: '비디오 1', src: '/videos/1.mp4', thumbnail: '/images/v1.jpg' },
  { id: 2, title: '비디오 2', src: '/videos/2.mp4', thumbnail: '/images/v2.jpg' },
  { id: 3, title: '비디오 3', src: '/videos/3.mp4', thumbnail: '/images/v3.jpg' },
];

function VideoPlaylist() {
  const [currentVideo, setCurrentVideo] = useState(videos[0]);

  return (
    <div className="container mx-auto px-4 py-8">
      <div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
        {/* 메인 비디오 */}
        <div className="lg:col-span-2">
          <div className="relative w-full aspect-video bg-black rounded-lg overflow-hidden">
            <video
              key={currentVideo.id}
              src={currentVideo.src}
              controls
              className="w-full h-full"
              autoPlay
            />
          </div>
          <h2 className="text-2xl font-bold mt-4">{currentVideo.title}</h2>
        </div>

        {/* 플레이리스트 */}
        <div className="space-y-4">
          <h3 className="text-xl font-semibold">플레이리스트</h3>
          {videos.map((video) => (
            <div
              key={video.id}
              className={`cursor-pointer rounded-lg overflow-hidden border-2 transition-all ${
                currentVideo.id === video.id
                  ? 'border-blue-500'
                  : 'border-transparent hover:border-gray-300'
              }`}
              onClick={() => setCurrentVideo(video)}
            >
              <div className="relative aspect-video">
                <img
                  src={video.thumbnail}
                  alt={video.title}
                  className="w-full h-full object-cover"
                />
                <div className="absolute inset-0 flex items-center justify-center bg-black/30">
                  <svg className="w-12 h-12 text-white" fill="currentColor" viewBox="0 0 20 20">
                    <path d="M6.3 2.841A1.5 1.5 0 004 4.11V15.89a1.5 1.5 0 002.3 1.269l9.344-5.89a1.5 1.5 0 000-2.538L6.3 2.84z" />
                  </svg>
                </div>
              </div>
              <p className="p-2 font-medium">{video.title}</p>
            </div>
          ))}
        </div>
      </div>
    </div>
  );
}

10. 모바일 최적화

10.1 모바일 친화적 이미지

import Image from 'next/image';

function MobileOptimizedImage() {
  return (
    <div className="w-full">
      <Image
        src="/images/mobile-hero.jpg"
        alt="Mobile hero"
        width={800}
        height={600}
        sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw"
        className="w-full h-auto rounded-lg"
      />
    </div>
  );
}

10.2 모바일 비디오 최적화

function MobileVideo() {
  return (
    <div className="w-full">
      <video
        className="w-full rounded-lg"
        controls
        playsInline
        preload="metadata"
      >
        <source src="/videos/mobile-video.mp4" type="video/mp4" />
      </video>
    </div>
  );
}

11. 에러 처리

11.1 이미지 에러 처리

'use client';

import Image from 'next/image';
import { useState } from 'react';

function ImageWithErrorHandling({ src, alt }) {
  const [hasError, setHasError] = useState(false);
  const [imageSrc, setImageSrc] = useState(src);

  const handleError = () => {
    if (!hasError) {
      setHasError(true);
      setImageSrc('/images/placeholder.jpg');
    }
  };

  return (
    <Image
      src={imageSrc}
      alt={alt}
      width={800}
      height={600}
      onError={handleError}
      className="rounded-lg"
    />
  );
}

11.2 비디오 에러 처리

'use client';

import { useState } from 'react';

function VideoWithErrorHandling({ src, poster }) {
  const [hasError, setHasError] = useState(false);

  const handleError = () => {
    setHasError(true);
  };

  if (hasError) {
    return (
      <div className="w-full aspect-video bg-gray-200 rounded-lg flex items-center justify-center">
        <p className="text-gray-500">비디오를 불러올 수 없습니다.</p>
      </div>
    );
  }

  return (
    <video
      src={src}
      poster={poster}
      controls
      className="w-full rounded-lg"
      onError={handleError}
    />
  );
}

12. 결론

Next.js의 Image 컴포넌트와 Tailwind CSS를 활용하면 이미지와 비디오를 효과적으로 삽입하고 최적화할 수 있습니다. 자동 이미지 최적화, 반응형 처리, 성능 향상 등의 이점을 얻을 수 있으며, Tailwind CSS의 유틸리티 클래스를 통해 일관된 디자인을 쉽게 구현할 수 있습니다.

주요 포인트:

  • Next.js Image 컴포넌트: 자동 최적화와 지연 로딩으로 성능 향상
  • Tailwind CSS 스타일링: 빠르고 일관된 디자인 구현
  • 반응형 디자인: 다양한 디바이스에서 최적의 경험 제공
  • 접근성: alt 텍스트, 캡션, 자막 등으로 모든 사용자 접근 가능
  • 성능 최적화: 지연 로딩, 프리로딩, 에러 처리로 안정적인 사용자 경험

이미지와 비디오는 웹사이트의 시각적 매력을 높이고 사용자 경험을 향상시키는 중요한 요소입니다. 올바른 도구와 기법을 사용하여 성능과 디자인을 모두 잡는 것이 중요합니다.