Blog

Next.js 15에서 html2canvas CORS 에러 해결하기: 이미지 프록시 솔루션

html2canvas 사용 시 외부 CDN 이미지로 인한 CORS 에러를 Next.js API Route 프록시로 완벽 해결한 과정과 다양한 해결 방법들을 상세히 소개합니다.

Next.js 15에서 html2canvas CORS 에러 해결하기: 이미지 프록시 솔루션

html2canvas를 사용해 화면 캡처 기능을 구현하다 보면 외부 CDN 이미지로 인한 CORS 에러를 자주 만나게 됩니다. 이 글에서는 여러 해결 방법을 비교 분석하고, 실제로 성공한 Next.js API Route 프록시 솔루션을 상세히 공유합니다.

📋 목차

  1. 문제 상황과 원인
  2. 해결 방법 비교
  3. 실제 성공한 해결책: Next.js 이미지 프록시
  4. 대안 해결 방법들
  5. 성능 최적화 및 추가 팁

문제 상황과 원인

웹 애플리케이션에서 html2canvas를 사용해 화면을 캡처하려고 했는데 다음과 같은 CORS 에러가 발생했습니다:

code-highlight
Access to image at 'https://cdn.example.com/images/my-image.svg' 
from origin 'http://localhost:3001' has been blocked by CORS policy: 
No 'Access-Control-Allow-Origin' header is present on the requested resource.

근본 원인

  • html2canvas의 작동 방식: DOM을 Canvas로 그리는 과정에서 Canvas API를 사용
  • Same-Origin Policy: 브라우저의 보안 정책으로 인한 크로스 도메인 이미지 접근 제한
  • 외부 CDN 이미지: CORS 헤더를 제공하지 않는 외부 도메인 이미지
  • 브라우저 캐싱: 첫 번째 요청 시 CORS 헤더가 없으면 캐시된 이미지도 동일한 문제 발생

해결 방법 비교

여러 해결 방법을 시도해본 결과, 각각의 장단점을 정리하면 다음과 같습니다:

순위방법난이도효과적용 범위권장도
🏆Next.js API Route 프록시⭐⭐⭐⭐⭐⭐⭐프로덕션최고
🥈Next.js Image + 커스텀 로더⭐⭐⭐⭐⭐⭐⭐⭐고급✅ 권장
🥉next.config.js Rewrites⭐⭐⭐⭐간편⚠️ 제한적
4클라이언트 최적화⭐⭐⭐즉시⚠️ 임시방편
5Middleware CORS⭐⭐⭐⭐⭐⭐전역❌ 복잡함

1. 클라이언트 최적화 (즉시 적용 가능) ⚠️

가장 빠르게 적용할 수 있는 임시 해결책입니다:

typescript
import html2canvas from 'html2canvas';

const captureElement = async () => {
  // 이미지들을 캐시 무효화로 미리 로드
  const images = document.querySelectorAll('img');
  await Promise.all(
    Array.from(images).map(img => 
      fetch(img.src + `?t=${Date.now()}`, { 
        cache: 'no-cache',
        mode: 'cors' 
      })
    )
  );

  // html2canvas 실행
  const canvas = await html2canvas(elementRef.current, {
    useCORS: true,
    allowTaint: false,
    scale: 2,
    logging: true,
    foreignObjectRendering: true
  });

  return canvas.toDataURL('image/png');
};

장점: 즉시 적용 가능, 별도 서버 설정 불필요
단점: 브라우저 캐싱 문제로 불안정, 근본적 해결책 아님

2. next.config.js Rewrites (간단한 프록시) ⚠️

설정만으로 간단히 해결하는 방법:

javascript
// next.config.js
/** @type {import('next').NextConfig} */
const nextConfig = {
  async rewrites() {
    return [
      {
        source: '/api/images/:path*',
        destination: 'https://external-domain.com/:path*',
      },
    ];
  },
  images: {
    remotePatterns: [
      {
        protocol: 'https',
        hostname: '**',
      },
    ],
  },
};

