← 목록으로

에디터 이미지 삽입

에디터에 이미지 삽입하기: 업로드부터 최적화까지 완벽 가이드

서론

게시글 작성 에디터에서 이미지를 삽입하는 것은 콘텐츠의 품질을 높이는 중요한 기능입니다. 단순히 이미지 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로 클라이언트 사이드 미리보기
  • 드래그 앤 드롭: 직관적인 이미지 삽입
  • 이미지 최적화: 클라이언트/서버 사이드 압축
  • 에러 처리: 업로드 실패와 이미지 로드 실패 처리

적절한 이미지 삽입 기능을 구현하면 사용자가 콘텐츠를 더욱 풍부하게 작성할 수 있습니다.