Styled Components를 StyleX로 바꿔보기

2024-03-31

예전부터 사이드 프로젝트를 할 때 주로 Next.js + Typescript + styled-components 조합으로 만들다보니, 해당 조합이 자연스럽게 나의 보일러플레이트가 되었다. 그런데 최근 React 서버 컴포넌트가 나오면서 스타일링 라이브러리를 바꿔야 하는 고민이 들기 시작했다.

styled-components는 클라이언트 런타임에서 동작하기 때문에, 서버 컴포넌트에서 사용을 못한다. Next.js 14로 올라오면서 서버 컴포넌트를 default로 사용 중인데, 클라이언트 로직이 아무것도 없음에도 단지 styled-components 때문에 use client를 입력하고 있을 때 약간(이 아니고 많은) 아쉬움이 들었다.

이에 이전에 시도했다가 나랑 잘 맞지 않던 tailwind를 다시 시도해볼까 하다가, 새로운 라이브러리를 사용해보자 싶어서 비슷한 컨셉의 unocss 사용해봤다. 그러나 여전히 나에게는 맞지 않았다.

그러다 최근 사내 FE 그룹 발표에서 한 분이 StyleX를 발표 했었는데, 제로 런타임이라는걸 보고 갑자기 써보고 싶다는 생각이 들었다. 사실 v1.0 이 되지 않은 라이브러리는 사용을 잘 안하는 편인데, 메타가 만들기도 했고 리액트 서버 컴포넌트가 나왔으니 계속 개발을 해줄 것이라는 믿음이 갖고 한 번 과감히 (물론 혼자하는 사이드 프로젝트에서) 사용해보기로 했다.

StyleX로 변경하면서 느낀 점들

해당 글에서는 StyleX의 소개나 특징을 나열하기보다는, Next.js에서 styled-components를 StyleX로 마이그레이션하면서 느낀 점들을 위주로 작성했다.

styled-components만 오랫동안 쓰다 넘어가는 입장에서 '이게 안돼?' 혹은 '이걸 이렇게 해야돼?' 싶은 것들이 좀 있었다.

아직 실제 사용해본 후기가 많지 않다보니 삽질을 좀 많이 했다(공식문서를 꼼꼼히 읽어봤으면 덜 했을 것 같다..). 혹은 해당 글의 해결책보다 더 쉬운 방법이 있을 수 있는데, 못 찾은 걸 수도 있다.


변경하면서 느낀 점들을 크게 몇 가지 섹션으로 나눠봤다.

1. swc 대신 babel을 사용하여 생기는 아쉬움

Next.js가 12으로 올라오면서 기본 컴파일러를 swc로 바꿨는데, StyleX를 사용하기 위해서는 babel를 써야했다. 이로 인해 초기 설정에 생각보다 해줘야 하는게 조금 있다.

.babelrc.js 생성 및 next.config.js 수정

공식문서에도 예시가 나와 있으며, 내가 작성한 코드는 아래와 같다.

pnpm install --save-dev @stylexjs/nextjs-plugin
.babelrc.js
const path = require('path');
module.exports = {
  presets: ['next/babel'],
  plugins: [
    [
      '@stylexjs/babel-plugin',
      {
        dev: process.env.NODE_ENV === 'development',
        runtimeInjection: false,
        genConditionalClasses: true,
        treeshakeCompensation: true,
        aliases: {
          '@/*': [path.join(__dirname, '*')],
        },
        unstable_moduleResolution: {
          type: 'commonJS',
          rootDir: __dirname,
        },
      },
    ],
  ],
};
next.config.js
const path = require('path');
const stylexPlugin = require('@stylexjs/nextjs-plugin');

/** @type {import('next').NextConfig} */
const nextConfig = {
};

module.exports = stylexPlugin({
  aliases: {
    '@/*': [path.join(__dirname, '*')],
  },
  rootDir: __dirname,
})(nextConfig);

런타임에 스타일 주입 설정

여태 주로 사용했던 스타일 라이브러리로는 전부 HMR를 지원해서 신경 쓸 필요가 없었다. 그러나 StyleX는 컴파일 단계에서 스타일이 처리되기 때문에, 수정 후에 다시 페이지를 빌드해야 한다.

다행히 @stylexjs/babel-pluginruntimeInjection 설정이 있었다. 이는 런타임에 스타일 주입 여부를 설정하는 것으로, 개발 환경에서만 사용을 권장하고 있다.

.babelrc.js
const path = require('path');

const IS_DEV = process.env.NODE_ENV === 'development';

module.exports = {
  presets: ['next/babel'],
  plugins: [
    [
      '@stylexjs/babel-plugin',
      {
        dev: IS_DEV,
        runtimeInjection: IS_DEV,
        // ... 생략
      },
    ],
  ],
};

