에디터 관련 에러
Nextjs와 react-quill 에디터를 함께 사용할 때 많은 문제가 발생한다. reqct-quill이지 next-quill은 아니기 때문에, 패키지에 아직 해결되지 않은 호환성 이슈가 많은 듯하다.
에디터와 관련해선 4가지 메인 이슈가 있었다. 검색하면 충분히 해결법도 많이 나오는 내용들이지만, 내가 겪은 것과 동일한 문제를 설명해주는 글은 없을 때도 있어서 stackoverflow, github issue를 뒤져가며 알음알음 해결해갔다. 혹여나 이 글을 읽으시는 분들에게 도움이 됐으면 싶다.
- Quill-Editor + Dynamic Import + useRef 함께 사용하기
- 커스텀 이미지 업로드
- auto focus / Memo
- Read only 사용하지 않기 / dangerouslyHTML sanitize (최적화)
1 Next.js + Dynamic Import 사용 시 ref에 접근하기
서버사이드 렌더링을 사용하여 quill 에디터에 커스텀 이미지 업로드 핸들러를 달 때 가장 큰 난관이 아닐까 싶다. 바로 동적 임포트로 가져온 에디터 컴포넌트의 ref에 접근할 수 없기 때문이다. useRef와 컴포넌트를 연결하면 타입 에러가 나고, 런타임 에러도 생기면서 앱이 제대로 실행이 되지 않는다. 검색을 해보니, 동적 임포팅 시에만 ref가 적용되지 않는다. 의아하게도 임포트 시 따로 forwardedRef 프랍을 추가해주면 ref에 접근이 가능하다.
출처 : https://github.com/zenoamaro/react-quill/issues/642
const QuillWrapper = dynamic(
async () => {
const { default: RQ } = await import("react-quill");
// @ts-ignore
return ({ forwardedRef, ...props }) => {
return <RQ ref={forwardedRef} {...props} />;
};
},
{ ssr: false }
);
2 커스텀 이미지 업로드
이미지는 base64로 인코딩하여 db에 저장하는 대신 Cloudinary를 사용하여 그 쪽 db에 저장하고 이미지 url을 반환받는 방식을 사용했다. planetscale db의 용량 문제도 있고, CDN을 사용하여 더 빠르게 이미지를 서빙할 수 있기 때문에 필요했다. 이 블로그는 기본적으로 이미지를 잔뜩 쓰고 있기 때문에 필수적으로 느껴졌다.
여하간, Cloudinary가 제공하는 API와 QuillEditor를 연동해야 하기에 까다롭게 느껴지는 작업이었다. 코드를 보면 에디터 컴포넌트의 ref에 왜 접근해야 하는 지 알 수 있다. 마지막으로 에디터의 모듈에 이미지 핸들러를 추가해주면 끝.
// Custom Image Upload Handler
const handleImage = () => {
const editor = quillRef.current.getEditor();
const input = document.createElement("input");
input.setAttribute("type", "file");
input.setAttribute("accept", "image/*");
input.click();
input.onchange = async () => {
const file = input.files[0];
const formData = new FormData();
if (/^image\//.test(file.type)) {
formData.append("file", file);
formData.append("upload_preset", "teklog");
// ImageUpload = cloudinary API Post 함수
const res = await ImageUpload(formData);
const url = res.url;
editor.insertEmbed(editor?.getSelection(), "image", url);
} else {
alert("이미지만 업로드 할 수 있습니다.");
}
};
};
const modules = React.useMemo(
() => ({
toolbar: {
container: toolbarOptions,
handlers: {
image: handleImage,
},
},
}),
[]
);
** 이 이미지 핸들러는 Cloudinary의 upload preset이 unsigned 모드로 되어있을 때를 기준으로 한다. signed 모드로 되어있다면 추가저긴 인증이 구현되어야 한다. 이 블로그에선 에디터가 있는 페이지는 OAuth로 발급받은 토큰이 있어야 접근할 수 있도록 만들었기에 Signed 모드를 사용하지 않았다.
3 auto focus / useMemo
modules 부분을 보면 useMemo 훅이 적용된 걸 볼 수 있다. memo가 빠지면 에디터에 글을 작성할 때마다 오토 포커싱이 되면서 글 작성이 되지 않는 에러가 발생한다. 이는 텍스트가 입력될 때마다 에디터 컴포넌트가 다시 로드되기 때문이기에 반드시 에디터의 초기 설정인 module에 useMemo를 적용해주어야 한다. 검색으로 비교적 쉽게 찾을 수 있는 해결법이긴 하였다.
출처 : https://github.com/zenoamaro/react-quill/issues/309
const modules = React.useMemo(
() => ({
toolbar: {
container: toolbarOptions,
handlers: {
image: handleImage,
},
},
}),
[]
);
4 Read only 사용하지 않기 / dangerouslySetInnerHTML sanitize (최적화)
에러는 아니지만 성능 최적화를 위해서 반드시 해야하는 작업이다. 기존에는 디테일 페이지의 문자열인 html 태그를 read only로 설정된 에디터의 초기값으로 넣어 간단하게 parse하고 렌더링했는데, dynamic import가 적용된 페이지는 성능 저하가 심하게 나타났다. 검색해보니 dangerouslyHTML을 sanitize 하여 사용하는 것이 여러 면에서 나은 것 같아 바로 개선 작업에 들어갔다.
dangerouslySetInnerHTML을 사용하면 XSS 공격에 노출되기 쉬워진다. *XSS 공격이란 "공격자가 상대방의 브라우저에 스크립트를 사용하여 사용자의 세션을 가로채거나, 웹사이트를 변조하거나, 악의적 콘텐츠를 삽입하는" 것이라고 한다. 이를 방지하기 위해 parse할 데이터를 먼저 sanitize 해주어야 한다. 나는 sanitizing이 적용된 dangerouslySetInnerHTML을 컴포넌트로 만들어 블로그 디테일 페이지에 삽입해주었다.
*출처: https://nordvpn.com/ko/blog/xss-attack/
import DOMPurify from "isomorphic-dompurify";
import { ReactNode } from "react";
const htmlParser = (html: string): ReactNode => {
const clean = DOMPurify.sanitize(html);
return <div dangerouslySetInnerHTML={{ __html: clean }} />;
};
export default htmlParser;
sanitize를 위해서 DOMPurify를 사용하였는데, nextjs를 사용한다면 'isomorphic-dompurify'를 사용해주어야 한다. 백엔드/프론트를 함께 사용하는 nextjs 같은 프레임 워크에선 isomorphic-dompurify를 사용해야 에러가 나지 않는다.