nextjs에서 dynamic import로 quill editor 사용하기

pds·2023년 4월 10일
13

TIL

목록 보기
48/60
post-thumbnail
post-custom-banner

nextjs에서 quill editor를 적용하면서 발생한 문제를 해결한 과정을 기록했다.

문제상황

quill-editor를 사용하는 컴포넌트가 있는 페이지를 nextjs 앱에서 로드하면 다음과 같이 document is not defined 에러가 식별된다.


뭘까?

일단 nextjs를 사용하면서 window의 특정 객체에 접근하고자 할 때 자주 접했던 not defined 에러가 생각났다.

useEffect 등 컴포넌트가 마운트 된 후가 아니면 localStorage같은 브라우저 객체에 접근할 때 반드시 typeof window !== 'undefined로 윈도우 객체가 있을 때를 보장해주어야 했다.

react-quill 이슈를 타고타고 가다보니 quill editordocument 객체를 조작해 동작한다는 것을 알게되었다.


따라서

ssr인 nextjs에서 document객체가 로드 된 후에 에디터를 사용할 수 있게끔 보장해주어야 한다.


해결하기0 - 실패

document가 있을 때 해당 에디터 컴포넌트를 불러오게 해보았다.

import EditorComponent from '@/components/editor';
...

...
{window && window.document && <EditorComponent />}

여기서 EditorComponentReactQuill을 사용하는 커스텀 에디터 컴포넌트다.

하지만 여전히 같은 에러가 발생했다.

생각해보니 nextjs에서 import로 모듈을 불러오는 것도 서버사이드로 하는데 quill 에디터는 애초에 라이브러리 자체가 document객체를 활용하고 조작한다고 하니 안되는 것이 당연했다.


해결하기1 - dynamic import

Dynamic Import?

Next.js supports lazy loading external libraries with import() and React components with next/dynamic. Deferred loading helps improve the initial loading performance by decreasing the amount of JavaScript necessary to render the page. Components or libraries are only imported and included in the JavaScript bundle when they're used.

클라이언트 측에서 모듈을 로드하도록 하여 초기 페이지 로드 성능을 개선할 수 있다고 말하고 있다.

dynamic import를 적용하면 클라이언트에서 동적으로 모듈을 로드하기 때문에 document객체를 활용할 수 있을 것이다.

import dynamic from 'next/dynamic';

const EditorComponent = dynamic(() => import('@/components/editor'), {
  loading: () => <div>...loading</div>,
  ssr: false,
});

...
<EditorComponent />

ssr로 렌더링 되는 위의 UI와 다르게 정말로 클라이언트 사이드에서 불러온다!


해결하기2 - Quill Editor를 dynamic import

위의 방법도 괜찮지만 에디터를 사용할 때 마다 dynamic하게 컴포넌트를 불러와야 하는 것을 신경써야 되고 로딩 컴포넌트 등도 구현해서 사용하게 되면 매번 작성해야되니 유지보수적인 측면에서 안좋을 것 같다.

어차피 quill-editor 자체가 서버사이드에서 불러와 사용할 수 없는 모듈이니 해당 컴포넌트 자체를 동적으로 가져오게 설정하는 것이 좋아보였다.

import dynamic from 'next/dynamic';

import ReactQuill, { ReactQuillProps } from 'react-quill';
import 'react-quill/dist/quill.snow.css';

interface ForwardedQuillComponent extends ReactQuillProps {
  forwardedRef: React.Ref<ReactQuill>;
}

const QuillNoSSRWrapper = dynamic(
  async () => {
    const { default: QuillComponent } = await import('react-quill');
    const Quill = ({ forwardedRef, ...props }: ForwardedQuillComponent) => (
      <QuillComponent ref={forwardedRef} {...props} />
    );
    return Quill;
  },
  { loading: () => <div>...loading</div>, ssr: false },
);

export default QuillNoSSRWrapper;

ref를 사용해 Quill Component 객체에 접근하는 부분들이 있기 때문에 prop으로 받아
QuillComponent (ReactQuill 타입)로 ref를 넘겨주게끔 구현하였고
동적으로 ReactQuill 컴포넌트를 로드하는 wrapper를 구성했다.

import QuillNoSSRWrapper from './QuillNoSSRWrapper';

const EditorComponent = () => {
  const quillInstance = useRef<ReactQuill>(null);
  // ..구현부 생략
  return (
    <QuillNoSSRWrapper
      forwardedRef={quillInstance}
      value={contents}
      onChange={setContents}
      modules={modules}
      theme="snow"
      placeholder="내용을 입력해주세요."
    />
  );
};

export default EditorComponent;

실제 에디터 컴포넌트는 다음과 같이 구성된다.