근데 가끔 텍스트 색상 같이 정말 단순한 수정 작업에 컴파일이 오래 걸리거나(3초), 컴파일이 끝났음에도 스타일이 변경되지 않은 적이 있었다. 아직 원인은 찾지 못했다.

만약 로컬에서 컴파일러를 아예 사용하고 싶지 않은 경우, @stylexjs/dev-runtime 라이브러리도 지원해주기 때문에 참고해서 적용하면 될 것 같다.

server action, next/font 사용 불가

사이드 프로젝트에서는 최대한 공식문서에서 업데이트된 최신 기능을 모두 사용해보려 노력하는 편이다.

이번에 사용해보고 싶었던 기능 중 하나는 Next.js의 Server Actions인데 올라온 이슈를 보면 안타깝게도 아직 babel 환경에서 지원하지 않는다. 그리고 next/font 역시 swc만 지원하기 때문에 사용이 불가하다. (아직 사용해보지 않은 기능들도 많은데, 발견하게 되면 추후에 업데이트 하겠다)

다행히 StyleX 측에서 Vercel과 얘기중이라고 하니 조만간 해결될 수 있지 않을까 한다.

2. 초기 세팅

reset 및 global css 작성

기존 styled-components를 사용할 때는, styled-reset 라이브러리를 사용하거나 reset.ts에 필요한 내용들만 생성한 후, createGlobalStyle에서 최상단에 작성한 후 커스텀 스타일로 오버라이딩하는 방식으로 사용했다.

// styles/reset.ts
export const reset = css``;

// styles/global.ts
export const GlobalStyle = createGlobalStyle`
  ${reset};

  // 커스텀 css
`;

// apps/layout.tsx
export default function RootLayout({
  children,
}: Readonly<{
  children: React.ReactNode;
}>) {
  return (
    <html>
      <body>
        <StyledComponentsRegistry>
          <GlobalStyle />
          <ThemeProvider theme={theme}>{children}</ThemeProvider>
        </StyledComponentsRegistry>
      </body>
    </html>
  );
}

그런데 StyleX 공식문서에 전역으로 스타일 설정하는 내용을 찾을 수 없었다. 아니나 다를까 공식문서에서 Avoid global configuration 섹션을 찾을 수 있었다. 전역 설정을 지양하는 대신, 어느 프로젝트에서든 일관된 스타일로 동작하는 것을 지향하고 있다.

프로젝트 최초에 styled-components에서 전역 스타일로 설정하고, 나중에 작성한 코드를 봤을 때 전역에 설정해둔 스타일을 까먹고 엄한 곳만 삽질했던 기억이 있어서 공감이 갔다. 그리고 사실 styled-component에서 전역 스타일 설정하고, SSR에서 클라이언트에 스타일 주입 등 일련의 작업보다는 StyleX가 보다 더 직관적이고 간단한 것 같다.

어떻게 수정하는게 좋을지 조금 고민하다가 아래 방법으로 변경했다.

src/apps/global.css
@layer reset, custom;

@layer custom {
  /* 커스텀 css */
}

@layer reset {
  /* reset css */
}
src/apps/layout.tsx
import './global.css';

export default function RootLayout({
  children,
}: Readonly<{
  children: React.ReactNode;
}>) {
  return (
    <html>
      <body>{children}</body>
    </html>
  );
}

StyleX용 eslint 설치

StyleX의 컴파일러가 스타일 검증까지 해주지 않기 때문에, 유효하지 않은 스타일을 넣을 수 있다. 따라서 별도의 ESLint 설정이 필요하다.

npm install --save-dev @stylexjs/eslint-plugin
.eslintrc.json
{
  "extends": "next/core-web-vitals",
  "plugins": ["@stylexjs"],
  "rules": {
    "@stylexjs/valid-styles": "error"
  }
}

이 외로, VSCode를 사용중이라면 StyleX Intellisense 익스텐션이 있다. 하지만 공식 문서에서도 experimental 익스텐션이라 할 정도로 아직 지원하는 기능이 많진 않다.

3. styled-components와 대비되는 점

변경을 진행했던 코드 일부를 가져왔다.

간단하게 메인 페이지에 LayoutWithGnb 컴포넌트를 이용해 Gnb와 컨텐츠를 렌더링해주고 있다. LayoutWithGnb 컴포넌트 내부에는 TopNavigationBar, BottomNavigationBar가 있다. (해당 글에서 필요없는 코드는 임의로 줄이거나 삭제했다)

화면은 아래와 같다.

메인 페이지 화면

메인 화면

src/components/LayoutWithGnb.tsx
'use client';

import styled from 'styled-components';
import TopNavigationBar from './TopNavigationBar';
import BottomNavigationBar from './BottomNavigationBar';
import { PropsWithChildren } from 'react';

const Container = styled.div``;

