← 목록으로

Next.js 리치 텍스트 에디터

Next.js에서 리치 텍스트 에디터 구현하기: React-quill-new 완벽 가이드

서론

현대 웹 애플리케이션에서 사용자가 텍스트를 서식화하고 이미지, 링크 등을 삽입할 수 있는 리치 텍스트 에디터는 필수적인 기능입니다. 블로그, CMS, 이메일 작성기 등 다양한 곳에서 사용되며, Next.js와 React를 사용하여 구현할 때는 적절한 라이브러리 선택이 중요합니다. 이 글에서는 Next.js에서 리치 텍스트 에디터를 구현하는 방법을 다루고, 특히 React-quill-new를 활용한 실전 구현 방법을 상세히 설명하겠습니다.

1. Next.js에서 에디터 구현 시 고려사항

1.1 서버 컴포넌트와 클라이언트 컴포넌트

Next.js 13+ App Router에서는 기본적으로 서버 컴포넌트입니다. 에디터는 사용자 인터랙션이 필요하므로 반드시 클라이언트 컴포넌트로 만들어야 합니다.

// ❌ 나쁜 예: 서버 컴포넌트에서 에디터 사용
export default function Editor() {
  return <div>에디터</div>;
}

// ✅ 좋은 예: 클라이언트 컴포넌트로 명시
'use client';

export default function Editor() {
  return <div>에디터</div>;
}

1.2 동적 임포트

에디터 라이브러리는 보통 크기가 크므로, 동적 임포트를 사용하여 번들 크기를 최적화할 수 있습니다.

'use client';

import dynamic from 'next/dynamic';

const Editor = dynamic(() => import('./EditorComponent'), {
  ssr: false,
  loading: () => <p>에디터 로딩 중...</p>,
});

export default function Page() {
  return <Editor />;
}

2. React-quill-new 소개

2.1 React-quill-new란?

React-quill-new는 Quill 에디터의 React 래퍼로, 기존 react-quill의 개선된 버전입니다. Quill은 모던하고 강력한 리치 텍스트 에디터로, WYSIWYG(What You See Is What You Get) 편집 경험을 제공합니다.

주요 특징

  • 가벼움: 번들 크기가 작고 성능이 우수함
  • 커스터마이징: 툴바, 포맷, 테마 등을 자유롭게 설정 가능
  • 확장성: 모듈 시스템으로 기능 확장 용이
  • 접근성: 키보드 네비게이션과 스크린 리더 지원

2.2 설치

npm install react-quill-new quill
# 또는
yarn add react-quill-new quill
# 또는
pnpm add react-quill-new quill

Quill의 CSS도 함께 임포트해야 합니다.

3. 기본 사용법

3.1 간단한 에디터 구현

'use client';

import { useState } from 'react';
import dynamic from 'next/dynamic';

// 동적 임포트로 SSR 방지
const ReactQuill = dynamic(() => import('react-quill-new'), { ssr: false });
import 'react-quill-new/dist/quill.snow.css';

export default function SimpleEditor() {
  const [content, setContent] = useState('');

  return (
    <div className="w-full max-w-4xl mx-auto p-4">
      <ReactQuill
        theme="snow"
        value={content}
        onChange={setContent}
        placeholder="내용을 입력하세요..."
      />
    </div>
  );
}

3.2 에디터 값 관리

'use client';

import { useState } 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 EditorWithState() {
  const [content, setContent] = useState('');

  const handleChange = (value) => {
    setContent(value);
    console.log('에디터 내용:', value);
  };

  const handleSubmit = () => {
    console.log('제출할 내용:', content);
    // API 호출 등
  };

  return (
    <div className="w-full max-w-4xl mx-auto p-4">
      <ReactQuill
        theme="snow"
        value={content}
        onChange={handleChange}
        placeholder="내용을 입력하세요..."
        className="bg-white rounded-lg"
      />
      
      <div className="mt-4">
        <button
          onClick={handleSubmit}
          className="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600"
        >
          저장하기
        </button>
      </div>

      {/* 미리보기 */}
      <div className="mt-8 p-4 border rounded-lg">
        <h3 className="font-bold mb-2">미리보기:</h3>
        <div dangerouslySetInnerHTML={{ __html: content }} />
      </div>
    </div>
  );
}

4. 툴바 커스터마이징

4.1 기본 툴바 설정

'use client';

