각 포스트마다 개성 있는 비주얼로 만들고 싶었는데, 특히 썸네일 이미지와 자연스럽게 어우러지는 배너가 있다면 더 나은 사용자 경험을 제공할 수 있을 것 같았다. 그래서 썸네일 이미지에서 색상을 추출해 동적으로 그라디언트 배너를 생성하는 시스템을 구현해보았다.

구현 목표

→ 정적인 배너 대신 각 포스트의 썸네일과 조화를 이루는 동적 배너 만들기

주요 아이디어는 다음과 같다.

  • 썸네일 이미지에서 주요 색상 추출
  • 추출된 색상으로 팔레트 생성
  • 그라디언트 배너 렌더링

기술적 구현

1. CORS 문제 해결

웹에서 외부 이미지의 픽셀 데이터를 읽으려면 CORS를 우회해야 한다. 여러 프록시를 순차적으로 시도하는 방식으로 진행했다.

import { useEffect, useRef, useState } from "react";
 
// CORS 프록시 목록
const corsProxies = [
  "", // 원본 URL 먼저 시도
  "https://api.allorigins.win/raw?url=",
  "https://corsproxy.io/?",
  "https://cors-anywhere.herokuapp.com/",
];
 
interface ColorPalette {
  color1: string;
  color2: string;
  color3: string;
  color4: string;
}
 
export function useDynamicBanner(thumbnail: string | undefined) {
  const [gradientStyle, setGradientStyle] = useState<string>("");
  const [isLoading, setIsLoading] = useState(false);
  const gradientCache = useRef<Map<string, string>>(new Map());
  const currentThumbnail = useRef<string>("");
 
  useEffect(() => {
    if (!thumbnail) return;
 
    currentThumbnail.current = thumbnail;
 
    // 캐시 확인
    if (gradientCache.current.has(thumbnail)) {
      const cachedGradient = gradientCache.current.get(thumbnail)!;
      setGradientStyle(cachedGradient);
      return;
    }
 
    setIsLoading(true);
    extractColorsAndCreateGradient(thumbnail)
      .then((gradient) => {
        // 경쟁 상태 방지
        if (currentThumbnail.current === thumbnail) {
          setGradientStyle(gradient);
          gradientCache.current.set(thumbnail, gradient);
        }
      })
      .finally(() => {
        if (currentThumbnail.current === thumbnail) {
          setIsLoading(false);
        }
      });
  }, [thumbnail]);
 
  return { gradientStyle, isLoading };
}
 
// Canvas API를 사용한 색상 추출
async function extractColorsAndCreateGradient(imageSrc: string): Promise<string> {
  try {
    const img = await loadImageWithCORS(imageSrc);
    const colors = extractMainColors(img);
    const palette = generateColorPalette(colors[0]);
 
    return `linear-gradient(135deg, 
      ${palette.color1} 0%, 
      ${palette.color2} 25%, 
      ${palette.color3} 75%, 
      ${palette.color4} 100%)`;
  } catch (error) {
    console.error("색상 추출 실패:", error);
    return generateFallbackGradient();
  }
}
 
// CORS 프록시를 통한 이미지 로딩
async function loadImageWithCORS(imageSrc: string): Promise<HTMLImageElement> {
  for (const proxy of corsProxies) {
    try {
      const img = await loadImage(proxy + imageSrc);
      return img;
    } catch (error) {
      continue;
    }
  }
  throw new Error("모든 CORS 프록시 실패");
}
 
function loadImage(src: string): Promise<HTMLImageElement> {
  return new Promise((resolve, reject) => {
    const img = new Image();
    img.crossOrigin = "anonymous";
 
    const timeout = setTimeout(() => {
      reject(new Error("이미지 로딩 타임아웃"));
    }, 2000);
 
    img.onload = () => {
      clearTimeout(timeout);
      resolve(img);
    };
 
    img.onerror = () => {
      clearTimeout(timeout);
      reject(new Error("이미지 로딩 실패"));
    };
 
    img.src = src;
  });
}
 