const ContentWrapper = styled.div<{ $isScrollable: boolean }>`
  ${(p) =>
    p.$isScrollable &&
    css`
      position: relative;
      overflow: scroll;
    `};
`;

const LayoutWithGnb = ({
  showTop = true,
  title,
  showBottom = true,
  isScrollable = true,
  children,
}: PropsWithChildren<LayoutWithGnbProps>) => {
  return (
    <Container>
      {showTop && <TopNavigationBar title={title} />}
      <ContentWrapper $isScrollable={isScrollable}>{children}</ContentWrapper>
      {showBottom && <BottomNavigationBar />}
    </Container>
  );
};

export default LayoutWithGnb;
src/components/TopNavigationBar.tsx
'use client';

import { useParams, useRouter } from 'next/navigation';
import styled, { css } from 'styled-components';
import { IoIosArrowBack, IoIosSettings } from 'react-icons/io';
import { MAX_WIDTH } from '@/styles/globalStyle';

export const TOP_NAVIGATION_BAR_HEIGHT = '5rem';

const Container = styled.div`
  height: ${TOP_NAVIGATION_BAR_HEIGHT};
  width: 100%;
  max-width: ${MAX_WIDTH};
  box-shadow: rgba(17, 17, 26, 0.1) 0 0 5px 0;
  background-color: ${(p) => p.theme.colors.white};
  z-index: 1;
`;

const Wrapper = styled.div`
  display: flex;
  align-items: center;
  height: 100%;
`;

const CurrentTitle = styled.h2`
  margin-left: 1rem;
  font-weight: bold;
`;

const BackButtonWrapper = styled.div`
  display: flex;
  margin-left: 1rem;
`;

const iconCss = css`
  font-size: 2.2rem;
  cursor: pointer;
`;

const ArrowBackIcon = styled(IoIosArrowBack)`
  ${iconCss};
`;

const BackButtonSection = () => {
  const router = useRouter();
  if (isVisibleBackButton(params)) {
    return (
      <BackButtonWrapper
        onClick={() => {
          router.back();
        }}
      >
        <ArrowBackIcon />
      </BackButtonWrapper>
    );
  }

  return <div />;
};

type TopNavigationBarProps = {
  title?: string;
};

const TopNavigationBar = ({ title }: TopNavigationBarProps) => {
  return (
    <Container>
      <Wrapper>
        <BackButtonSection />
        <CurrentTitle>{title ?? '페이지명'}</CurrentTitle>
      </Wrapper>
    </Container>
  );
};

export default TopNavigationBar;
src/components/BottomNavigationBar.tsx
'use client';

import { usePathname, useRouter } from 'next/navigation';
import styled from 'styled-components';
import { ROUTES } from '@/constants/routes';
import { MAX_WIDTH } from '@/styles/globalStyle';
import { IoMdHome, IoIosStar, IoMdSettings } from 'react-icons/io';

export const BOTTOM_NAVIGATION_BAR_HEIGHT = '6rem';

const Container = styled.nav`
  position: fixed;
  bottom: 0;
  width: 100%;
  height: ${BOTTOM_NAVIGATION_BAR_HEIGHT};
  max-width: ${MAX_WIDTH};
  box-shadow: rgba(17, 17, 26, 0.1) 0 0 5px 0;
  background-color: ${(p) => p.theme.colors.white};
`;

const NavWrapper = styled.ul`
  display: flex;
  align-items: center;
  height: 100%;

  & > * {
    width: 50%;

    &:not(:last-child) {
      content: '';
      border-right: 1px solid ${(p) => p.theme.colors.grey100};
    }
  }
`;

const TabWrapper = styled.li<{ $isActive: boolean }>`
  & > * {
    display: flex;
    justify-content: center;
    margin: 0 auto;
    color: ${(p) => (p.$isActive ? 'green' : '')};
  }

  .icon {
    font-size: 2.5rem;
  }
`;

const TabText = styled.span`
  font-size: 1.2rem;
`;

const BottomNavigationBar = () => {
  const router = useRouter();
  const pathname = usePathname();
  return (
    <Container>
      <NavWrapper>
        {navList.map((item) => (
          <TabWrapper
            key={item.text}
            onClick={() => {
              router.push(item.path);
            }}
            $isActive={pathname === item.path}
          >
            <p className="icon">{item.icon}</p>
            <TabText>{item.text}</TabText>
          </TabWrapper>
        ))}
      </NavWrapper>
    </Container>
  );
};

export default BottomNavigationBar;

공통으로 사용되는 스타일을 다른 요소에 적용하는 법

나는 보통 styled-components에서 공통된 스타일이 있을 때 변수로 선언한 후에, 해당 스타일을 필요로 하는 곳의 최상단에 이걸 넣어주어 사용했다.

src/components/TopNavigationBar.tsx
import { IoIosArrowBack, IoIosSettings } from 'react-icons/io';

