다양한 루트로 얻은 소스를 짬뽕한거라 일관성은 없지만 대략 사용방법을 터득하기에는 괜찮을 것 같아서 정리해봄

최종 채택은 2번으로 했지만 나머지도 기록 삼아 남김

 

1. 클라이언트 - heic2any: webp 바로 전환은 안되서 png 거쳐서 변환. 갤럭시 울트라 200M 사진은 처리불가

'use client'

import { useState } from 'react'

export function FileUpload() {
	const [file, setFile] = useState<any>()

	// 파일 선택 시 처리
	const handleFileChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
		const file = event.target.files?.[0]
		if (!file) return
		
		const isHeic =
			file.type === 'image/heic' ||
			file.name.toLowerCase().endsWith('.heic') ||
			file.name.toLowerCase().endsWith('.heif')

		if (isHeic) {
			try {
				const heic2any = (await import('heic2any')).default
				// 1. HEIC/HEIF 파일을 PNG Blob으로 변환
				const pngBlob = await heic2any({
					blob: file,
					toType: 'image/png',
				})

				// 2. PNG Blob을 File 객체로 변환 및 WebP로 Canvas 변환
				const convertedFile = await convertToWebP(pngBlob, file.name)
				setFile(convertedFile)
			} catch (error) {
				console.error('### HEIC 변환 실패:', error)
				alert('이미지 변환에 실패했습니다. 다른 이미지를 시도해주세요.')
			}
		} else {
			setFile(file)
		}
	}

	// Blob을 WebP 포맷 File 객체로 변환하는 함수
	const convertToWebP = (blob: Blob | Blob[], originalName: string): Promise<File> => {
		return new Promise((resolve, reject) => {
			const img = new Image()
			const url = URL.createObjectURL(Array.isArray(blob) ? blob[0] : blob)

			img.onload = () => {
				URL.revokeObjectURL(url)
				const { width, height } = imageResize(img.width, img.height)

				// Canvas 생성
				const canvas = document.createElement('canvas')
				canvas.width = width
				canvas.height = height
				const ctx = canvas.getContext('2d')
				if (!ctx) throw new Error('Canvas context를 생성할 수 없습니다.')
				ctx.drawImage(img, 0, 0)
				// ctx.drawImage(img, 0, 0, targetWidth, targetHeight)

				canvas.toBlob(
					(webpBlob) => {
						if (!webpBlob) throw new Error('WebP 변환에 실패했습니다.')
						const newName = originalName.replace(/\.(heic|heif)$/i, '.webp')
						const webpFile = new File([webpBlob], newName, { type: 'image/webp' })
						resolve(webpFile)
					},
					'image/webp',
					0.8,
				)
			}
			img.onerror = (err) => reject(err)
			img.src = url
		})
	}

	// 폼 전송
	const handleSubmit = async (e) => {
		e.preventDefault()
		if (!file) return alert('파일을 선택해주세요.')

		const formData = new FormData()
		formData.append('image', file)
	}

	return (
		<form onSubmit={handleSubmit}>
			<input type="file" accept="image/*" onChange={handleFileChange} />
			<button type="submit">전송</button>
		</form>
	)
}

 

2. 클라이언트 - heic-decode: heic2any가 100%라면 60% 정도의 속도로 빠르게 처리하나 빌드 후 라이브러리 용량이 다소 나감. 갤럭시 울트라 200M 사진은 처리불가

'use client'

import { useState } from 'react'

export function FileUpload() {
	const [loading, setLoading] = useState(false)

	const handleFileChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
		const file = event.target.files?.[0]
		if (!file) return

		setLoading(true)
		let fileToSend: File | Blob = file

		const isHeic = file.name.toLowerCase().endsWith('.heic') || file.name.toLowerCase().endsWith('.heif')

		if (isHeic) {
			try {
				const arrayBuffer = await file.arrayBuffer()

				// 1. HEIC 내부의 원본 픽셀 데이터(RGBA) 추출
				const decode = (await import('heic-decode')).default // 399.78 KB => 910.64 KB
				const { width, height, data } = await decode({ buffer: new Uint8Array(arrayBuffer) })
				const { width: targetWidth, height: targetHeight } = imageResize(width, height)

				// 3. 임시 오프스크린 캔버스 생성하여 리사이징 처리
				const canvas = document.createElement('canvas')
				canvas.width = targetWidth
				canvas.height = targetHeight
				const ctx = canvas.getContext('2d')

				if (!ctx) throw new Error('Canvas context를 생성할 수 없습니다.')

				// 원본 거대 데이터를 임시 ImageData 객체로 만듦
				const originalImageData = new ImageData(new Uint8ClampedArray(data), width, height)

				if (width === targetWidth && height === targetHeight) {
					// 크기가 동일하면 그대로 그림
					ctx.putImageData(originalImageData, 0, 0)
				} else {
					// 임시 캔버스를 거쳐 브라우저 하드웨어 가속을 이용해 부드럽게 축소(Downscale)합니다.
					const tempCanvas = document.createElement('canvas')
					tempCanvas.width = width
					tempCanvas.height = height
					tempCanvas.getContext('2d')?.putImageData(originalImageData, 0, 0)

					// 최종 캔버스에 축소해서 그리기
					ctx.drawImage(tempCanvas, 0, 0, targetWidth, targetHeight)
				}

				// 4. 🔥 [수정] 캔버스 내용을 WebP Blob으로 변환
				const webpBlob = await new Promise<Blob | null>((resolve) => {
					canvas.toBlob((blob) => resolve(blob), 'image/webp', 0.8)
				})

				if (!webpBlob) throw new Error('WebP 변환에 실패했습니다.')

				// 5. 🔥 [수정] 서버 전송용 File 객체 확장자를 .webp로 재포장
				const newFileName = file.name.replace(/\.(heic|heif)$/i, '.webp')
				fileToSend = new File([webpBlob], newFileName, { type: 'image/webp' })
			} catch (error) {
				console.error('브라우저 내 초고해상도 변환 중 에러 발생:', error)
				alert('이미지가 너무 커서 변환에 실패했습니다. 일반 JPG/PNG를 사용해주세요.')
				setLoading(false)
				return
			}
		}

		// 6. Next.js Route Handler로 FormData 전송
		try {
			const formData = new FormData()
			formData.append('file', fileToSend)
		} catch (err) {
			console.error(err)
		} finally {
			setLoading(false)
		}
	}
	return (
		<>
			<div>
				<input type="file" accept=".heic,.heif" onChange={handleFileChange} disabled={loading} />
				{loading && <p>이미지 최적화 및 변환 중... 잠시만 기다려주세요 (화면을 끄지 마세요)</p>}
			</div>
		</>
	)
}

 

