2024-07-04 · 7 min read · 개발
Next.js에서의 styled-components 탐구
해당 글의 버전은 Next.js v14(Page Router), styled-components v6으로 진행됩니다.
프로젝트에서 Next.js와 styled-omponents를 사용하고 있다.
근데 Next.js를 사용할 때 CSS-in-JS을 쓰면 문제가 많다는 것을 듣기만 했지 자세히 살펴보지는 않았었다.
이번 기회에 한 번 알아보고자 한다. 🧐
문제 접근하기
기본 환경 세팅은 npx create-next-app@latest으로 진행했다.
예제 코드는 다음과 같다.
pages/index.tsx
import styled from 'styled-components'
export default function Home() { return ( <> <Container> <Title>테스트를 진행합니다.</Title> <Button>Click Me!</Button> </Container> </> )}
const Container = styled.div` display: flex; flex-direction: column; align-items: center; justify-content: center; height: 100vh; background-color: #e0ffe0;`
const Title = styled.h1` font-size: 2.5em; color: #008000;`
const Button = styled.button` background-color: #008000; color: white; border: none; padding: 10px 20px; font-size: 1em; cursor: pointer; border-radius: 5px; margin-top: 20px;`우선 next.config.js에서 styledComponents: true로 설정한다.
/** @type {import('next').NextConfig} */const nextConfig = { reactStrictMode: true, compiler: { styledComponents: true },}
export default nextConfig이거만 해도 화면에서는 제대로 스타일이 적용되는 것을 볼 수 있다.

하지만 현재 상황은 클라이언트에서만 스타일을 받아오는 상황이다.
styled-components는 CSS-in-JS 방식으로 JavaScript를 통해 스타일을 구성된다. (일반 CSS로 직렬화하는 과정이 필요하다.)
예제 코드에서 사용된 #00800을 네트워크 탭에서 검색하면 index.js 번들에 포함된 것을 확인할 수 있다.
index.js 번들에 스타일이 포함된 모습

따라서 지금과 같은 상황에서 styled-components는 클라이언트에서만 스타일을 받아오게 된다.
하지만 Next.js와 같이 미리 서버에서 렌더링이 일어나는 경우 깜빡이는 문제와 스타일이 적용되지 않은 문서를 볼 수 있다.
깜빡이는 문제
스타일이 적용되지 않은 문서

문제 해결하기
감사하게도 관련 예제를 제공해주고 있다.
pages/_documents.tsx
import Document, { DocumentContext, DocumentInitialProps } from 'next/document'import { ServerStyleSheet } from 'styled-components'
export default class MyDocument extends Document { static async getInitialProps(ctx: DocumentContext): Promise<DocumentInitialProps> { const sheet = new ServerStyleSheet() const originalRenderPage = ctx.renderPage
try { ctx.renderPage = () => originalRenderPage({ enhanceApp: (App) => (props) => sheet.collectStyles(<App {...props} />), })
const initialProps = await Document.getInitialProps(ctx) return { ...initialProps, styles: [initialProps.styles, sheet.getStyleElement()], } } finally { sheet.seal() } }}세부적인 내용은 나중에 살펴보고 결과부터 보면 기대하는 바와 일치한다.
더 이상 깜빡거리지 않는다.
처음 문서에도 스타일이 적용된 상태로 오는 것을 확인할 수 있다.

styled-components는 어떻게 해결했을까?
_documents.tsx 코드를 다시 살펴보자.
크게 3가지 부분으로 나눌 수 있을 것 같다.
우선 서버 시트를 생성한다.
const sheet = new ServerStyleSheet()해당 로직에서는 sheet.collectStyles을 통해 컴포넌트에서 작성했던 스타일들을 수집한다.
// Next.js에서 제공하는 기본 페이지 렌더링 메서드ctx.renderPage = () => // Next.js 기본 렌더링 로직을 참조하는 변수 originalRenderPage({ // Next.js 전체 react tree(App 컴포넌트)를 감싸기 위한 고차 컴포넌트(HOC) enhanceApp: (App) => (props) => sheet.collectStyles(<App {...props} />), // 스타일 수집 })html에 스타일 태그를 추가한다.
여기서 sheet.getStyleElement을 통해 수집했던 스타일을 React Element로 반환하게 해준다.
return { ...initialProps, styles: [initialProps.styles, sheet.getStyleElement()], // 수집된 스타일을 React Element로 반환}추가된 스타일 태그

styled-components 코드 살펴보기
동작을 좀 더 이해해보자.
시작점인 ServerStyleSheet을 통해 생성된 인스턴스에서 앞서 살펴본 2가지 동작이 발생한다.
collectStyles: 스타일 수집getStyleElement:수집된 스타일을ReactElement로 반환
collectStyles의 코드는 다음과 같다.
collectStyles
collectStyles(children: any): JSX.Element { if (this.sealed) { throw styledError(2); }
return <StyleSheetManager sheet={this.instance}>{children}</StyleSheetManager>;}StyleSheetManager가 등장한다.
StyleSheetManager
export function StyleSheetManager(props: IStyleSheetManager): JSX.Element { const [plugins, setPlugins] = useState(props.stylisPlugins); const { styleSheet } = useStyleSheetContext();
const resolvedStyleSheet = 스타일 시트를 설정하는 함수
const stylis = 스타일 코드로 만들어주는 함수
...
const styleSheetContextValue = useMemo( () => ({ shouldForwardProp: props.shouldForwardProp, styleSheet: resolvedStyleSheet, stylis, }), [props.shouldForwardProp, resolvedStyleSheet, stylis] );
return ( <StyleSheetContext.Provider value={styleSheetContextValue}> <StylisContext.Provider value={stylis}>{props.children}</StylisContext.Provider> </StyleSheetContext.Provider> );}해당 컨텍스트를 통해 스타일을 수집 및 변환해서 StyleSheetManager에서 주입한 sheet에 반영될 것으로 예상된다.
다음으로 getStyleElement 코드이다.
getStyleElement
getStyleElement = () => { if (this.sealed) { throw styledError(2) }
const props = { [SC_ATTR]: '', [SC_ATTR_VERSION]: SC_VERSION, dangerouslySetInnerHTML: { __html: this.instance.toString(), }, }
const nonce = getNonce() if (nonce) { ;(props as any).nonce = nonce }
// v4 returned an array for this fn, so we'll do the same for v5 for backward compat return [<style {...props} key="sc-0-0" />]}dangerouslySetInnerHTML가 눈에 띈다.
this.instance.toString()을 통해 앞서 시트에 수집한 스타일들을 문자열로 반환한다.
그 다음 dangerouslySetInnerHTML을 통해 DOM에 주입하는데 style 태그에 반환하는 것을 알 수 있다.
마무리
이전까지는 서버 렌더링이 이루어질 때 CSS-in-JS가 문제가 많다고 듣기만 했지 깊게 파고들지는 못했다.
이번 기회에 직접 코드를 구성해보면서 어떠한 경우에 문제가 발생하고 어떻게 해결하는지 알게 되었다.
그리고 직접 styled-components 코드도 살펴보면서 흥미롭게 학습한 시간이 되었던 것 같다.