
사용하고 있는 패키지 매니저로 설치하면 된다.
yarn add react-quill 또는 npm i react-quill
import React, {
ReactChild,
ReactFragment,
RefObject,
useMemo,
useState,
} from 'react';
import ReactQuill, { Quill } from 'react-quill';
import 'react-quill/dist/quill.snow.css';
const formats = [
'font',
'header',
'bold',
'italic',
'underline',
'strike',
'blockquote',
'list',
'bullet',
'indent',
'link',
'align',
'color',
'background',
'size',
'h1',
];
export default function QuillEditor = () => {
const [values, setValues] = useState();
const modules = useMemo(() => {
return {
toolbar: {
container: [
[{ size: ['small', false, 'large', 'huge'] }],
[{ align: [] }],
['bold', 'italic', 'underline', 'strike'],
[{ list: 'ordered' }, { list: 'bullet' }],
[
{
color: [],
},
{ background: [] },
],
],
},
};
}, []);
return(
<ReactQuill
theme="snow"
modules={modules}
formats={formats}
onChange={setValues}
/>
)
}

toolbar를 id로 선언해준다. export const CustomToolbar = () => (
<div id="toolbar">
<span className="ql-formats">
<select className="ql-size" defaultValue="medium">
<option value="small">Small</option>
<option value="medium">Medium</option>
<option value="large">Large</option>
<option value="huge">Huge</option>
</select>
<select className="ql-header">
<option value="1">Header 1</option>
<option value="2">Header 2</option>
<option value="3">Header 3</option>
<option value="4">Header 4</option>
<option value="5">Header 5</option>
<option value="6">Header 6</option>
</select>
</span>
<span className="ql-formats">
<button className="ql-bold" />
<button className="ql-italic" />
<button className="ql-underline" />
<button className="ql-strike" />
<button className="ql-blockquote" />
</span>
<span className="ql-formats">
<select className="ql-color" />
<select className="ql-background" />
</span>
<span className="ql-formats">
<button className="ql-image" />
<button className="ql-video" />
</span>
<span className="ql-formats">
<button className="ql-clean" />
</span>
</div>
);
커스텀한 toolbar를 사용하기 위해 React Quill 컴포넌트에서 선언해준 modules의 toolbar 객체의 container에 부여한 id값을 지정한다.
const modules = useMemo(() => {
return {
toolbar: {
container: "#toolbar",
},
};
}, []);
에디터에 글자를 입력하고, 각각 다르게 글자 색깔을 색상했다. 추가적으로 반갑습니다~부분에는 strong 효과를 주었다.

스타일이 지정된 html 태그 값이 정상적으로 찍히는걸 확인할 수 있다.
<div
dangerouslySetInnerHTML={{
__html: DOMPurify.sanitize(values),
}}
style={{
marginTop: '30px',
overflow: 'hidden',
whiteSpace: 'pre-wrap',
}}
/>
정상적으로 잘 출력되었다!
여기에서
dangerouslySetInnerHTML은 브라우저 DOM에서 innerHTML을 사용하기 위한 React의 대체 방법이다. 일반적으로 코드에서 HTML을 설정하는 것은 사이트 간 스크립팅 공격에 쉽게 노출될 수 있기 때문에 위험하다. 따라서 React에서 직접 HTML을 설정할 수는 있지만, 위험하다는 것을 상기시키기 위dangerouslySetInnerHTML을 작성하고 __html 키로 객체를 전달해야 한다.
dangerouslySetInnerHTML을 이용할때 script가 포함되어 있으면 스크립팅 공격에 취약해지는데, 이러한 위험을 막기 위해 DOMPurify를 사용해줬다.