// 이미지에서 주요 색상 추출
function extractMainColors(img: HTMLImageElement): string[] {
  const canvas = document.createElement("canvas");
  const ctx = canvas.getContext("2d", { willReadFrequently: true })!;
 
  // 성능을 위해 이미지 크기 축소
  const targetSize = 80;
  const scale = Math.min(targetSize / img.width, targetSize / img.height);
  canvas.width = Math.max(40, Math.floor(img.width * scale));
  canvas.height = Math.max(40, Math.floor(img.height * scale));
 
  ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
 
  const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
  const data = imageData.data;
 
  // 색상 히스토그램 생성
  const colorMap = new Map<string, number>();
 
  for (let i = 0; i < data.length; i += 4) {
    const r = data[i];
    const g = data[i + 1];
    const b = data[i + 2];
    const alpha = data[i + 3];
 
    // 투명 픽셀 제외
    if (alpha < 128) continue;
 
    // 극단적인 밝기 필터링
    const brightness = (r + g + b) / 3;
    if (brightness < 40 || brightness > 220) continue;
 
    // 색상 그룹화 (노이즈 감소)
    const rRounded = Math.round(r / 20) * 20;
    const gRounded = Math.round(g / 20) * 20;
    const bRounded = Math.round(b / 20) * 20;
 
    const colorKey = `${rRounded},${gRounded},${bRounded}`;
    colorMap.set(colorKey, (colorMap.get(colorKey) || 0) + 1);
  }
 
  // 빈도수 기준 정렬
  const sortedColors = Array.from(colorMap.entries())
    .sort((a, b) => b[1] - a[1])
    .slice(0, 5)
    .map(([color]) => color);
 
  return sortedColors.length > 0 ? sortedColors : ["128,128,128"];
}
 
// 색상 팔레트 생성
function generateColorPalette(baseColor: string): ColorPalette {
  const [r, g, b] = baseColor.split(",").map(Number);
  const hsl = rgbToHsl(r, g, b);
 
  return generateHarmonicPalette(hsl);
}
 
// 조화로운 4색 팔레트 생성
function generateHarmonicPalette(hsl: [number, number, number]): ColorPalette {
  const [h, s, l] = hsl;
 
  // 아날로그 색상 조화 (15도씩 회전)
  const color1 = hslToRgb(h, Math.min(0.8, s * 1.2), Math.max(0.3, l * 0.8));
  const color2 = hslToRgb((h + 15) % 360, Math.min(0.9, s * 1.1), Math.max(0.35, l * 0.9));
  const color3 = hslToRgb((h + 30) % 360, Math.min(0.7, s * 0.9), Math.min(0.8, l * 1.1));
  const color4 = hslToRgb((h + 45) % 360, Math.min(0.6, s * 0.8), Math.min(0.85, l * 1.2));
 
  return {
    color1: `rgb(${color1.join(",")})`,
    color2: `rgb(${color2.join(",")})`,
    color3: `rgb(${color3.join(",")})`,
    color4: `rgb(${color4.join(",")})`,
  };
}
 
// RGB to HSL 변환
function rgbToHsl(r: number, g: number, b: number): [number, number, number] {
  r /= 255;
  g /= 255;
  b /= 255;
 
  const max = Math.max(r, g, b);
  const min = Math.min(r, g, b);
  let h = 0;
  let s = 0;
  const l = (max + min) / 2;
 
  if (max !== min) {
    const d = max - min;
    s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
 
    switch (max) {
      **case r**:
        h = ((g - b) / d + (g < b ? 6 : 0)) / 6;
        break;
      **case g**:
        h = ((b - r) / d + 2) / 6;
        break;
      **case b**:
        h = ((r - g) / d + 4) / 6;
        break;
    }
  }
 
  return [Math.round(h * 360), s, l];
}
 
// HSL to RGB 변환
function hslToRgb(h: number, s: number, l: number): [number, number, number] {
  h /= 360;
 
  let r, g, b;
 
  if (s === 0) {
    r = g = b = l;
  } else {
    const hue2rgb = (p: number, q: number, t: number) => {
      if (t < 0) t += 1;
      if (t > 1) t -= 1;
      if (t < 1/6) return p + (q - p) * 6 * t;
      if (t < 1/2) return q;
      if (t < 2/3) return p + (q - p) * (2/3 - t) * 6;
      return p;
    };
 
    const q = l < 0.5 ? l * (1 + s) : l + s - l * s;
    const p = 2 * l - q;
 
    r = hue2rgb(p, q, h + 1/3);
    g = hue2rgb(p, q, h);
    b = hue2rgb(p, q, h - 1/3);
  }
 
  return [
    Math.round(r * 255),
    Math.round(g * 255),
    Math.round(b * 255),
  ];
}
 