module.exports = nextConfig;

장점: 설정만으로 해결, Next.js 내장 기능 활용
단점: 특정 도메인에만 제한적, 동적 URL 처리 어려움

3. Next.js Image + 커스텀 로더 (권장) ✅

Next.js Image 컴포넌트의 강력한 기능을 활용:

typescript
// components/ProxyImage.tsx
'use client';

import Image from 'next/image';

const customLoader = ({ src, width, quality }: {
  src: string;
  width: number;
  quality?: number;
}) => {
  // 외부 도메인 이미지는 프록시를 통해 로드
  if (src.startsWith('http') && !src.includes(window.location.hostname)) {
    return `/api/proxy-image?url=${encodeURIComponent(src)}&w=${width}&q=${quality || 75}`;
  }
  return src;
};

export default function ProxyImage(props) {
  return (
    <Image
      loader={customLoader}
      crossOrigin="anonymous"
      {...props}
    />
  );
}

장점: 자동 최적화, WebP 변환, 레이지 로딩, 반응형 지원
단점: Next.js Image API 학습 필요, 약간 복잡함

실제 성공한 해결책: Next.js 이미지 프록시

여러 해결 방법을 시도한 결과, Next.js API Route를 활용한 이미지 프록시 방식이 가장 안정적이고 효과적인 솔루션이었습니다. 이 방법으로 프로덕션 환경에서 완전히 문제를 해결할 수 있었습니다.

핵심 아이디어

외부 CDN 이미지를 직접 사용하지 않고, Next.js 서버를 경유해서 이미지를 프록시하여 CORS 헤더를 추가하는 방식입니다:

1단계: API Route 프록시 엔드포인트 생성

가장 핵심이 되는 서버사이드 프록시 엔드포인트를 구현합니다:

typescript
// app/api/image-proxy/route.ts
import { NextRequest, NextResponse } from 'next/server';

export async function GET(request: NextRequest) {
  const { searchParams } = new URL(request.url);
  const imageUrl = searchParams.get('url');

  if (!imageUrl) {
    return NextResponse.json({ error: 'URL parameter is required' }, { status: 400 });
  }

  try {
    // 외부 이미지 URL 유효성 검사
    const url = new URL(decodeURIComponent(imageUrl));
    
    // 허용된 도메인만 프록시 (보안상 중요)
    const allowedDomains = [
      'cdn.example.com',
      'assets.mysite.com',
      'images.service.com'
    ];
    
    if (!allowedDomains.includes(url.hostname)) {
      return NextResponse.json({ error: 'Domain not allowed' }, { status: 403 });
    }

    // 외부 이미지 fetch
    const response = await fetch(decodeURIComponent(imageUrl), {
      headers: {
        'User-Agent': 'Mozilla/5.0 (compatible; NextJS Image Proxy)',
      },
    });

    if (!response.ok) {
      return NextResponse.json(
        { error: `Failed to fetch image: ${response.status}` },
        { status: response.status }
      );
    }

    const imageBuffer = await response.arrayBuffer();
    const contentType = response.headers.get('content-type') || 'image/jpeg';

    // 🔑 핵심: CORS 헤더와 함께 이미지 반환
    return new NextResponse(imageBuffer, {
      headers: {
        'Content-Type': contentType,
        'Cache-Control': 'public, max-age=31536000, immutable',
        'Access-Control-Allow-Origin': '*',
        'Access-Control-Allow-Methods': 'GET',
        'Access-Control-Allow-Headers': 'Content-Type',
      },
    });
  } catch (error) {
    console.error('Image proxy error:', error);
    return NextResponse.json({ error: 'Failed to proxy image' }, { status: 500 });
  }
}

2단계: ProxyImage 컴포넌트 구현

외부 이미지를 자동으로 프록시를 통해 로드하는 컴포넌트를 만듭니다:

typescript
// components/ProxyImage.tsx
'use client';

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