import { useState } 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 CustomToolbarEditor() {
  const [content, setContent] = useState('');

  const modules = {
    toolbar: [
      [{ header: [1, 2, 3, false] }],
      ['bold', 'italic', 'underline', 'strike'],
      [{ list: 'ordered' }, { list: 'bullet' }],
      [{ color: [] }, { background: [] }],
      ['link', 'image'],
      ['clean'],
    ],
  };

  const formats = [
    'header',
    'bold',
    'italic',
    'underline',
    'strike',
    'list',
    'bullet',
    'color',
    'background',
    'link',
    'image',
  ];

  return (
    <div className="w-full max-w-4xl mx-auto p-4">
      <ReactQuill
        theme="snow"
        value={content}
        onChange={setContent}
        modules={modules}
        formats={formats}
        placeholder="내용을 입력하세요..."
      />
    </div>
  );
}

4.2 커스텀 툴바

'use client';

import { useState, 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 CustomToolbarComponent() {
  const [content, setContent] = useState('');
  const quillRef = useRef(null);

  const modules = {
    toolbar: {
      container: [
        [{ header: [1, 2, 3, false] }],
        ['bold', 'italic', 'underline'],
        [{ list: 'ordered' }, { list: 'bullet' }],
        ['link', 'image'],
      ],
      handlers: {
        // 커스텀 핸들러 추가 가능
      },
    },
  };

  return (
    <div className="w-full max-w-4xl mx-auto p-4">
      <ReactQuill
        ref={quillRef}
        theme="snow"
        value={content}
        onChange={setContent}
        modules={modules}
        placeholder="내용을 입력하세요..."
      />
    </div>
  );
}

5. 이미지 업로드 처리

5.1 이미지 핸들러 구현

'use client';

import { useState, 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 EditorWithImageUpload() {
  const [content, setContent] = useState('');
  const quillRef = useRef(null);

  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,
        });

        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 (
    <div className="w-full max-w-4xl mx-auto p-4">
      <ReactQuill
        ref={quillRef}
        theme="snow"
        value={content}
        onChange={setContent}
        modules={modules}
        placeholder="내용을 입력하세요..."
      />
    </div>
  );
}

5.2 이미지 업로드 API (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 });
    }

    const bytes = await file.arrayBuffer();
    const buffer = Buffer.from(bytes);

    // 파일 저장 경로
    const filename = `${Date.now()}-${file.name}`;
    const filepath = path.join(process.cwd(), 'public', 'uploads', filename);

    // 디렉토리 생성 (필요한 경우)
    await writeFile(filepath, buffer);

    // URL 반환
    const url = `/uploads/${filename}`;

    return NextResponse.json({ url });
  } catch (error) {
    console.error('업로드 에러:', error);
    return NextResponse.json(
      { error: '업로드 실패' },
      { status: 500 }
    );
  }
}

6. 스타일링과 테마

6.1 Tailwind CSS와 통합

'use client';

import { useState } 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 StyledEditor() {
  const [content, setContent] = useState('');

  return (
    <div className="w-full max-w-4xl mx-auto p-4">
      <div className="bg-white rounded-lg shadow-lg overflow-hidden">
        <ReactQuill
          theme="snow"
          value={content}
          onChange={setContent}
          placeholder="내용을 입력하세요..."
          className="[&_.ql-editor]:min-h-[300px] [&_.ql-toolbar]:border-b [&_.ql-container]:border-0"
        />
      </div>
    </div>
  );
}

6.2 커스텀 CSS 스타일링

// styles/editor.css
.quill-editor .ql-editor {
  min-height: 300px;
  font-size: 16px;
  line-height: 1.6;
}

.quill-editor .ql-toolbar {
  border-top-left-radius: 8px;
  border-top-right-radius: 8px;
  background-color: #f8f9fa;
}

.quill-editor .ql-container {
  border-bottom-left-radius: 8px;
  border-bottom-right-radius: 8px;
}

.quill-editor .ql-editor.ql-blank::before {
  font-style: normal;
  color: #9ca3af;
}
'use client';

import { useState } from 'react';
import dynamic from 'next/dynamic';
import '../styles/editor.css';

const ReactQuill = dynamic(() => import('react-quill-new'), { ssr: false });
import 'react-quill-new/dist/quill.snow.css';

export default function CustomStyledEditor() {
  const [content, setContent] = useState('');

  return (
    <div className="w-full max-w-4xl mx-auto p-4">
      <div className="quill-editor">
        <ReactQuill
          theme="snow"
          value={content}
          onChange={setContent}
          placeholder="내용을 입력하세요..."
        />
      </div>
    </div>
  );
}

7. 폼과 함께 사용하기

7.1 React Hook Form과 통합

'use client';