사용하는 쪽 코드에서 동적 임포트를 신경쓸 필요 없게 되었고,

반드시 클라이언트 쪽에서 불러와야 하는 ReactQuill에 대해서만 동적 임포트가 적용된 상태라 추후 추가적인 동적 임포트를 하던 안하던 선택지가 좀 더 생긴게 아닌가 생각한다.


quill editor의 추가 모듈 등록하기

quill-editor의 유틸성 추가모듈들이 많이 있다. 이미지 압축, 이미지 복붙 등

해당하는 라이브러리들을 사용할 때 에디터에 등록해줘야 하는데 이런 라이브러리들 또한 결국 quill-editor기반으로 document객체를 조작하는 듯 하다.
그냥 등록하면 같은 오류가 난다.

역시나 dynamic import를 적용해줘야 nextjs에서 사용할 수 있다.

quill-image-compress 라이브러리를 예시로 적용해보았다.

const QuillNoSSRWrapper = dynamic(
  async () => {
    const { default: QuillComponent } = await import('react-quill');
    // 해당 라이브러리 dynamic import
    const { default: ImageCompress } = await import('quill-image-compress');
    // Quill에 모듈 등록
    QuillComponent.Quill.register('modules/imageCompress', ImageCompress);
    const Quill = ({ forwardedRef, ...props }: ForwaredQuillComponent) => (
      <QuillComponent ref={forwardedRef} {...props} />
    );
    return Quill;
  },
  { loading: () => <div>...loading</div>, ssr: false },
);
  const modules = useMemo(
    () => ({
      toolbar: {
		// 생략...
      },
      imageCompress: {
        quality: 0.7,
        maxWidth: 222, 
        maxHeight: 222, 
        debug: true, // default
        suppressErrorLogging: false, 
        insertIntoEditor: undefined,
      },
    }),
    [],
  );


작성 말고 읽을때는?

읽을 때도 quill로 불러와야할텐데 실제 컨텐츠(데이터)는 ssr로 api를 패치해 불러온다.

물론 이런식으로 그냥 dangerouslySetInnerHTML 속성을 사용해 HTML 스타일을 반영해서 넣어도 되지만 뭔가 쎄?하다. 린트에서 경고를 보여준다는 건 뭔가 좋지 않다는 것일 것입니다.

게다가 작성할 때 quill의 class로 스타일링이 되어있어 정상적으로 스타일이 적용된 채로 보여주려면 적절한 classcss파일을 또 불러오거나 커스텀해서 컴포넌트로 만들어야 한다.

이는 매우 귀찮을 수 있고 라이브러리 의도대로 스타일이 잘 나오지 않을 확률이 있기 때문에 읽을 때도 dynamic import를 적용했다.

동적으로 클라이언트 사이드에서 가져와 보여주면서도 로딩 중임을 보여주고 싶지 않았다.

이미 서버 사이드에서 데이터를 모두 가져오는데 검색엔진에 적절한 태그 위치에 노출되지 않는 것도 싫었고 로드 시 로딩중 UI를 보여주다가 긴 컨텐츠들을 팍! 하고 보여주고 싶지 않았다.


export const QuillNoSSRReader = ({ content }: { content: string }) => {
  const Result = dynamic(
    async () => {
      const { default: QuillComponent } = await import('react-quill');
      return () => <QuillComponent theme="bubble" readOnly value={content} />;
    },
    {
      loading: () => (
        <div className="quill">
          <div className="ql-container ql-bubble ql-disabled">
            <div className="ql-editor" data-gramm="false" dangerouslySetInnerHTML={{ __html: content }} />
          </div>
        </div>
      ),
      ssr: false,
    },
  );
  return Result;
};

앞서 만들었던 QuillNoSSRWrapperWriter로 이름을 변경하고 Reader를 따로 만들어주었다.

로딩중일 때만 최대한 같은 스타일로 dangerouslySetInnerHTML를 통해 컨텐츠를 보여주다가
동적 임포트가 되었을 경우 ReactQuill을 통해 보여주게 했다.

아무래도 XSS에 대한 방어도 그렇고 보여지는 것도 그렇고 ReactQuill에서 의도한대로 사용하는 것이 훨씬 나을 것이다.

일부러 loading 함수에 로딩중임 텍스트를 추가했는데 빼면 로딩중일 때도 에디터를 통해 불러왔을 때와 아예 똑같은 UI가 나타난다!

References

profile
강해지고 싶은 주니어 프론트엔드 개발자
post-custom-banner

1개의 댓글

comment-user-thumbnail
2023년 6월 1일

해결에 도움이 되었습니다. 감사합니다!!

답글 달기