// 여러 개의 아이콘이 존재할 수 있으니 iconCss로 공통 css를 생성하고 적용
const iconCss = css`
  font-size: 2.2rem;
  cursor: pointer;
`;

const ArrowBackIcon = styled(IoIosArrowBack)`
  ${iconCss};
`;

const BackButtonSection = () => {
  return <ArrowBackIcon />;
};

그래서 처음에 StyleX에서도 이와 비슷한 방식으로 접근했다.

src/components/TopNavigationBar.tsx
const iconStyle = stylex.create({
  base: {
    fontSize: '2.2rem',
    cursor: 'pointer',
  },
});

const backButtonStyle = stylex.create({
  backIcon: {
    ...iconStyle.base, // 에러
  },
});

const BackButtonSection = () => {
  return (
    <IoIosArrowBack {...stylex.props(backButtonStyle.backIcon)} />
  );
};

그런데 이렇게 하니 A style value can only contain an array, string or number. 에러가 떴다.

위의 iconStyle.base만 보면 value가 모두 string이라 에러가 안나야 할 것 같아서, 이를 콘솔로 찍어봤다.

iconStyle.base 로그

iconStyle.base 로그

그랬더니 $$css: true라는 값이 들어가 있는 걸 확인할 수 있었다. 아마 컴파일 단계에서 표시를 위해 추가한 것으로 추측되어 이를 찾아보니, styleXCreateSet 함수에서 찾을 수 있었다.

stylex/packages/shared/src/stylex-create.js
// https://github.com/facebook/stylex/blob/main/packages/shared/src/stylex-create.js#L76

// 이는 stylex.create에 전달된 스타일 객체를 변환하며, 스타일 값들을 클래스명으로 교체한다.
//
// 또한, 그 과정에서 모든 주입된 스타일들을 수집한다.
// 그런 다음 변환된 스타일 객체와 주입된 스타일들의 객체로 구성된 튜플을 반환한다.
//
// 이 함수는 기본적인 검증을 수행하고, 각 네임스페이스를 변환하기 위해 styleXCreateNamespace를 사용한다.
//
// 반환하기 전에, 중복되는 스타일이 주입되지 않도록 합니다.
export default function styleXCreateSet(
  namespaces: { +[string]: RawStyles },
  options?: StyleXOptions = defaultOptions,
): [{ [string]: FlatCompiledStyles }, { [string]: InjectableStyle }] {
  const resolvedNamespaces: { [string]: FlatCompiledStyles } = {};
  const injectedStyles: { [string]: InjectableStyle } = {};

  for (const namespaceName of Object.keys(namespaces)) {
    // ...
    resolvedNamespaces[namespaceName] = { ...namespaceObj, $$css: true };
  }

  return [resolvedNamespaces, injectedStyles];
}

따라서 하나의 요소에 다른 여러 스타일을 적용하기 위해서라면, 공식문서에서 명시하고 있는 Merging Style처럼 여러 style을 주입해줘야 한다.

src/components/TopNavigationBar.tsx
const iconStyle = stylex.create({
  base: {
    fontSize: '2.2rem',
    cursor: 'pointer',
  },
});

const backButtonStyle = stylex.create({
  backIcon: {},
});

const BackButtonSection = () => {
  return (
    <IoIosArrowBack {...stylex.props(iconStyle.base, backButtonStyle.backIcon)} />
  );
};

보통 스타일 코드를 상단이나 하단에 몰아두고, 컴포넌트 내부에서는 신경 안쓰이게 하는 편인데, 위처럼 하게 될 경우 iconStyle을 어디에서 사용 중인지 확인하기 위해 컴포넌트 내부를 봐야하기 때문에 약간의 피로도가 있지 않을까 생각이 든다.

기존 컴포넌트의 스타일 오버라이딩

styled-components에서는 기존 컴포넌트의 스타일에서 특정 스타일만 수정하고 싶을 때, 이미 존재하는 className 속성을 활용하면 바로 오버라이딩할 수 있다.

하지만 StyleX는 이 방식처럼 부모 컴포넌트에서 stylex.props(새로운 스타일)로 할 경우, '늘 마지막 스타일이 적용된다(the last style applied always wins)'는 원칙에 의거하여 오버라이딩이 아니라 덮어 씌워진다.

import Button from '@/components/Button';

// 1. styled-compoents
// 기존 Button 컴포넌트에 선언한 스타일에 아래 스타일이 오버라이딩 된다.
const StyledButton = styled(Button)`
  height: 5rem;
  width: 5rem;
  border-radius: 50%;
`;

// 2. StyleX
// 기존 Button 컴포넌트의 스타일은 무시되며 style.button 스타일만 적용된다.
<Button appearance="primary" onClick={open} {...stylex.props(style.button)}>
  {/* 생략 */}
</Button>;

