Next.js 15에서 html2canvas CORS 에러 해결하기: 이미지 프록시 솔루션
html2canvas 사용 시 외부 CDN 이미지로 인한 CORS 에러를 Next.js API Route 프록시로 완벽 해결한 과정과 다양한 해결 방법들을 상세히 소개합니다.
Next.js 15에서 html2canvas CORS 에러 해결하기: 이미지 프록시 솔루션
html2canvas를 사용해 화면 캡처 기능을 구현하다 보면 외부 CDN 이미지로 인한 CORS 에러를 자주 만나게 됩니다. 이 글에서는 여러 해결 방법을 비교 분석하고, 실제로 성공한 Next.js API Route 프록시 솔루션을 상세히 공유합니다.
📋 목차
문제 상황과 원인
웹 애플리케이션에서 html2canvas를 사용해 화면을 캡처하려고 했는데 다음과 같은 CORS 에러가 발생했습니다:
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 | 클라이언트 최적화 | ⭐ | ⭐⭐⭐ | 즉시 | ⚠️ 임시방편 |
| 5 | Middleware CORS | ⭐⭐⭐ | ⭐⭐⭐ | 전역 | ❌ 복잡함 |
1. 클라이언트 최적화 (즉시 적용 가능) ⚠️
가장 빠르게 적용할 수 있는 임시 해결책입니다:
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 (간단한 프록시) ⚠️
설정만으로 간단히 해결하는 방법:
// 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 컴포넌트의 강력한 기능을 활용:
// 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 프록시 엔드포인트 생성
가장 핵심이 되는 서버사이드 프록시 엔드포인트를 구현합니다:
// 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 컴포넌트 구현
외부 이미지를 자동으로 프록시를 통해 로드하는 컴포넌트를 만듭니다:
// 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로 교체하기만 하면 됩니다.
교체 과정:
- 기존
<Image>또는<img>태그를<ProxyImage>로 변경 - 외부 CDN URL을 사용하는 이미지들을 우선적으로 교체
- 필요에 따라
fallbackSrcprop으로 대체 이미지 지정 - 기존 props(
width,height,className등)은 그대로 유지 가능
핵심 포인트: ProxyImage는 Next.js Image와 동일한 API를 제공하므로, 기존 코드의 변경 없이도 CORS 문제를 해결할 수 있습니다.
4단계: html2canvas와 함께 사용
html2canvas로 캡처할 때 ProxyImage가 포함된 컴포넌트를 캡처하면 CORS 문제 없이 정상 작동합니다:
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. 보안 고려사항
// 허용된 도메인만 프록시
const allowedDomains = [
'cdn.example.com',
'assets.mysite.com'
];
if (!allowedDomains.includes(url.hostname)) {
return NextResponse.json({ error: 'Domain not allowed' }, { status: 403 });
}
허용된 도메인만 프록시하여 보안 위험을 방지합니다.
2. SSR 호환성
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. 캐시 최적화
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
// 메모리 캐시 추가
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. 대안 라이브러리 고려
npm install modern-screenshot
import { domToJpeg } from 'modern-screenshot';
const dataUrl = await domToJpeg(element);
성능 최적화 및 추가 팁
1. html2canvas 최적화 설정
const canvas = await html2canvas(element, {
useCORS: true,
allowTaint: false,
scale: 2,
logging: false,
backgroundColor: '#ffffff',
foreignObjectRendering: true,
imageTimeout: 10000,
onclone: (clonedDoc) => {
console.log('문서 클론 완료');
}
});
2. 이미지 프리로딩
// 캡처 전 이미지 프리로딩
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단계: API Route 프록시 엔드포인트 생성 (10분)
- 2단계: ProxyImage 컴포넌트 구현 (20분)
- 3단계: 기존 이미지 컴포넌트 교체 (5분)
- 4단계: html2canvas 테스트 및 최적화 (15분)
- 5단계: 성능 최적화 및 캐싱 추가 (선택사항)
이 순서대로 진행하면 Next.js 15의 강력한 서버사이드 기능을 활용하여 html2canvas CORS 문제를 가장 효율적으로 해결할 수 있습니다!
핵심 성과
- html2canvas 호환성: 외부 CDN 이미지가 포함된 화면을 성공적으로 캡처
- 성능 최적화: 이미지 캐싱과 반응형 최적화로 사용자 경험 향상
- 보안 강화: 도메인 화이트리스트로 악의적 요청 차단
- 개발자 경험: 기존 Image 컴포넌트와 동일한 API로 쉬운 마이그레이션
이 방법은 html2canvas뿐만 아니라 Canvas API를 사용하는 다른 라이브러리(Fabric.js, Konva.js 등)에서도 동일하게 적용할 수 있어, 크로스 도메인 이미지 처리가 필요한 다양한 상황에서 유용할 것입니다.
⚠️ API Route 프록시 방식 주의사항
API Route 프록시는 html2canvas CORS 문제를 해결하지만, 서버 리소스 측면에서 고려해야 할 사항이 있습니다:
🖼️ 서버 리소스 문제
<!-- 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. 적극적인 캐시 정책
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 컴포넌트 기술적 제한사항:
unoptimized와loader충돌 문제, SVG 처리 방법 등은 다음 글에서 상세히 다룹니다.
🚀 성능 최적화 및 추가 팁
1. html2canvas 최적화 설정
const canvas = await html2canvas(element, {
useCORS: true,
allowTaint: false,
scale: 2,
logging: false,
backgroundColor: '#ffffff',
foreignObjectRendering: true,
imageTimeout: 10000,
onclone: (clonedDoc) => {
console.log('문서 클론 완료');
}
});
2. 이미지 프리로딩
// 캡처 전 이미지 프리로딩
const preloadImages = async (urls: string[]) => {
await Promise.all(
urls.map(url =>
fetch(`/api/image-proxy?url=${encodeURIComponent(url)}`, {
cache: 'no-cache'
})
)
);
};
3. 메모리 캐시 추가 (고급)
// 메모리 캐시 추가
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 이미지 최적화 완전 가이드**를 참고하세요.