이미지를 업로드 하고 콘솔에 출력해보면 엄청나게 긴 문자열이 찍히는걸 확인할 수 있다. 이렇게 base64 형태로 백엔드 서버에 저장하면 서버에 엄청난 부하가 걸릴 것이다. 만약 업로드해야할 사진이 100장이라면....?
그래서 다른 방법으로 이미지를 처리해야 한다.
useUploadFile을 react query로 만들었고 handleImageUpload 함수를 통해 file을 인자로 받아서 useUploadFile에 전달해줘서 업로드 하는 방식으로 구현했다. // 파일 업로드를 위한 커스텀 훅
const uploadFileMutation = useUploadFile();
async function handleImageUpload(file: File) {
if (!file) {
// 파일 선택이 취소된 경우 사용자에게 알려주기
alert('파일이 선택되지 않았습니다.');
return;
}
if (quillRef.current) {
try {
const result = await uploadFileMutation.mutateAsync(file);
const editor = quillRef.current.getEditor();
const range = editor.getSelection(true);
// range가 있는지를 검사한다.
// 만약 range가 null 이거나 undefined인경우 당연히 삽입할 대상의 위치가
// 없는것이므로 이미지가 삽입되지 않는다...!
if (range) {
editor.insertEmbed(range.index, 'image', result.displayUrl);
} else {
alert('에디터에 포커스를 맞추고 다시 시도해주세요.');
}
} catch (error) {
alert('이미지 업로드 중 오류가 발생했습니다. 다시 시도해주세요.');
console.error('Error:', error);
}
}
}
마지막으로 아까 만든 react quill 컴포넌트에 선언한 modules에 handlers 옵션을 추가해주면 된다.
가독성과 유지보수를 위해 handlers의 image에 바인딩된 함수는 커스텀훅으로 분리할 수도 있다.
const modules = useMemo(() => {
return {
toolbar: {
container: [
[{ size: ['small', false, 'large', 'huge'] }],
[{ align: [] }],
['bold', 'italic', 'underline', 'strike'],
[{ list: 'ordered' }, { list: 'bullet' }],
[
{
color: [],
},
{ background: [] },
],
['image'],
],
// ✅ 추가된 handlers 옵션
handlers: {
image: () => {
const input = document.createElement('input');
input.setAttribute('type', 'file');
input.setAttribute('accept', 'image/*');
input.addEventListener('change', () => {
// 파일을 하나씩 업로드
const file = input.files && input.files[0];
handleImageUpload(file);
});
input.click();
},
},
},
};
}, []);
이전에는 개별 파일의 0번째 인덱스에 접근하여 파일을 하나씩 업로드했던 반면, 다중 이미지를 업로드 하기 위해서는 multiple 속성을 생성한 input 요소에 attribute 시켜준다.
const multiImageHandler = () => {
const input = document.createElement('input');
input.setAttribute('type', 'file');
input.setAttribute('accept', 'image/*');
input.setAttribute('multiple', '');
input.addEventListener('change', () => {
if (input.files) {
handleMultipleImagesUpload(input.files);
}
});
input.click();
};
이번에는 multiImageHandler 함수를 생성하고, 툴바의 handers의 image에 함수를 바인딩해주자.
...
handlers: {
image: multiImageHandler,
},
...
multiImageHandler의 handleMultipleImagesUpload는 감지된 이미지 정보들을 파라미터로 받아서 이를 각각 순회하고 해당 값을 처리하는 로직인데, 나의 경우에는 전역상태관리 라이브러리인 jotai를 이용해서 이미지 자체를 아톰에 세팅했다.
// 아톰 생성해주기
export const multipleAtom = atom<NewFileDto[]>([]);
// setter로만 사용할것이기 때문에 useSetAtom 훅으로 아톰 선언하기
const setMultiImages = useSetAtom(multipleAtom);
const handleMultipleImagesUpload = useCallback(
async (files: FileList) => {
for (const file of files) {
try {
const res = await uploadFileMutation.mutateAsync(file);
// 원하시는 로직을 작성하면 될거같다.
setUploadImageStatus(res.displayUrl);
setMultiImages(prev => [...prev, res]);
} catch (error) {
console.error(error);
}
}
},
[uploadFileMutation, setMultiImages],
);