// 외부 도메인 이미지인지 확인 (SSR 안전)
const isExternalImage = (src: string): boolean => {
  try {
    const url = new URL(src);
    return url.protocol === 'http:' || url.protocol === 'https:';
  } catch {
    return false;
  }
};

// 🔑 핵심: CORS 문제 해결을 위한 커스텀 로더
const corsProxyLoader: ImageLoader = ({ src, width, quality }) => {
  // 외부 도메인 이미지면 프록시를 통해 로드
  if (isExternalImage(src)) {
    return `/api/image-proxy?url=${encodeURIComponent(src)}&w=${width}&q=${quality || 75}`;
  }
  return src; // 내부 이미지는 그대로 사용
};

interface ProxyImageProps extends Omit<ImageProps, 'loader'> {
  fallbackSrc?: string;
}

export const ProxyImage = ({ fallbackSrc, onError, ...props }: ProxyImageProps) => {
  const [error, setError] = useState(false);

  const handleError = (e: React.SyntheticEvent<HTMLImageElement>) => {
    setError(true);
    onError?.(e);
  };

  // 에러 시 fallback 이미지 표시
  if (error && fallbackSrc) {
    return (
      <Image
        {...props}
        src={fallbackSrc}
        loader={corsProxyLoader}
        crossOrigin="anonymous"
        onError={handleError}
      />
    );
  }

  return (
    <Image
      {...props}
      loader={corsProxyLoader}
      crossOrigin="anonymous"
      onError={handleError}
    />
  );
};

3단계: 컴포넌트 교체

이제 기존 이미지 컴포넌트를 ProxyImage로 교체하기만 하면 됩니다.

교체 과정:

  1. 기존 <Image> 또는 <img> 태그를 <ProxyImage>로 변경
  2. 외부 CDN URL을 사용하는 이미지들을 우선적으로 교체
  3. 필요에 따라 fallbackSrc prop으로 대체 이미지 지정
  4. 기존 props(width, height, className 등)은 그대로 유지 가능

핵심 포인트: ProxyImage는 Next.js Image와 동일한 API를 제공하므로, 기존 코드의 변경 없이도 CORS 문제를 해결할 수 있습니다.

4단계: html2canvas와 함께 사용

html2canvas로 캡처할 때 ProxyImage가 포함된 컴포넌트를 캡처하면 CORS 문제 없이 정상 작동합니다:

typescript
import html2canvas from 'html2canvas';

const handleCapture = async () => {
  const element = document.querySelector('[data-capture-target]') as HTMLElement;
  
  if (!element) return;

  try {
    const canvas = await html2canvas(element, {
      useCORS: true,
      allowTaint: false,
      scale: 2,
      backgroundColor: '#ffffff',
    });

    // 이미지 다운로드
    const link = document.createElement('a');
    link.download = `capture-${Date.now()}.png`;
    link.href = canvas.toDataURL('image/png');
    link.click();
  } catch (error) {
    console.error('캡처 실패:', error);
  }
};

핵심 구현 포인트

1. 보안 고려사항

typescript
// 허용된 도메인만 프록시
const allowedDomains = [
  'cdn.example.com',
  'assets.mysite.com'
];

if (!allowedDomains.includes(url.hostname)) {
  return NextResponse.json({ error: 'Domain not allowed' }, { status: 403 });
}

허용된 도메인만 프록시하여 보안 위험을 방지합니다.

2. SSR 호환성

typescript
const isExternalImage = (src: string): boolean => {
  try {
    const url = new URL(src);
    // window.location이 아닌 프로토콜로 판단하여 SSR 안전성 확보
    return url.protocol === 'http:' || url.protocol === 'https:';
  } catch {
    return false;
  }
};

서버사이드 렌더링에서도 안전하게 작동하도록 window 객체 의존성을 제거했습니다.

3. 캐시 최적화