따라서 StyleX에서 스타일 오버라이딩을 원할 때에는, style 자체를 넘겨줘야 한다.

// 부모 컴포넌트
<Button appearance="primary" onClick={open} overrideStyle={style.button}>
  {/* 생략 */}
</Button>;

// 자식 컴포넌트 (Button)
type ButtonProps = {
  overrideStyle?: stylex.StyleXStyles;
};

const Button = ({ overrideStyle, children, ...restProps }: PropsWithChildren<ButtonProps>) => {
  return (
    <button
      {...stylex.props(
        s.base,
        s.height(`${height}px`),
        isFull && s.isFull,
        appearanceS[appearance],
        overrideStyle
      )}
      {...restProps}
    >
      {children}
    </button>
  );
};

그런데 이렇게 할 경우, 스타일 오버라이딩이 필요한 컴포넌트에서 매번 인터페이스를 뚫어줘야하기 때문에 불편한 것 같다.

물론 PropsWithStyle 같은 공용 인터페이스를 만든 후 사용해도 되지만, 컴포넌트 내부에서 주입받은 값을 요소에 넘겨줘야하니 한 번은 더 신경써야 한다.

외부에서 import 해온 변수 사용 불가

전체 애플리케이션에서 설정해둔 MAX_WIDTH를 가끔 컴포넌트 내부에서도 사용해야할 때가 있다.

src/components/TopNavigationBar.tsx
// 수정 전
import { MAX_WIDTH } from '@/styles/globalStyle';

const Container = styled.div`
  max-width: ${MAX_WIDTH};
`;

const TopNavigationBar = ({ title }: TopNavigationBarProps) => {
  return (
    <Container>
      {/* 생략 */}
    </Container>
  );
};

// 수정 후
const styles = stylex.create({
  container: {
    maxWidth: MAX_WIDTH,
  },
});

const TopNavigationBar = ({ title }: TopNavigationBarProps) => {
  return (
    <div {...stylex.props(stylex.container)}>
      {/* 생략 */}
    </div>
  );
};

하지만 이를 StyleX에서 바로 사용하면 Only static values are allowed inside of a stylex.create() call. 에러가 뜬다.

StyleX의 discssion을 찾아보니, StyleX는 컴파일러이기 때문에 모든 변수가 로컬에 정의되어 있어야 하며, 예외 케이스는 오직 *.styles.ts(x) 파일에 defineVars()를 이용해 선언한 변수뿐이다. defineVars()로 선언한 변수는 컴파일 단계에서 실제로 파일의 내용을 읽어오는 것이 아니라, import할 이름과 파일 경로를 사용해 해시 알고리즘을 통해 주입할 변수명을 생성한다.

styles/theme.stylex.ts
const MAX_WIDTH = '64rem';
export const viewport = defineVars({
  maxWidth: MAX_WIDTH,
});
src/components/TopNavigationBar.tsx
const styles = stylex.create({
  container: {
    maxWidth: viewport.maxWidth,
  },
});

const TopNavigationBar = ({ title }: TopNavigationBarProps) => {
  return (
    <div {...stylex.props(styles.container)}>
      {/* 생략 */}
    </div>
  );
};

보통 해당 변수가 실제로 사용 되는 파일 내부에 선언 후 export해서 사용했는데, 오랜만에 수정이 필요할 때 어디에 있었는지 바로 기억이 나지 않아 여기저기 파일을 열어 봐야했다. 그런데 defineVars()를 사용하다보니 자연스레 관련 변수들을 동일한 파일에 넣으니 그 번거로움도 없어졌다.

ThemeProvider 보다 편한 defineVars

위에서 나온 defineVars()로 전역에서 사용해야 하는 값을 쉽게 import 해서 사용할 수 있다.

styled-components는 루트에서 ThemeProvider와 미리 설정한 theme을 넘겨준 후, 스타일 내부에서 props로 받아 사용할 수 있다. 한 두 줄이라면 상관없지만, 한 컴포넌트 내에서 조건부로 스타일을 수정해야할 일이 많거나, 여러 props를 사용해야 한다면 좀 번거로워진다.

src/components/TopNavigationBar.tsx
// 수정 전
const Container = styled.div`
  height: ${TOP_NAVIGATION_BAR_HEIGHT};
  width: 100%;
  max-width: ${MAX_WIDTH};
  box-shadow: rgba(17, 17, 26, 0.1) 0 0 5px 0;
  background-color: ${(p) => p.theme.colors.white};
  z-index: 1;
`;

const TopNavigationBar = ({ title }: TopNavigationBarProps) => {
  return (
    <Container>
      {/* 생략 */}
    </Container>
  );
};

또한 타입스크립트를 사용하면 DefaultTheme선언 병합이 필요하다.

그에 반해 defineVars()는 변수로 선언하면 타입 추론이 매우 잘되어, 따로 타입을 신경 쓸 필요가 없다.