// 폴백 그라디언트
function generateFallbackGradient(): string {
  return `linear-gradient(135deg, 
    rgb(100, 100, 100) 0%, 
    rgb(120, 120, 120) 25%, 
    rgb(140, 140, 140) 75%, 
    rgb(160, 160, 160) 100%)`;
}

2. 배너 컴포넌트 구현

추출된 색상을 활용해 실제 배너를 랜더링하는 컴포넌트를 만들었다.

import React from "react";
import { useDynamicBanner } from "./useDynamicBanner";
 
interface DynamicBannerProps {
  thumbnail?: string;
  title: string;
  subtitle?: string;
  className?: string;
}
 
export default function DynamicBanner({ 
  thumbnail, 
  title, 
  subtitle,
  className = "" 
}: DynamicBannerProps) {
  const { gradientStyle, isLoading } = useDynamicBanner(thumbnail);
 
  return (
    <div className={`relative w-full h-64 overflow-hidden ${className}`}>
      {/* 그라디언트 배경 */}
      <div 
        className="absolute inset-0 transition-all duration-2000 ease-in-out"
        style={{ 
          background: gradientStyle || "linear-gradient(135deg, #667eea 0%, #764ba2 100%)"
        }}
      />
 
      {/* 로딩 상태 */}
      {isLoading && (
        <div className="absolute inset-0">
          <div className="absolute inset-0 bg-gradient-to-br from-slate-100 to-slate-100 dark:from-slate-800 dark:to-slate-800" />
 
          {/* 스캔 애니메이션 */}
          <div className="absolute inset-0 overflow-hidden">
            <div 
              className="absolute inset-0 bg-gradient-to-r from-transparent via-white/20 to-transparent 
                         transform -translate-x-full animate-scan"
            />
          </div>
        </div>
      )}
 
      {/* 콘텐츠 오버레이 */}
      <div className="absolute inset-0 bg-black/20" />
 
      {/* 텍스트 콘텐츠 */}
      <div className="relative h-full flex flex-col justify-center items-center text-white p-8">
        <h1 className="text-4xl md:text-5xl font-bold text-center mb-4 drop-shadow-lg">
          {title}
        </h1>
        {subtitle && (
          <p className="text-lg md:text-xl text-center opacity-90 drop-shadow">
            {subtitle}
          </p>
        )}
      </div>
    </div>
  );
}
 
// Tailwind CSS 애니메이션 설정 (tailwind.config.js에 추가)
// animation: {
//   scan: 'scan 2s ease-in-out infinite',
// },
// keyframes: {
//   scan: {
//     '0%': { transform: 'translateX(-100%)' },
//     '100%': { transform: 'translateX(100%)' },
//   },
// }

3. 색상 이론(?) 적용

색상의 어떤 이론을 활용해서 조화로운 팔레트를 생성하도록 했다.

아날로그 색상 조화

  • 색상환에서 인접한 색상들을 사용
  • 15도씩 회전하여 자연스러운 변화 구현
  • 채도와 명도를 점진적으로 조정

색상 속성 조정

채도는 첫 색상을 가장 진하게 하고, 뒤로 갈수록 연하게 만들었다.
명도는 점진적으로 밝아지도록 조정했다.
색조는 15~45도 범위 내에서 변화를 주었다.

4. 성능 최적화

이미지 크기 최적화

원본 이미지를 80*80 픽셀로 축소하여 처리했다. 색상 추출에는 작은 이미지로도 충분하니 처리 시간을 많이 단축시킬 수 있었다.

캐싱 메모리

const gradientCache = useRef<Map<string, string>>(new Map());

Map을 사용한 간단한 그라디언트 캐싱을 두어 동일한 이미지에 대한 중복 처리를 방지했다.

Race Condition 방지

사용자가 빠르게 페이지를 전환할 때 이전 요청의 결과가 현재 화면에 적용되는 것을 방지했다.

결과

image

동적 배너 시스템을 적용한 이후, 전체적인 비주얼이 한층 개선되었다. 썸네일과 조화를 이루는 배너가 자연스럽게 렌더링되며, 페이지 로드 시 부드러운 애니메이션도 함께 출력된다. 캐싱 덕분에 리프레시 시 해당 과정이 반복되지 않아 효율적이다. 특히 각 포스트마다 고유한 색상의 배너가 생성되는 점이 개인적으로 가장 만족스럽다.

📝 Note

이 프로젝트의 모든 소스 코드는 GitHub에 공개되어 있습니다. 코드 품질 개선이나 새로운 기능 제안에 대한 피드백은 언제나 환영합니다.