import { useForm } from 'react-hook-form';
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 EditorWithForm() {
  const { register, handleSubmit, setValue, watch } = useForm();

  const content = watch('content') || '';

  const modules = {
    toolbar: [
      [{ header: [1, 2, 3, false] }],
      ['bold', 'italic', 'underline'],
      [{ list: 'ordered' }, { list: 'bullet' }],
      ['link', 'image'],
    ],
  };

  const onSubmit = (data) => {
    console.log('폼 데이터:', data);
    // API 호출 등
  };

  return (
    <form onSubmit={handleSubmit(onSubmit)} className="w-full max-w-4xl mx-auto p-4">
      <div className="mb-4">
        <label className="block text-sm font-medium mb-2">제목</label>
        <input
          {...register('title')}
          type="text"
          className="w-full px-4 py-2 border rounded-lg"
        />
      </div>

      <div className="mb-4">
        <label className="block text-sm font-medium mb-2">내용</label>
        <ReactQuill
          theme="snow"
          value={content}
          onChange={(value) => setValue('content', value)}
          modules={modules}
          placeholder="내용을 입력하세요..."
        />
        <input
          type="hidden"
          {...register('content')}
        />
      </div>

      <button
        type="submit"
        className="px-6 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600"
      >
        저장하기
      </button>
    </form>
  );
}

7.2 제출 전 유효성 검사

'use client';

import { useState } 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 EditorWithValidation() {
  const [content, setContent] = useState('');
  const [error, setError] = useState('');

  const handleChange = (value) => {
    setContent(value);
    setError(''); // 에러 초기화
  };

  const handleSubmit = () => {
    // HTML 태그 제거하여 순수 텍스트 길이 확인
    const textContent = content.replace(/<[^>]*>/g, '').trim();

    if (textContent.length < 10) {
      setError('내용은 최소 10자 이상 입력해주세요.');
      return;
    }

    if (textContent.length > 10000) {
      setError('내용은 10,000자를 초과할 수 없습니다.');
      return;
    }

    // 제출 로직
    console.log('제출할 내용:', content);
  };

  return (
    <div className="w-full max-w-4xl mx-auto p-4">
      <ReactQuill
        theme="snow"
        value={content}
        onChange={handleChange}
        placeholder="내용을 입력하세요..."
      />

      {error && (
        <p className="mt-2 text-red-500 text-sm">{error}</p>
      )}

      <div className="mt-4">
        <button
          onClick={handleSubmit}
          className="px-6 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600"
        >
          저장하기
        </button>
      </div>
    </div>
  );
}

8. 고급 기능

8.1 자동 저장 기능

'use client';