styles/theme.stylex.ts
export const colors = defineVars({
  white: '#ffffff',
  // ...
});
src/components/TopNavigationBar.tsx
// 수정 후
const styles = stylex.create({
  container: {
    height: size.topGnbHeight,
    width: '100%',
    maxWidth: viewport.maxWidth,
    boxShadow: 'rgba(17, 17, 26, 0.1) 0 0 5px 0',
    backgroundColor: colors.white,
    zIndex: 1,
  },
});

const TopNavigationBar = ({ title }: TopNavigationBarProps) => {
  return (
    <div {...stylex.props(styles.container)}>
      {/* 생략 */}
    </div>
  );
};

그리고 위처럼 defineVar()로 선언한 것을 불러오니 작성해야 할 반복 코드가 줄어들어 이전보다 깔끔해 보인다.

스타일을 묶을 수 있음

styled-components를 사용할 경우, 엘리먼트 단위로 만들어줘야 했기 때문에 응집도가 높은 요소들의 스타일링을 묶이게끔 표현하려면, 개행을 안하거나 주석을 달아야 했다.

// styled-components
const Container = styled.div``;

// ...다른 스타일

const StyledModal = styled(Modal)``;
const ModalHeader = styled.h4``;

const CreateItemModal = () => {
  return (
    <Container>
      {/* 다른 엘리먼트 */}
      <StyledModal header={<ModalHeader>모달 제목</ModalHeader>}>{/* 생략 */}</StyledModal>
    </Container>
  );
};

그런데 StyleX는 스타일별로 나눠서 생성할 수 있어서 더 편한 것 같다.

// StyleX
const style = stylex.create({
  container: {},
  // ...다른 스타일
});

const modalS = stylex.create({
  base: {},
  header: {},
});

const CreateItemModal = () => {
  return (
    <div {...stylex.props(style.container)}>
      {/* 다른 엘리먼트 */}
      <Modal
        header={<h4 {...stylex.props(modalS.header)}>모달 제목</h4>}
        {...stylex.props(modalS.base)}
      >
        {/* 생략 */}
      </Modal>
    </div>
  );
};

스타일을 위한 타입 선언이 줄어든다

styled-components를 사용할 경우, 동적으로 스타일을 생성할 경우 이를 위한 타입을 선언해야 할 때도 종종 있다.

type StyledButtonProps = {
  appearance: Appearance;
  height: Height;
  isFull?: boolean;
};

const StyledButton = styled.button<StyledButtonProps>`
  // ...
`;

그런데 StyleX는 계산된 값을 넘겨 받는 것이기 때문에 따로 타입 선언할 일이 없다.

const s = stylex.create({
  base: {
    // ...
  },
});

const appearanceS = stylex.create({
  // ...
});

const Button = ({
  // ...
  ...restProps
}: PropsWithChildren<ButtonProps>) => {
  return (
    <button
      {...stylex.props(
        s.base,
        s.height(`${height}px`),
        isFull && s.full,
        appearanceS[appearance], // 타입 추론이 잘 됨
        overrideStyle
      )}
      {...restProps}
    >
      {children}
    </button>
  );
};

pseudo-classes(의사 클래스) 사용 방식

Common 컴포넌트를 만들 때, 특히 Button이나 Checkbox처럼 상태에 따라 스타일을 다르게 설정해야하는 컴포넌트에서 의사 클래스를 많이 사용하는 편이다.

const StyledButton = styled.button<StyledButtonProps>`
  height: ${(p) => p.height}px;
  border: none;
  border-radius: 4px;
  padding: 5px 10px;

  &:hover {
    cursor: pointer;
  }

  &:disabled,
  &:focus {
    outline: none;
  }

  &:disabled {
    cursor: not-allowed;
  }

  ${(p) =>
    p.isFull &&
    css`
      width: 100%;
    `}
`;

그런데 StyleX에서는 pseudo 코드 작성을 지양하고 있고, 하더라도 아래처럼 그 CSS의 속성에 직관적으로 선언하는 형태를 지향한다.

const s = stylex.create({
  base: {
    border: 'none',
    borderRadius: '0.4rem',
    padding: '0.5rem 1rem',
    cursor: {
      default: null,
      ':hover': 'pointer',
      ':disabled': 'not-allowed',
    },
    outline: {
      default: null,
      ':disabled': 'none',
      ':focus': 'none',
    },
  },
  height: (height) => ({ height }),
  isFull: {
    width: '100%',
  },
});

물론, 해당 속성일 때 어떤 값을 갖고 있어야 하는지 명확하게 눈에 띄어서 좋지만, 없는 경우에도 default: null처럼 명시적으로 작성해줘야 하기 때문에 개인의 성향에 따라 다를 것 같다.