typescript
headers: {
  'Content-Type': contentType,
  'Cache-Control': 'public, max-age=31536000, immutable',
  'Access-Control-Allow-Origin': '*',
  'Access-Control-Allow-Methods': 'GET',
  'Access-Control-Allow-Headers': 'Content-Type',
}

이미지를 1년간 캐시하여 성능을 최적화합니다.

트러블슈팅

1. Hydration Mismatch 에러

문제: 서버와 클라이언트에서 다른 URL 생성으로 인한 Hydration 에러
해결: window.location.origin 대신 프로토콜 기반으로 외부 이미지 판단

2. 무한 로딩

문제: 프록시 로더가 적용되지 않아 원본 URL로 요청
해결: SSR 안전한 로직으로 변경하여 서버/클라이언트 일관성 확보

대안 해결 방법들

1. 캐싱이 포함된 개선된 API Route

typescript
// 메모리 캐시 추가
const imageCache = new Map<string, {
  data: ArrayBuffer;
  contentType: string;
  timestamp: number;
}>();

const CACHE_DURATION = 1000 * 60 * 10; // 10분 캐시

export async function GET(request: NextRequest) {
  // 캐시 확인 로직 추가
  const cached = imageCache.get(imageUrl);
  if (cached && Date.now() - cached.timestamp < CACHE_DURATION) {
    return new NextResponse(cached.data, {
      headers: {
        'Content-Type': cached.contentType,
        'Access-Control-Allow-Origin': '*',
        'Cache-Control': 'public, max-age=300',
      },
    });
  }
  
  // fetch 후 캐시에 저장
  imageCache.set(imageUrl, {
    data: imageBuffer,
    contentType,
    timestamp: Date.now(),
  });
}

2. 대안 라이브러리 고려

bash
npm install modern-screenshot
typescript
import { domToJpeg } from 'modern-screenshot';
const dataUrl = await domToJpeg(element);

성능 최적화 및 추가 팁

1. html2canvas 최적화 설정

typescript
const canvas = await html2canvas(element, {
  useCORS: true,
  allowTaint: false,
  scale: 2,
  logging: false,
  backgroundColor: '#ffffff',
  foreignObjectRendering: true,
  imageTimeout: 10000,
  onclone: (clonedDoc) => {
    console.log('문서 클론 완료');
  }
});

2. 이미지 프리로딩

typescript
// 캡처 전 이미지 프리로딩
const preloadImages = async (urls: string[]) => {
  await Promise.all(
    urls.map(url => 
      fetch(`/api/image-proxy?url=${encodeURIComponent(url)}`, { 
        cache: 'no-cache' 
      })
    )
  );
};

결과

CORS 에러 해결: 외부 CDN 이미지를 프록시를 통해 안전하게 로드
html2canvas 호환: 캡처 기능이 정상적으로 작동
성능 최적화: 이미지 캐싱 및 반응형 최적화
보안 강화: 허용된 도메인만 프록시하여 보안 위험 방지
개발자 경험: 기존 Image 컴포넌트와 동일한 API로 쉬운 사용

마무리

Next.js의 API Route를 활용한 이미지 프록시 솔루션으로 CORS 문제를 깔끔하게 해결할 수 있었습니다. 특히 SSR 호환성과 보안을 고려한 설계로 프로덕션 환경에서도 안전하게 사용할 수 있는 솔루션이 되었습니다.

권장 구현 순서

  1. 1단계: API Route 프록시 엔드포인트 생성 (10분)
  2. 2단계: ProxyImage 컴포넌트 구현 (20분)
  3. 3단계: 기존 이미지 컴포넌트 교체 (5분)
  4. 4단계: html2canvas 테스트 및 최적화 (15분)
  5. 5단계: 성능 최적화 및 캐싱 추가 (선택사항)

이 순서대로 진행하면 Next.js 15의 강력한 서버사이드 기능을 활용하여 html2canvas CORS 문제를 가장 효율적으로 해결할 수 있습니다!