import { useState, useEffect, 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 EditorWithAutoSave() {
  const [content, setContent] = useState('');
  const [saved, setSaved] = useState(false);
  const saveTimeoutRef = useRef(null);

  useEffect(() => {
    // 이전 자동 저장 타이머 클리어
    if (saveTimeoutRef.current) {
      clearTimeout(saveTimeoutRef.current);
    }

    // 2초 후 자동 저장
    saveTimeoutRef.current = setTimeout(() => {
      if (content.trim()) {
        // 로컬 스토리지에 저장
        localStorage.setItem('draft-content', content);
        setSaved(true);
        
        // 저장 표시 2초 후 제거
        setTimeout(() => setSaved(false), 2000);
      }
    }, 2000);

    return () => {
      if (saveTimeoutRef.current) {
        clearTimeout(saveTimeoutRef.current);
      }
    };
  }, [content]);

  // 페이지 로드 시 초안 불러오기
  useEffect(() => {
    const draft = localStorage.getItem('draft-content');
    if (draft) {
      setContent(draft);
    }
  }, []);

  return (
    <div className="w-full max-w-4xl mx-auto p-4">
      {saved && (
        <div className="mb-2 text-sm text-green-500">자동 저장됨</div>
      )}
      
      <ReactQuill
        theme="snow"
        value={content}
        onChange={setContent}
        placeholder="내용을 입력하세요..."
      />
    </div>
  );
}

8.2 단어 수 카운터

'use client';

import { useState, useMemo } 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 EditorWithWordCount() {
  const [content, setContent] = useState('');

  const stats = useMemo(() => {
    const text = content.replace(/<[^>]*>/g, ''); // HTML 태그 제거
    const words = text.trim().split(/\s+/).filter(word => word.length > 0);
    const characters = text.length;
    const charactersNoSpaces = text.replace(/\s/g, '').length;

    return {
      words: words.length,
      characters,
      charactersNoSpaces,
    };
  }, [content]);

  return (
    <div className="w-full max-w-4xl mx-auto p-4">
      <ReactQuill
        theme="snow"
        value={content}
        onChange={setContent}
        placeholder="내용을 입력하세요..."
      />

      <div className="mt-4 flex gap-4 text-sm text-gray-600">
        <span>단어: {stats.words}</span>
        <span>글자 수: {stats.characters}</span>
        <span>공백 제외: {stats.charactersNoSpaces}</span>
      </div>
    </div>
  );
}

9. 성능 최적화

9.1 메모이제이션

'use client';

import { useState, useMemo, useCallback } 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 OptimizedEditor() {
  const [content, setContent] = useState('');

  // 모듈 설정을 메모이제이션
  const modules = useMemo(() => ({
    toolbar: [
      [{ header: [1, 2, 3, false] }],
      ['bold', 'italic', 'underline'],
      [{ list: 'ordered' }, { list: 'bullet' }],
      ['link', 'image'],
    ],
  }), []);

  // onChange 핸들러 메모이제이션
  const handleChange = useCallback((value) => {
    setContent(value);
  }, []);

  return (
    <div className="w-full max-w-4xl mx-auto p-4">
      <ReactQuill
        theme="snow"
        value={content}
        onChange={handleChange}
        modules={modules}
        placeholder="내용을 입력하세요..."
      />
    </div>
  );
}

9.2 지연 로딩

'use client';

import { useState } from 'react';
import dynamic from 'next/dynamic';

const ReactQuill = dynamic(
  () => import('react-quill-new'),
  {
    ssr: false,
    loading: () => (
      <div className="w-full h-64 bg-gray-100 rounded-lg animate-pulse flex items-center justify-center">
        <p className="text-gray-500">에디터 로딩 중...</p>
      </div>
    ),
  }
);

export default function LazyLoadedEditor() {
  const [content, setContent] = useState('');

  return (
    <div className="w-full max-w-4xl mx-auto p-4">
      <ReactQuill
        theme="snow"
        value={content}
        onChange={setContent}
        placeholder="내용을 입력하세요..."
      />
    </div>
  );
}

10. 실전 예제: 블로그 포스트 에디터

10.1 완전한 블로그 에디터 컴포넌트

'use client';

import { useState, 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 BlogPostEditor() {
  const [title, setTitle] = useState('');
  const [content, setContent] = useState('');
  const [tags, setTags] = useState([]);
  const [tagInput, setTagInput] = useState('');
  const quillRef = useRef(null);

  const modules = {
    toolbar: [
      [{ header: [1, 2, 3, false] }],
      ['bold', 'italic', 'underline', 'strike'],
      [{ list: 'ordered' }, { list: 'bullet' }],
      [{ color: [] }, { background: [] }],
      ['blockquote', 'code-block'],
      ['link', 'image', 'video'],
      ['clean'],
    ],
  };

  const formats = [
    'header',
    'bold',
    'italic',
    'underline',
    'strike',
    'list',
    'bullet',
    'color',
    'background',
    'blockquote',
    'code-block',
    'link',
    'image',
    'video',
  ];

  const handleImageUpload = () => {
    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 {
        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);
      }
    };
  };

  const handleAddTag = (e) => {
    if (e.key === 'Enter' && tagInput.trim()) {
      e.preventDefault();
      if (!tags.includes(tagInput.trim())) {
        setTags([...tags, tagInput.trim()]);
      }
      setTagInput('');
    }
  };

  const handleRemoveTag = (tagToRemove) => {
    setTags(tags.filter(tag => tag !== tagToRemove));
  };

  const handleSubmit = async () => {
    if (!title.trim() || !content.trim()) {
      alert('제목과 내용을 입력해주세요.');
      return;
    }

    try {
      const response = await fetch('/api/posts', {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json',
        },
        body: JSON.stringify({
          title,
          content,
          tags,
        }),
      });

      if (response.ok) {
        alert('포스트가 저장되었습니다.');
        // 리다이렉트 또는 상태 초기화
      }
    } catch (error) {
      console.error('저장 실패:', error);
      alert('저장에 실패했습니다.');
    }
  };

  return (
    <div className="w-full max-w-4xl mx-auto p-4">
      <h1 className="text-3xl font-bold mb-6">새 포스트 작성</h1>

      {/* 제목 입력 */}
      <div className="mb-6">
        <input
          type="text"
          value={title}
          onChange={(e) => setTitle(e.target.value)}
          placeholder="제목을 입력하세요..."
          className="w-full px-4 py-3 text-2xl font-bold border-b-2 border-gray-300 focus:border-blue-500 focus:outline-none"
        />
      </div>

      {/* 에디터 */}
      <div className="mb-6">
        <ReactQuill
          ref={quillRef}
          theme="snow"
          value={content}
          onChange={setContent}
          modules={{
            ...modules,
            toolbar: {
              ...modules.toolbar,
              handlers: {
                image: handleImageUpload,
              },
            },
          }}
          formats={formats}
          placeholder="내용을 입력하세요..."
          className="bg-white rounded-lg"
        />
      </div>

      {/* 태그 입력 */}
      <div className="mb-6">
        <label className="block text-sm font-medium mb-2">태그</label>
        <input
          type="text"
          value={tagInput}
          onChange={(e) => setTagInput(e.target.value)}
          onKeyPress={handleAddTag}
          placeholder="태그를 입력하고 Enter를 누르세요"
          className="w-full px-4 py-2 border rounded-lg"
        />
        <div className="flex flex-wrap gap-2 mt-2">
          {tags.map((tag, index) => (
            <span
              key={index}
              className="px-3 py-1 bg-blue-100 text-blue-800 rounded-full text-sm flex items-center gap-2"
            >
              {tag}
              <button
                onClick={() => handleRemoveTag(tag)}
                className="hover:text-blue-600"
              >
                ×
              </button>
            </span>
          ))}
        </div>
      </div>

      {/* 저장 버튼 */}
      <div className="flex justify-end gap-4">
        <button
          onClick={() => window.history.back()}
          className="px-6 py-2 border rounded-lg hover:bg-gray-50"
        >
          취소
        </button>
        <button
          onClick={handleSubmit}
          className="px-6 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600"
        >
          저장하기
        </button>
      </div>
    </div>
  );
}