styled-components는 개발자의 입장에서 만들어진 코드라면, StyleX는 전적으로 CSS의 입장에서 만들어진 것 같다.

4. 기타

부모 요소에서 자식 요소 스타일링이 안된다

사실 이게 좀 치명적인 단점이다.

src/components/BottomNavigationBar.tsx
const NavWrapper = styled.ul`
  & > * {
    width: 50%;

    &:not(:last-child) {
      content: '';
      border-right: 1px solid ${(p) => p.theme.colors.grey100};
    }
  }
`;

const BottomNavigationBar = () => {
  return (
    <Container>
      <NavWrapper>{/* 생략 */}</NavWrapper>
    </Container>
  );
};

위 코드를 아래 처럼 수정할 경우, Invalid pseudo or at-rule. 라는 에러가 뜬다.

src/components/BottomNavigationBar.tsx
const styles = stylex.create({
  navWrapper: {
    '& > *': {
      width: '50%',
      //...
    },
  },
});

const BottomNavigationBar = () => {
  return (
    <nav {...stylex.props(styles.container)}>
      <ul {...stylex.props(styles.navWrapper)}>{/* 생략 */}</ul>
    </nav>
  );
};

StyleXTypes.d.ts에서도 해당 패턴은 존재하지 않고 공식문서에서도 최대한 의사 요소(pseudo-elements) 사용을 지양하고 있다.

이는 StyleX가 스타일 캡슐화(Encapsulation)를 추구하고 있기 때문이다. 해당 패턴은 강력하지만 스타일을 예측하기가 어렵고, 다른 요소에 영향을 줄 수 있다.

.className > *
.className ~ *
.className:hover > div:first-child

따라서 StyleX에서는 위와 같은 패턴을 모두 사용할 수 없다. 물론, 예측하기 어렵다는 점은 인정한다. 자식 컴포넌트가 여러 컴포넌트 하위에 있을 때, 어디에 적용한 스타일 때문에 이렇게 되었는지 코드를 타고 타고 올라간 경험이 있기 때문이다.

하지만 StyleX에 이에 대한 대안이 없다는 점은 상당히 아쉬우며, 기존 프로젝트에 이런 패턴이 많이 있다면 마이그레이션하기 쉽지 않을 것 같다. 만약 해당 패턴을 꼭 사용해야 한다면, CSS/LESS/SASS/SCSS 등 다른 것과 같이 사용하라고 말하고 있다.

그래서 할 수 없이 (눈물을 머금고) SCSS를 같이 사용했다.

src/components/BottomNavigationBar.scss
.ulWrapper {
  & > * {
    width: 50%;

    &:not(:last-child) {
      content: '';
      border-right: 1px solid #f5f5f5;
    }
  }
}
src/components/BottomNavigationBar.tsx
const styles = stylex.create({
  navWrapper: {
    display: 'flex',
    alignItems: 'center',
    height: '100%',
  },
});

const BottomNavigationBar = () => {
  const router = useRouter();
  const pathname = usePathname();
  return (
    <nav {...stylex.props(styles.container)}>
      <ul className={scssS.ulWrapper} {...stylex.props(styles.navWrapper)}>
        {/* 생략 */}
      </ul>
    </nav>
  );
};

그런데 이렇게 했더니, 여기서도 '늘 마지막 스타일이 적용된다'는 원칙 때문에, 마지막에 작성한 스타일만 적용되었다.

필요한 부분만 SCSS로 하고 싶었지만, 결국 SCSS를 사용해야 하는 곳은 SCSS만을 사용했다.

src/components/BottomNavigationBar.scss
.ulWrapper {
  display: flex;
  align-items: center;
  height: 100%;

  & > * {
    width: 50%;

    &:not(:last-child) {
      content: '';
      border-right: 1px solid #f5f5f5;
    }
  }
}
src/components/BottomNavigationBar.tsx
const BottomNavigationBar = () => {
  const router = useRouter();
  const pathname = usePathname();
  return (
    <nav {...stylex.props(styles.container)}>
      <ul className={scssS.ulWrapper}>{/* 생략 */}</ul>
    </nav>
  );
};

SCSS를 추가하다보니, 그럼 SCSS 내부에서 theme.stylex.ts에서 선언한 변수를 사용하고 싶을 때를 생각하니 아예 CSS 변수를 사용하는게 좋지 않을까란 생각도 든다. (추후 추가)

생각보다 중복 코드가 많이 나온다

src/components/TopNavigationBar.tsx
const TopNavigationBar = ({ title }: TopNavigationBarProps) => {
  const router = useRouter();

  return (
    <div {...stylex.props(styles.container)}>
      <div {...stylex.props(styles.wrapper)}>
        <BackButtonSection />
        <h2 {...stylex.props(styles.title)}>{title ?? '페이지명'}</h2>
      </div>
    </div>
  );
};