핵심 성과

  • html2canvas 호환성: 외부 CDN 이미지가 포함된 화면을 성공적으로 캡처
  • 성능 최적화: 이미지 캐싱과 반응형 최적화로 사용자 경험 향상
  • 보안 강화: 도메인 화이트리스트로 악의적 요청 차단
  • 개발자 경험: 기존 Image 컴포넌트와 동일한 API로 쉬운 마이그레이션

이 방법은 html2canvas뿐만 아니라 Canvas API를 사용하는 다른 라이브러리(Fabric.js, Konva.js 등)에서도 동일하게 적용할 수 있어, 크로스 도메인 이미지 처리가 필요한 다양한 상황에서 유용할 것입니다.


⚠️ API Route 프록시 방식 주의사항

API Route 프록시는 html2canvas CORS 문제를 해결하지만, 서버 리소스 측면에서 고려해야 할 사항이 있습니다:

🖼️ 서버 리소스 문제

html
<!-- Next.js Image가 생성하는 HTML -->
<img srcset="/api/image-proxy?url=...&w=256 256w,
             /api/image-proxy?url=...&w=384 384w,
             /api/image-proxy?url=...&w=640 640w" />

문제점:

  • 모든 이미지 요청이 서버를 거쳐감 → 서버 부하 증가
  • 여러 크기의 이미지가 서버 캐시에 저장 → 메모리 사용량 증가
  • Vercel/Netlify 등에서 Function timeout 발생 가능

💡 해결 방법

1. 적극적인 캐시 정책

typescript
return new NextResponse(imageBuffer, {
  headers: {
    'Cache-Control': 'public, max-age=31536000, immutable',
    'Access-Control-Allow-Origin': '*',
  },
});

2. CDN 활용

  • CloudFlare, AWS CloudFront로 서버 부하 분산
  • 이미지 크기 제한 (최대 5MB)
  • 화이트리스트 도메인 관리

📝 Next.js Image 컴포넌트 기술적 제한사항: unoptimizedloader 충돌 문제, SVG 처리 방법 등은 다음 글에서 상세히 다룹니다.

🚀 성능 최적화 및 추가 팁

1. html2canvas 최적화 설정

typescript
const canvas = await html2canvas(element, {
  useCORS: true,
  allowTaint: false,
  scale: 2,
  logging: false,
  backgroundColor: '#ffffff',
  foreignObjectRendering: true,
  imageTimeout: 10000,
  onclone: (clonedDoc) => {
    console.log('문서 클론 완료');
  }
});

2. 이미지 프리로딩

typescript
// 캡처 전 이미지 프리로딩
const preloadImages = async (urls: string[]) => {
  await Promise.all(
    urls.map(url => 
      fetch(`/api/image-proxy?url=${encodeURIComponent(url)}`, { 
        cache: 'no-cache' 
      })
    )
  );
};

3. 메모리 캐시 추가 (고급)

typescript
// 메모리 캐시 추가
const imageCache = new Map<string, {
  data: ArrayBuffer;
  contentType: string;
  timestamp: number;
}>();

const CACHE_DURATION = 1000 * 60 * 10; // 10분 캐시

export async function GET(request: NextRequest) {
  // 캐시 확인 로직 추가
  const cached = imageCache.get(imageUrl);
  if (cached && Date.now() - cached.timestamp < CACHE_DURATION) {
    return new NextResponse(cached.data, {
      headers: {
        'Content-Type': cached.contentType,
        'Access-Control-Allow-Origin': '*',
        'Cache-Control': 'public, max-age=300',
      },
    });
  }
  
  // fetch 후 캐시에 저장
  imageCache.set(imageUrl, {
    data: imageBuffer,
    contentType,
    timestamp: Date.now(),
  });
}

📚 더 자세한 이미지 최적화 가이드

Next.js 15의 공식 권장사항과 더 자세한 성능 비교를 원한다면 **Next.js 15 이미지 최적화 완전 가이드**를 참고하세요.