에디터 이미지 삽입
에디터에 이미지 삽입하기: 업로드부터 최적화까지 완벽 가이드
서론
게시글 작성 에디터에서 이미지를 삽입하는 것은 콘텐츠의 품질을 높이는 중요한 기능입니다. 단순히 이미지 URL을 삽입하는 것부터 파일 업로드, 이미지 최적화, 미리보기까지 다양한 방법이 있습니다. 이 글에서는 React Quill, Quill, 그리고 다른 에디터에서 이미지를 효과적으로 삽입하는 방법을 다루겠습니다.
1. React Quill에서 이미지 삽입
1.1 기본 이미지 핸들러 구현
'use client';
import { useRef } from 'react';
import dynamic from 'next/dynamic';
const ReactQuill = dynamic(() => import('react-quill-new'), { ssr: false });
import 'react-quill-new/dist/quill.snow.css';
export default function EditorWithImage() {
const quillRef = useRef(null);
const [content, setContent] = useState('');
const imageHandler = () => {
const input = document.createElement('input');
input.setAttribute('type', 'file');
input.setAttribute('accept', 'image/*');
input.click();
input.onchange = async () => {
const file = input.files?.[0];
if (!file) return;
try {
// FormData 생성
const formData = new FormData();
formData.append('image', file);
// 이미지 업로드 API 호출
const response = await fetch('/api/upload', {
method: 'POST',
body: formData,
});
if (!response.ok) {
throw new Error('이미지 업로드 실패');
}
const data = await response.json();
const imageUrl = data.url;
// Quill 에디터에 이미지 삽입
const quill = quillRef.current?.getEditor();
const range = quill?.getSelection();
const index = range?.index || 0;
quill?.insertEmbed(index, 'image', imageUrl);
quill?.setSelection(index + 1);
} catch (error) {
console.error('이미지 업로드 실패:', error);
alert('이미지 업로드에 실패했습니다.');
}
};
};
const modules = {
toolbar: {
container: [
[{ header: [1, 2, 3, false] }],
['bold', 'italic', 'underline'],
[{ list: 'ordered' }, { list: 'bullet' }],
['link', 'image'],
],
handlers: {
image: imageHandler,
},
},
};
return (
<ReactQuill
ref={quillRef}
theme="snow"
value={content}
onChange={setContent}
modules={modules}
/>
);
}
1.2 이미지 업로드 진행 상태 표시
const imageHandler = () => {
const input = document.createElement('input');
input.setAttribute('type', 'file');
input.setAttribute('accept', 'image/*');
input.click();
input.onchange = async () => {
const file = input.files?.[0];
if (!file) return;
const quill = quillRef.current?.getEditor();
const range = quill?.getSelection();
const index = range?.index || 0;
// 업로드 중 플레이스홀더 삽입
const placeholderId = `uploading-${Date.now()}`;
quill?.insertEmbed(index, 'image', '/loading.gif', 'user');
quill?.setSelection(index + 1);
try {
const formData = new FormData();
formData.append('image', file);
const response = await fetch('/api/upload', {
method: 'POST',
body: formData,
});
const data = await response.json();
// 플레이스홀더를 실제 이미지로 교체
const length = quill?.getLength() || 0;
quill?.deleteText(index, 1);
quill?.insertEmbed(index, 'image', data.url);
} catch (error) {
// 업로드 실패 시 플레이스홀더 제거
quill?.deleteText(index, 1);
alert('이미지 업로드에 실패했습니다.');
}
};
};
2. 이미지 업로드 API 구현
2.1 Next.js API Route
// app/api/upload/route.js
import { NextResponse } from 'next/server';
import { writeFile } from 'fs/promises';
import path from 'path';
export async function POST(request) {
try {
const formData = await request.formData();
const file = formData.get('image');
if (!file) {
return NextResponse.json(
{ error: '파일이 없습니다.' },
{ status: 400 }
);
}
// 파일 타입 검증
if (!file.type.startsWith('image/')) {
return NextResponse.json(
{ error: '이미지 파일만 업로드 가능합니다.' },
{ status: 400 }
);
}
// 파일 크기 제한 (예: 5MB)
const maxSize = 5 * 1024 * 1024; // 5MB
if (file.size > maxSize) {
return NextResponse.json(
{ error: '파일 크기는 5MB를 초과할 수 없습니다.' },
{ status: 400 }
);
}
const bytes = await file.arrayBuffer();
const buffer = Buffer.from(bytes);
// 고유한 파일명 생성
const timestamp = Date.now();
const randomString = Math.random().toString(36).substring(2, 15);
const extension = path.extname(file.name);
const filename = `${timestamp}-${randomString}${extension}`;
// 파일 저장 경로
const filepath = path.join(process.cwd(), 'public', 'uploads', filename);
// 디렉토리 생성 (필요한 경우)
const uploadsDir = path.join(process.cwd(), 'public', 'uploads');
await fs.mkdir(uploadsDir, { recursive: true });
// 파일 저장
await writeFile(filepath, buffer);
// URL 반환
const url = `/uploads/${filename}`;
return NextResponse.json({ url });
} catch (error) {
console.error('업로드 에러:', error);
return NextResponse.json(
{ error: '업로드 실패' },
{ status: 500 }
);
}
}
2.2 클라우드 스토리지 업로드 (AWS S3 예시)
// app/api/upload/route.js
import { S3Client, PutObjectCommand } from '@aws-sdk/client-s3';
import { NextResponse } from 'next/server';
const s3Client = new S3Client({
region: process.env.AWS_REGION,
credentials: {
accessKeyId: process.env.AWS_ACCESS_KEY_ID,
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY,
},
});
export async function POST(request) {
try {
const formData = await request.formData();
const file = formData.get('image');
if (!file) {
return NextResponse.json({ error: '파일이 없습니다.' }, { status: 400 });
}
const bytes = await file.arrayBuffer();
const buffer = Buffer.from(bytes);
const filename = `${Date.now()}-${file.name}`;
// S3에 업로드
const command = new PutObjectCommand({
Bucket: process.env.AWS_S3_BUCKET_NAME,
Key: `uploads/${filename}`,
Body: buffer,
ContentType: file.type,
});
await s3Client.send(command);
// S3 URL 반환
const url = `https://${process.env.AWS_S3_BUCKET_NAME}.s3.${process.env.AWS_REGION}.amazonaws.com/uploads/${filename}`;
return NextResponse.json({ url });
} catch (error) {
console.error('S3 업로드 에러:', error);
return NextResponse.json(
{ error: '업로드 실패' },
{ status: 500 }
);
}
}
3. 이미지 미리보기
3.1 업로드 전 미리보기
const imageHandler = () => {
const input = document.createElement('input');
input.setAttribute('type', 'file');
input.setAttribute('accept', 'image/*');
input.click();
input.onchange = async () => {
const file = input.files?.[0];
if (!file) return;
// FileReader로 미리보기 생성
const reader = new FileReader();
reader.onload = (e) => {
const previewUrl = e.target.result;
// 사용자 확인
if (confirm('이 이미지를 업로드하시겠습니까?')) {
uploadImage(file);
}
};
reader.readAsDataURL(file);
};
};
3.2 드래그 앤 드롭으로 이미지 삽입
'use client';
import { useRef, useState } from 'react';
export default function DragDropEditor() {
const quillRef = useRef(null);
const [isDragging, setIsDragging] = useState(false);
const handleDragOver = (e) => {
e.preventDefault();
setIsDragging(true);
};
const handleDragLeave = () => {
setIsDragging(false);
};
const handleDrop = async (e) => {
e.preventDefault();
setIsDragging(false);
const files = Array.from(e.dataTransfer.files);
const imageFiles = files.filter(file => file.type.startsWith('image/'));
for (const file of imageFiles) {
await uploadAndInsertImage(file);
}
};
const uploadAndInsertImage = async (file) => {
try {
const formData = new FormData();
formData.append('image', file);
const response = await fetch('/api/upload', {
method: 'POST',
body: formData,
});
const data = await response.json();
const quill = quillRef.current?.getEditor();
const range = quill?.getSelection();
const index = range?.index || 0;
quill?.insertEmbed(index, 'image', data.url);
} catch (error) {
console.error('이미지 업로드 실패:', error);
}
};
return (
<div
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
className={isDragging ? 'border-2 border-blue-500' : ''}
>
<ReactQuill ref={quillRef} theme="snow" />
</div>
);
}
4. 이미지 최적화
4.1 클라이언트 사이드 이미지 압축
import imageCompression from 'browser-image-compression';
const compressImage = async (file) => {
const options = {
maxSizeMB: 1, // 최대 파일 크기
maxWidthOrHeight: 1920, // 최대 너비/높이
useWebWorker: true,
};
try {
const compressedFile = await imageCompression(file, options);
return compressedFile;
} catch (error) {
console.error('이미지 압축 실패:', error);
return file;
}
};
const imageHandler = async () => {
const input = document.createElement('input');
input.setAttribute('type', 'file');
input.setAttribute('accept', 'image/*');
input.click();
input.onchange = async () => {
const file = input.files?.[0];
if (!file) return;
// 이미지 압축
const compressedFile = await compressImage(file);
// 압축된 파일 업로드
const formData = new FormData();
formData.append('image', compressedFile);
const response = await fetch('/api/upload', {
method: 'POST',
body: formData,
});
const data = await response.json();
// 이미지 삽입...
};
};
4.2 서버 사이드 이미지 최적화
// app/api/upload/route.js
import sharp from 'sharp';
export async function POST(request) {
const formData = await request.formData();
const file = formData.get('image');
const bytes = await file.arrayBuffer();
const buffer = Buffer.from(bytes);
// Sharp로 이미지 최적화
const optimizedBuffer = await sharp(buffer)
.resize(1920, 1920, { fit: 'inside', withoutEnlargement: true })
.jpeg({ quality: 80 })
.toBuffer();
const filename = `${Date.now()}.jpg`;
const filepath = path.join(process.cwd(), 'public', 'uploads', filename);
await writeFile(filepath, optimizedBuffer);
return NextResponse.json({ url: `/uploads/${filename}` });
}
5. 이미지 URL 직접 입력
5.1 URL 입력 모달
const imageHandler = () => {
const url = prompt('이미지 URL을 입력하세요:');
if (url) {
const quill = quillRef.current?.getEditor();
const range = quill?.getSelection();
const index = range?.index || 0;
quill?.insertEmbed(index, 'image', url);
}
};
5.2 커스텀 이미지 입력 모달
'use client';
import { useState } from 'react';
export default function CustomImageModal({ isOpen, onClose, onInsert }) {
const [imageUrl, setImageUrl] = useState('');
const [file, setFile] = useState(null);
const handleSubmit = async () => {
if (file) {
// 파일 업로드
const formData = new FormData();
formData.append('image', file);
const response = await fetch('/api/upload', {
method: 'POST',
body: formData,
});
const data = await response.json();
onInsert(data.url);
} else if (imageUrl) {
// URL 사용
onInsert(imageUrl);
}
onClose();
};
if (!isOpen) return null;
return (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<div className="bg-white p-6 rounded-lg w-96">
<h2 className="text-xl font-bold mb-4">이미지 삽입</h2>
<div className="mb-4">
<label className="block mb-2">이미지 URL</label>
<input
type="text"
value={imageUrl}
onChange={(e) => setImageUrl(e.target.value)}
className="w-full px-3 py-2 border rounded"
placeholder="https://example.com/image.jpg"
/>
</div>
<div className="mb-4">
<label className="block mb-2">또는 파일 업로드</label>
<input
type="file"
accept="image/*"
onChange={(e) => setFile(e.target.files?.[0])}
className="w-full"
/>
</div>
<div className="flex gap-2 justify-end">
<button
onClick={onClose}
className="px-4 py-2 border rounded"
>
취소
</button>
<button
onClick={handleSubmit}
className="px-4 py-2 bg-blue-500 text-white rounded"
>
삽입
</button>
</div>
</div>
</div>
);
}
6. 이미지 크기 조절
6.1 Quill 이미지 리사이저
import Quill from 'quill';
import ImageResize from 'quill-image-resize-module-react';
Quill.register('modules/imageResize', ImageResize);
const modules = {
toolbar: {
container: [['image']],
},
imageResize: {
parchment: Quill.import('parchment'),
modules: ['Resize', 'DisplaySize'],
},
};
7. 에러 처리
7.1 이미지 로드 실패 처리
const handleImageError = (e) => {
e.target.src = '/placeholder-image.jpg';
e.target.alt = '이미지를 불러올 수 없습니다.';
};
// Quill 이미지에 에러 핸들러 추가
useEffect(() => {
const quill = quillRef.current?.getEditor();
if (quill) {
const editorElement = quill.root;
editorElement.addEventListener('error', handleImageError, true);
return () => {
editorElement.removeEventListener('error', handleImageError, true);
};
}
}, []);
8. 결론
에디터에 이미지를 삽입하는 방법은 다양하며, 프로젝트의 요구사항에 따라 선택할 수 있습니다. 파일 업로드, URL 입력, 드래그 앤 드롭 등 다양한 방법을 제공하고, 이미지 최적화와 에러 처리를 통해 사용자 경험을 향상시킬 수 있습니다.
주요 포인트:
- 파일 업로드: FormData와 API Route 활용
- 미리보기: FileReader로 클라이언트 사이드 미리보기
- 드래그 앤 드롭: 직관적인 이미지 삽입
- 이미지 최적화: 클라이언트/서버 사이드 압축
- 에러 처리: 업로드 실패와 이미지 로드 실패 처리
적절한 이미지 삽입 기능을 구현하면 사용자가 콘텐츠를 더욱 풍부하게 작성할 수 있습니다.