아무리 간단한 스타일이라도, 스타일이 들어간 모든 요소에 {...stylex.props(...)} 을 작성해줘야 한다.

만약 컴포넌트 단위가 더 크다면 꽤나 중복적이고, 코드를 읽을 때 피로도가 더 높을 것 같다.

브라우저에서 CSS 디버깅이 어렵다

StyleX는 스타일 정보를 컴파일 단계에서 해싱하여, 최종적으로 생성되는 클래스명에는 짧고 최소한의 정보만을 포함한다. 이는 성능 최적화, 보안 강화, 충돌 방지 등의 이점을 갖고 있지만, 브라우저에서 바로 스타일을 알아보기 어렵다.

브라우저에서의 Elements 탭

브라우저 > Elements 탭

우측의 스타일은 모두 TopNavigationBar의 wrapper의 내용이지만, 스타일 값 하나하나가 해싱되어 보여지기 때문에 모두 따로 노출된다.

src/components/TopNavigationBar.tsx
const styles = stylex.create({
  // ...
  wrapper: {
    display: 'flex',
    alignItems: 'center',
    height: '100%',
  },
});

const TopNavigationBar = ({ title }: TopNavigationBarProps) => {
  return (
    <div {...stylex.props(styles.container)}>
      <div {...stylex.props(styles.wrapper)}>
      {/* 생략*/}
      </div>
    </div>
  );
};

왜 이렇게 노출되는지 추후에 라이브러리를 들여다 봐야 할 것 같다.

Next.js에서는 dev 모드에서 기본적으로 Source maps을 활성화해주기 때문에, Source 탭으로 가서 실제 파일을 보는 방법도 있지만, Element 탭에서 바로 확인이 불가한 점은 좀 아쉽다. (추후 다른 방법을 알게 되면 추가)


느낀점

styled-components를 오래 사용한 프로젝트를 StyleX로 마이그레이션하려면 상당한 시간이 들 것 같다. 해당 글에서도 간단한 페이지 하나만 수정한 것에 비해 많은 불편한 점이 나왔다고 생각한다. 따라서 사이즈가 작은 프로젝트에서는 괜찮지만, 운영 중이거나 사이즈가 큰 프로젝트라면 v1.0 이후에 고려해보는 것이 좋지 않을까 생각한다.

SWC 지원이 빨리 되었으면 좋겠다

현재 Stylex가 바벨 환경만 가능하기 때문에 제약사항이 좀 (많이) 있다.

2023년 12월에 올라온 이슈에서는 메타에서는 SWC 플러그인 관련 예정 작업이 없다며, "StyleX가 인기가 많아진다면 자연스럽게 Next.js가 이를 동작하게끔 만들것이다"라고 했다.

하지만 2024년 2월에 StyleX에 올라온 PR을 보면, 런타임 이전에 모든 JS 파일을 StyleX 바벨 플러그인으로 변환하고 CSS 번들을 출력 후, 번들된 CSS 파일을 Next.js 같은 다른 라이브러리에 주입하는 방식을 시도 중인 것 같다. 지금도 꾸준히 계속 PR에 커밋이 올라오는 걸 보니, 조만간 지원이 될 것 같다.

StyleX는 컴포넌트 스타일링을 위한 것이다

'이게 왜 안되지?' 싶을 때 StyleX 레포로 들어가 검색을 많이 했는데, 질문에 대한 StyleX 개발자의 답변을 보면 일관된 기조가 있다.

"StyleX is for component styling."

그렇다. StyleX는 컴포넌트의 스타일을 위한 것이다.

StyleX의 특징이나 철학만 보더라도, Scalable(확장 가능), Predictable(예측 가능), Composable(조합 가능), 그리고 Co-location(동일 위치 배치) 등 모두 컴포넌트 단위에서 상당한 이점을 주는 설계다.

실제 구현 코드에서도 전역 설정이 없으며, 의사 클래스나 의사 요소의 사용 범위도 최소한으로 하여 컴포넌트에 미칠 수 있는 사이드 이펙트도 최소화했다.

따라서 프로젝트 전체에 바로 적용하기 보다, 모노레포의 컴포넌트 패키지부터 적용하는건 시도해볼 법 한 것 같다.

늘 공식문서를 보고 작업하는 것을 추천한다

아직 v1.0 전이라 그런지, 버그 수정이나 업데이트 주기가 상당히 빠르다.

해당 글은 1-2주에 걸쳐 작성했는데, 글을 작성하는 도중에도 업데이트 된 이슈나 수정된 부분이 있어서 글을 읽고 있는 시점에도 변경될 가능성이 높다. 따라서 항상 StyleX 공식 문서를 보고 프로젝트에 적용하는 것을 추천한다.

(뭔가 글을 업로드하고 StyleX의 내용이 자주 변경 될 것 같아서... 수정이 잦을 수도 있다)


참고