안녕하세요. 이번에는 dangerouslySetInnerHTML를 nextjs에서 사용하는 것에 대해 글을 작성해보려고 합니다.
nextjs project를 진행하는 과정에서 "<p태그>Hello world</p태그>"를 db에서 불러와 화면으로 보이는 작업을 진행하였습니다.
하지만 이를 db에서 가져올 때, String형식으로 태그 그 자체까지 보여준 다는 것을 알게 되었습니다.
이를 tag를 적용한 text로 보여주기 위해 Nextjs 단위에서 어떤식으로 하면 좋을지에 해결 방안을 모색하였고 공유하면 좋을 것 같아 글로 작성해봅니다.
db에서 가져온 "<p태그>Hello world</p태그>"를 실제 화면에 tag를 적용한 상태로 보여주기 위해서 사용하는 것이 dangerouslySetInnerHTML입니다.
const cleanContent = "<p>Hello World!</p>";
<span
dangerouslySetInnerHTML={{
__html: cleanContent
}}
/>
하지만 이를 그대로 사용하는 것은 보안상의 이슈를 범할 수 있으므로 그대로 사용하는 것은 지양하는 것이 바람직합니다.
이러한 보안상의 이슈를 해결하기 위한 방법이 DOMPurify입니다.
참고 : DOMPurify Readme 내용
그럼 DOMPurify를 어떤식으로 적용하면 좋을지에 대해서 알아봐야겠습니다.
여기서 먼저 말씀드려야할 것이 있습니다.
Nextjs는 현재 14v을 기준으로 server component와 client component로 나뉘어 있습니다.
이에 따라 각각 적용해야 하는 방법이 다름을 먼저 알고 가시면 좋을 것 같습니다.
import DOMPurify from 'dompurify';
import { JSDOM } from 'jsdom';
const sanitizeHtml = (html: string) => {
return DOMPurify(new JSDOM('<!DOCTYPE html>').window).sanitize(html);
};
export default sanitizeHtml;
먼저 lib으로 다음과 같이 만들어줬습니다.
여기서는 DOMPurify 뿐만 아니라 JSDOM을 이용해줬는데요,
그 이유는 현재 DOMPurify는 server쪽에서 실행되고 있습니다. 이에 따라 JSDOM을 이용해 서버쪽에서 HTML 문서를 파싱하고 DOM 요소를 조작할 수 있기 위해 위와 같이 작성하였습니다.
import Image from "next/image";
import Link from "next/link";
import sanitizeHtml from "@/lib/sanitizeHtml";
interface LatestPostProps {
className? : string;
imageHeight : string;
imgUrl : string;
title : string;
content : string;
postId : number;
createdAt : Date;
isSmallCard? : boolean;
isLongCard? : boolean;
}
const LatestPost = ({
className,
imageHeight,
title,
content,
createdAt,
postId,
imgUrl,
isSmallCard = false,
isLongCard = false,
} : LatestPostProps) => {
const cleanContent = sanitizeHtml(content);
return (
<div className={className}>
<Link className="basis-full hover:opacity-70" href={`/posts/${postId}`}>
<div className={`relative w-auto mb-3 ${imageHeight}`}>
<Image
src={imgUrl}
alt="LatestPost"
fill={true}
/>
</div>
</Link>
<div className="basis-full">
<Link href={`/posts/${postId}`}>
<h4
className={`font-bold hover:text-accent-green
${isSmallCard ? "text-base" : "text-lg"}
${isSmallCard ? "line-clamp-2" : ""}
`}
>
{title}
</h4>
</Link>
<div className={`${isSmallCard ? "my-2" : "flex my-3"} gap-3`}>
<h6 className="text-gray-300 text-xs">
{createdAt.toLocaleDateString()}
</h6>
</div>
<div className={`text-gray-500 ${isLongCard ? "line-clamp-5" : "line-clamp-3"}`}>
<span
dangerouslySetInnerHTML={{
__html: cleanContent
}}
/>
</div>
</div>
</div>
)
}
export default LatestPost;
그리고 다음과 같이 server component에서 sanitizeHtml를 import해서 적용시켜 주었습니다.
참고 : https://velog.io/@brgndy/Next.js-DOMPurify%EB%A1%9C-XSS-%EA%B3%B5%EA%B2%A9-%EB%A7%89%EA%B8%B0
client component 단위에서는 위에 해결 방법을 그대로 적용하면 error가 발생합니다.
omg.. 제 생각에 이건 client 단위에서 jsdom을 실행하는데 있어서 적절치 못하게 접근하고 있기 때문에 그런것 같습니다.(이것저것 해본 저의 생각입니다.) 그래서 현재 client component에서 jsdom을 제외하고
const cleanContent = DOMPurify.sanitize(content);
<span
dangerouslySetInnerHTML={{
__html: cleanContent
}}
/>
이처럼 나타내보면
omg2... 이와 같은 error가 나옵니다.
이는 JSDOM을 쓰지 않았을 때 나오는 error인데요. 웃긴게 처음 보여드렸던 error는 jsdom때문에 나오는 것 같은 error, 그리고 현재 client component 단위이기 때문에 jsdom을 제거했더니 이와 같은 error가 나옵니다. ㅋㅋㅋㅋㅋ!
이 error는 server에서 DOMPurify가 실행되기 때문에 뜨는 에러인데요,
"client component도 어찌됐건 server-side rendering이 이루어지기 때문에 그 과정에서 JSDOM이 필요하다."
라고 보시면 되겠습니다.
그럼 client component에서 이 문제에 대한 해결 방법은 무엇일까요?
DOMPurify를 server 단위에서 실행하지 않는 것입니다.
Nextjs에서 제공하는 noSSR rendering을 이용하는 것이죠.
참고 : With no SSR(공식문서)
import DOMPurify from "dompurify";
interface NoSSRContentProps {
content : string;
}
const SsrContent = ({content}:NoSSRContentProps) => {
const cleanContent = DOMPurify.sanitize(content);
return (
<span
dangerouslySetInnerHTML={{
__html: cleanContent
}}
/>
)
}
export default SsrContent;
이건 client component에서 사용될 dangerouslySetInnerHTML 이므로 먼저 jsdom을 제외하고 DOMPurify.sanitize를 사용하여 content를 필터링 해주었습니다. 그 후 이것을 사용하고자 하는 component로 넘어갑니다.
"use client";
import { DeletePosts } from "@/app/actions/deletePosts";
import sanitizeHtml from "@/lib/sanitizeHtml";
import { userStore } from "@/store/user-store";
import { PencilLineIcon, Trash, X } from "lucide-react";
import dynamic from "next/dynamic";
import Image from "next/image";
import { useRouter } from "next/navigation";
interface PostContentProps {
title : string;
subtitle : string;
content : string;
imgUrl : string;
id : number;
createdAt : Date;
}
const NoSSRContent = dynamic(()=>import("../ssrContent"), {
ssr : false
})
const PostContent = ({title,subtitle,content,imgUrl,id,createdAt }:PostContentProps ) => {
const {isAdmin} = userStore();
const router = useRouter();
const isEdit = () => {
if(!isAdmin) {
return alert("권한이 없습니다.");
}
router.push(`/writer/direct?edit=true&title=${title}&postId=${id}`);
}
const isDelete = async (title : string) => {
if(!isAdmin) {
return alert("권한이 없습니다.");
}
await DeletePosts(title).then((res)=>{
if(res?.success) {
alert("게시글 삭제완료.");
router.push("/");
}
else {
alert("게시글 삭제실패.");
}
});
}
return (
<div className="flex flex-col w-full mb-10">
<div className="w-full flex justify-between">
<div/>
<div>
<h2 className="font-bold">{title}</h2>
<h6 className="text-wh-300 text-xs">{createdAt.toLocaleDateString()}</h6>
</div>
<div className="flex gap-x-5">
<div
className="hover:cursor-pointer"
onClick={()=>isEdit()}
>
<PencilLineIcon/>
</div>
<div
className="hover:cursor-pointer"
onClick={()=>isDelete(title)}
>
<Trash />
</div>
</div>
</div>
<div className="relative w-auto mt-2 mb-16 h-96">
<Image
src={imgUrl}
alt="PostContent"
fill={true}
sizes="(max-width : 480px) 100vw,
(max-width : 768px) 85vw,
(max-width : 1060px) 75vw,
60vw"
/>
</div>
<div className="">
<NoSSRContent content={content} />
</div>
</div>
)
}
export default PostContent;
제가 만든 ssrContent를
const NoSSRContent = dynamic(()=>import("../ssrContent"), {
ssr : false
})
<NoSSRContent content={content} />
을 통해 NoSSR로 만들었습니다. 이렇게하면 server 단위에서 rendering이 이루어지지 않기 때문에 jsdom을 필요로하지 않고 그에 따라 jsdom이 필요하다는 에러도 뜨지 않을 것입니다.