11. 에러 처리와 접근성

11.1 에러 바운더리

'use client';

import { Component } from 'react';
import dynamic from 'next/dynamic';

const ReactQuill = dynamic(() => import('react-quill-new'), { ssr: false });
import 'react-quill-new/dist/quill.snow.css';

class ErrorBoundary extends Component {
  constructor(props) {
    super(props);
    this.state = { hasError: false };
  }

  static getDerivedStateFromError(error) {
    return { hasError: true };
  }

  render() {
    if (this.state.hasError) {
      return (
        <div className="p-4 border border-red-300 rounded-lg bg-red-50">
          <p className="text-red-600">에디터를 불러오는 중 오류가 발생했습니다.</p>
        </div>
      );
    }

    return this.props.children;
  }
}

export default function SafeEditor() {
  return (
    <ErrorBoundary>
      <ReactQuill theme="snow" value="" onChange={() => {}} />
    </ErrorBoundary>
  );
}

11.2 접근성 개선

'use client';

import { useState } 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 AccessibleEditor() {
  const [content, setContent] = useState('');

  return (
    <div className="w-full max-w-4xl mx-auto p-4">
      <label htmlFor="editor" className="block text-sm font-medium mb-2">
        내용 작성
      </label>
      <div id="editor" role="textbox" aria-label="리치 텍스트 에디터">
        <ReactQuill
          theme="snow"
          value={content}
          onChange={setContent}
          placeholder="내용을 입력하세요..."
          aria-label="에디터"
        />
      </div>
      <p className="mt-2 text-sm text-gray-500">
        단축키: Ctrl+B (굵게), Ctrl+I (기울임), Ctrl+U (밑줄)
      </p>
    </div>
  );
}

12. 결론

Next.js에서 React-quill-new를 사용하여 리치 텍스트 에디터를 구현하는 방법을 살펴보았습니다. React-quill-new는 강력하고 유연한 에디터 솔루션으로, 다양한 커스터마이징 옵션과 확장 가능한 구조를 제공합니다.

주요 포인트:

  • 클라이언트 컴포넌트: 에디터는 반드시 'use client'로 명시
  • 동적 임포트: 번들 크기 최적화를 위해 dynamic import 사용
  • 이미지 업로드: 커스텀 핸들러로 이미지 업로드 기능 구현
  • 스타일링: Tailwind CSS와 함께 사용하여 일관된 디자인
  • 성능 최적화: 메모이제이션과 지연 로딩 활용
  • 접근성: 적절한 라벨과 ARIA 속성 사용

React-quill-new는 블로그, CMS, 이메일 작성기 등 다양한 용도로 활용할 수 있으며, 프로젝트의 요구사항에 맞게 커스터마이징하여 사용할 수 있습니다. 올바르게 구현하면 사용자에게 훌륭한 편집 경험을 제공할 수 있습니다.