Next.js + styled-components에서 Prop `className` did not match
가 발생하는 이유와 해결 방법
작년 가을에 solved.ac 프론트엔드를 Typescript로 리팩터하면서 당황스러운 경험을 했습니다. 사이트 내의 아무 링크나 클릭해서 다른 페이지로 가면 스타일시트가 전부 깨지는 것이었습니다.
콘솔을 열어 보니 다음과 같은 에러가 뜹니다.
Warning: Prop `className` did not match. Server: "sc-xxxxxx xxxxx" Client: "sc-yyyyyy yyyyy"
오류에서 알 수 있듯이 서버와 클라이언트에서 클래스네임이 일치하지 않아서 발생한 오류임을 알 수 있습니다. Next.js는 SSRServer-side Render을 도와주는 프레임워크입니다. SEO검색 엔진 최적화 등을 위해 처음 페이지를 로드할 때는 서버에서 렌더해 오지만, 페이지에서 링크를 클릭해 다른 페이지로 넘어갈 때는 CSR로 페이지를 렌더합니다. SEO와 속도 두 가지를 해결해 주는 프레임워크죠.
그럼 서버와 클라이언트는 기본적으로 같은 로직을 공유할 텐데 왜 이런 일이 일어나는 걸까요?
babel-plugin-styled-components가 없어서
첫 번째 이유는 babel-plugin-styled-components가 없어서입니다. 이 Babel 플러그인은 환경과 상관없이 일관된 className을 생성(consistently hashed component classNames between environments)해 줍니다. 헐 그럼 styled-components는 이 플러그인 없으면 기본적으로 className 생성을 ‘환경’에 의존한다는 뜻인가요 네 그렇습니다.
styled-components는 styled
함수로 만든 컴포넌트마다 generateId
함수를 이용해 유일한 식별자를 생성하는데요, 함수에서 확인할 수 있다시피 전역 카운터를 하나 두고 컴포넌트 하나를 처리할 때마다 증가시켜 가면서 생성됩니다.
위의 방법으로 식별자를 생성하면 어떤 일이 일어날 수 있을까요? 컴포넌트가 생성되는 순서에 따라 같은 컴포넌트이더라도 다른 식별자가 붙을 수 있게 됩니다! CSR에서는 상관없겠지만 SSR과 CSR을 같이 활용하는 경우 서버와 클라이언트가 컴포넌트를 생성하는 순서에 따라 식별자가 달라질 수 있습니다. babel-plugin-styled-components는 이런 식별자 생성 과정을 정규화해 줍니다.
다음과 같이 해결할 수 있습니다.
1. 플러그인 설치:
npm i --save-dev babel-plugin-styled-components
2. 프로젝트 루트의 .babelrc
편집(없을 경우 생성):
{ "plugins": ["babel-plugin-styled-components"] }
3. Next.js를 사용 중인 경우 이곳의 _document.js를 복붙해 오세요.
그래도 안 돼요
이 포스트의 핵심입니다.
solved.ac의 경우에는 저걸 다 했는데도 안 되길래 몇 달 내내 이거 고치려고 삽질을 했습니다. Typescript로 리팩터링하기 전엔 없던 문제였는데 Typescript를 들고 오면서 생긴 오류라, 뭐가 문제인지 찾아보다가 커스텀 테마 타입 정의 때문이라는 걸 알았습니다.
solved.ac 프론트엔드 코드에는 SolvedTheme
라는 타입이 있고, 여기에 테마 정의를 넣습니다.
interface SolvedTheme { defaultFonts: string codeFonts: string background: string textColor: string textColorInverted: string textSecondaryColor: string border: string borderColor: string tableHeaderBackground: string tableBackground: string footerBackground: string primaryColor: string }
기존의 경우 styled-components로 만든 컴포넌트에서는 이를 아래와 같은 식으로 활용해 왔습니다.
const TopBarContainer = styled.div` position: fixed; width: 100%; height: 48px; line-height: 48px; background: ${({ theme }) => theme.background}; border-bottom: ${({ theme }) => theme.border}; top: 0; left: 0; z-index: 10000; `
Javascript에서는 문제가 없는 코드이지만 Typescript에서는 컴파일 에러가 납니다. theme
의 타입이 위에서 정의한 SolvedTheme
가 아닌 styled-components에 내장된 DefaultTheme
이기 때문입니다.
우선 이 문제를 해결하기 위해 열심히 구글링을 했고, styled-components를 아래와 같이 제 타입 정의로 감싼 뒤 이렇게 만들어진 새로운 styled를 여기저기 import 해 와서 사용했습니다. import styled from '../styles/Themes'
같은 식으로요.
import * as styledComponents from 'styled-components' import { ThemedStyledComponentsModule } from 'styled-components' const { default: styled, css, createGlobalStyle, ThemeProvider, ThemeConsumer, keyframes, } = styledComponents as ThemedStyledComponentsModule<SolvedTheme> export { css, createGlobalStyle, keyframes, ThemeProvider, ThemeConsumer } export default styled
그런데 이렇게 정의해 버리면 babel-plugin-styled-components가 의미가 없어집니다. babel-plugin-styled-components는 import 경로가 ‘styled-components’인 경우에만 작동하기 때문인데요.
옵션에서 바꿀 수 있어 보이지만 깔끔한 해결책은 아닌 거 같고, 대신 styled.d.ts
를 새로 만들고 여기에서 DefaultTheme
를 SolvedTheme
으로 두면 됩니다. 그러면 위의 문제를 해결하면서 커스텀 테마의 타입 정의도 사용할 수 있습니다.
import 'styled-components' import { SolvedTheme } from './Themes' declare module 'styled-components' { export interface DefaultTheme extends SolvedTheme {} }
(21/12/23 수정: 코드가 declaration merging을 사용하도록 수정했습니다. type DefaultTheme = SolvedTheme
로 하는 경우에는 VS Code 에디터 자동완성 등의 기능을 제대로 활용하지 못했습니다.)
일반적인 원인은 아닐지 모르겠지만, 제가 영문도 모르고 몇 달간 고생한 걸 다른 분들이 겪지 않았으면 하는 마음에서 제 사례를 소개했습니다.
참고한 리소스
- Investigate better ways to create component-specific classes https://github.com/styled-components/styled-components/issues/2069
- Serverside rendering with Typescript for img tags has mismatching classNames with client https://github.com/styled-components/styled-components/issues/2064#issuecomment-427571787
- What is the point of the styledComponentId? https://spectrum.chat/styled-components/general/what-is-the-point-of-the-styledcomponentid~edd37201-7e63-43a8-981a-545ca344aed7
덕분에 삽질을 ㅠㅜㅠ 안했네요 감사합니다! 도움 많이 되었어요