
dangerouslySetInnerHTML
다크모드 FOUC 문제 해결하기
썸네일
문제 상황
블로그에 다크모드를 만들었다. 다크모드를 만들 때 마다 느꼈지만, 새로고침할 때마다 다크모드가 한타임 늦게 적용돼서 생기는 눈뽕현상이 아주 마음에 들지 않았다.
찾아보니 왜 Flash of Unstyled Content, FOUC 문제라고 불리는 현상이었다. 새로고침하고 나서 다크모드 여부를 해석하고 다크모드 스타일을 적용하기 까지의 딜레이가 있어서 생기는 문제였다.
어두운 밤에 다크모드를 쓰고 있는데 페이지 이동하거나 새로고침할 때마다 배경이 0.5초 흰색으로 변하게 된다면 눈이 아플 수 있다. 이처럼 사용자 경험에 안좋은 영향을 주는 문제이다.
새로고침할때마다 깜빡임
해결하기 위한 방법으로 dangerouslySetInnerHTML를 사용했다. 직역 그대로 위험하게 innerHTML 설정하기인데, 왜 위험한지와 어떻게 해결했는지를 적어보려한다.
Why dangerouslySetInnerHTML?
왜 위험하다고 하는걸까? 왠지 XSS 느낌이 나기도 한다. 리액트에서는 직접 돔요소를 제어하는 일이 적다. 생각해보면 순수 자바스크립트를 쓸 때는 querySelector()를 사용해서 돔 요소를 찾고 $element.innerHTML = 'dfadfasfd' 이런 식으로 자주 썼다. 하지만 이런 걸 리액트에서 하려면 아래처럼 해야한다.
// 아래와 같은 식으로 사용
<script dangerouslySetInnerHTML={} />
<article dangerouslySetInnerHTML={} />
찾아보니 리액트에서 위험하다고 표시하는 이유는 XSS 공격에 취약해서라고 한다.
XSS란 악의적인 스크립트를 포함한 html를 삽입해서 해당 스크립트를 실행시키는 공격이다.
이렇게만 보면 왜 위험하다는건지 잘 와닿지 않는데 아래 예시를 보고 이해해보자.
<div>
<h2>댓글</h2>
{/* 댓글 목록 표시 - XSS 취약점! */}
<div className="comments-list">
{comments.map((comment, index) => (
<div
key={index}
// 위험한 부분: 사용자 입력을 직접 HTML로 렌더링
dangerouslySetInnerHTML={{ __html: comment }}
/>
))}
</div>
<form onSubmit={handleSubmit}>
<textarea
value={newComment}
onChange={(e) => setNewComment(e.target.value)}
placeholder="댓글을 입력하세요"
/>
<button type="submit">댓글 게시</button>
</form>
</div>
이제 이해가 좀 됐다. XSS에 취약하다는 것은 dangerouslySetInnerHTML에 사용할 문자열의 입력 권한을 사용자 혹은 제 3자에게 주는 코드가 위험하다는 의미이다.
만약 위의 코드에서 악의적인 사용자가 정상적인 댓글이 아닌 alert(’댓글’)을 작성하게 되면 해당 댓글이 작성되는 것이 아니라 코드가 실행될 수 있다.
실제로 위 컴포넌트에 댓글로 아래 코드를 입력하게 되면 alert가 실행된다…. ㄷㄷㄷ
<iframe src="javascript:alert('iframe을 통한 XSS')"></iframe>
image.png
전달해야하는 값
dangerouslySetInnerHTML 속성에 전달해야하는 값으로는 객체가 들어간다. 이 객체에 __html이라는 키를 반드시 포함해야한다.
<div dangerouslySetInnerHTML={{__html: 문자열}} />
왜 __html이라는 키를 명시적으로 사용해야하는지를 찾아봤다. 이 객체 형식을 사용하는 이유는 일단 명시적으로 __html이라는 키를 통해 html을 삽입한다는 것을 알리는 역할을 한다. 그리고 객체를 사용한 이유는 문자열은 실수로 의도하지 않게 들어갈 수 있지만 객체는 조금 더 개발자의 의도가 반영되었다고 판단해서라고 한다.
그리고 리액트는 가상 돔을 사용하기 때문에 리액트에서 원하는 흐름으로 렌더링되어야한다. 이 속성은 조금 예외적인 메서드이기 때문에 우회해서 html을 삽입한다. 그래서 __html이라는 키 값을 사용해서 위험성을 개발자에게 인지시킨다.
다크모드 해결하기
필요한 개념은 학습했다. 다크모드의 플래시 현상을 수정해보자. 플래시 현상이 일어나는 이유는 아래와 같다.
- html 문서 렌더링
- 기본 배경 색상 적용되어있음 (라이트모드)
- 클라이언트 코드 다운 및 실행
- useTheme의 코드가 실행되고 로컬스토리지 값에 따른 테마 모드를 적용 (다크모드)
이 과정에서 기본 배경 색상이 라이트모드이기 때문에 플래시 현상이 일어난다.
해결방법
html 최상단에 인라인 스크립트를 작성하는 방식이다. 해당 방식으로 페이지가 렌더링 되는 초기에 배경색을 다크모드에 맞게 변경해줘서 사용자가 페이지를 보기 시작하는 시점 전부터 배경 색을 설정해두는 방식이다.
이 방식을 사용하기 위해서는 최상단 파일 태그 안에
간단하게 설명하면 localStorage에 저장된 theme-storage 테마 정보를 사용해서 isDark를 계산하고, 이에 따라 문서 레벨에 dark 클래스를 추가한다. 그리고 바로 적용하기 위해서 style.backgroundColor를 조절해주는 방식으로 해결했다.
const darkmodeFOUCScript = `
(function() {
const savedTheme = JSON.parse(localStorage.getItem('theme-storage')).state.theme;
const isDark = savedTheme === 'dark';
if (isDark) {
document.documentElement.classList.add('dark');
} else {
document.documentElement.classList.remove('dark');
}
document.documentElement.style.backgroundColor = isDark ? '#1e201e' : '#ffffff';
})();
`;
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="ko">
<head>
<script
dangerouslySetInnerHTML={{
__html: darkmodeFOUCScript
}}
/>
</head>
<body
className={`${pretendard.variable} font-sans antialiased min-h-screen flex flex-col justify-between`}
>
<NavBar />
<main className="flex-grow pb-20">{children}</main>
<Footer />
<ToastProvider />
<GoogleAnalytics gaId={'G-8HJPFDHXEC'} />
</body>
</html>
);
}
이 외의 방법
이 방법 외에도 next-themes 같은 라이브러리를 사용해서 테마 모드를 구현하면 자동으로 필요한 스크립트를 html 문서에 inject해주기 때문에 플래시 문제가 발생하지 않는다고 한다
The Flash ThemeProvider automatically injects a script into
next/headto update thehtmlelement with the correct attributes before the rest of your page loads. This means the page will not flash under any circumstances, including forced themes, system theme, multiple themes, and incognito. Nonoflash.jsrequired.
그러나 나는 간단한 테마 모드를 적용하기 위해 새로운 의존성 패키지를 설치하는 것은 좋지 않다고 판단해서 직접 구현했다.