3. 클라이언트 - @imagemagick/magick-wasm: 갤럭시 울트라 200M 사진도 처리가 가능하나 속도가 압도적으로 느리다.

'use client'
import NextImage from 'next/image'
import { useState } from 'react'

async function initMagick() {
    const { initializeImageMagick } = await import('@imagemagick/magick-wasm')
	const response = await fetch('/magick.wasm') // public 경로. 해당 파일은 \node_modules\@imagemagick\magick-wasm\dist 안에 있음
	const wasmBuffer = await response.arrayBuffer()
	await initializeImageMagick(wasmBuffer)
}
initMagick()

export function FileUpload() {
	const [outputUrl, setOutputUrl] = useState({ src: '', width: 0, height: 0 })

	const handleConvert = async (event: React.ChangeEvent<HTMLInputElement>) => {
		const file = event.target.files?.[0]
		if (!file) return
		const { ImageMagick } = await import('@imagemagick/magick-wasm')

		// 2. Read file as Uint8Array
		const arrayBuffer = await file.arrayBuffer()
		const data = new Uint8Array(arrayBuffer)

		// 3. Perform transformation
		ImageMagick.read(data, (image) => {
			const { width, height } = imageResize(image.width, image.height)
			image.resize(width, height)
			image.format = 'WEBP'

			// 4. Write back to a Blob for display
			image.write((outputData) => {
				const url = window.URL.createObjectURL(new Blob([new Uint8Array(outputData).buffer], { type: 'image/webp' }))
				setOutputUrl({ src: url, width, height })
			})
		})
	}

	return (
		<div>
			<input type="file" onChange={handleConvert} />
			{outputUrl.src && <NextImage {...outputUrl} alt="Converted" />}
		</div>
	)
}

function imageResize(width: number, height: number) {
	const MAX_WIDTH = 4000
	const MAX_HEIGHT = 4000
	let targetWidth = width
	let targetHeight = height

	if (width > MAX_WIDTH || height > MAX_HEIGHT) {
		if (width > height) {
			targetWidth = MAX_WIDTH
			targetHeight = Math.round((height * MAX_WIDTH) / width)
		} else {
			targetHeight = MAX_HEIGHT
			targetWidth = Math.round((width * MAX_HEIGHT) / height)
		}
	}
	return { width: targetWidth, height: targetHeight }
}

 

4. 서버 - heic-convert, sharp: 갤럭시 울트라 200M 사진은 처리불가

import { NextRequest, NextResponse } from 'next/server';
import convert from 'heic-convert'
import sharp from 'sharp'

async function handler(req: NextRequest) {


	// ...전략
	
		const formdata = await req.formData();

		const newFormData = new FormData()

		for (const [key, value] of formdata.entries()) {
			if (!value) {
				newFormData.append(key, '')
			} else if (value instanceof File === false) {
				newFormData.append(key, value)
			} else {
				const file = value
				const newFileName = file.name.split('.')
				const ext = newFileName.pop()

				if (!ext || ['heic', 'heif'].includes(ext.toLowerCase()) === false) {
					newFormData.append(key, value)
				} else {
					const outputBuffer = await convert({
						buffer: new Uint8Array(await file.arrayBuffer()) as unknown as ArrayBuffer,
						format: 'PNG',
					})
					// 2. 변환된 버퍼를 sharp로 처리 (리사이징 등)
					const webpBuffer = await sharp(outputBuffer).webp({ quality: 80 }).toBuffer()

					// 3. Web API의 File 객체 형태로 만들어 FormData에 추가
					const finalFile = new File([webpBuffer as any], `${newFileName}.webp`, { type: 'image/webp' })
					newFormData.append(key, finalFile)
				}
			}
		}

// ...후략		
}

export const POST = handler;
export const PUT = handler;
export const PATCH = handler;
export const OPTIONS = handler;

 

공통함수

export function imageResize(width: number, height: number) {
	const MAX_WIDTH = 4000
	const MAX_HEIGHT = 4000
	let targetWidth = width
	let targetHeight = height

	if (width > MAX_WIDTH || height > MAX_HEIGHT) {
		if (width > height) {
			targetWidth = MAX_WIDTH
			targetHeight = Math.round((height * MAX_WIDTH) / width)
		} else {
			targetHeight = MAX_HEIGHT
			targetWidth = Math.round((width * MAX_HEIGHT) / height)
		}
	}
	return { width: targetWidth, height: targetHeight }
}

 

 

+ Recent posts