본문 바로가기
Programming/3. Experience

emotion to scss module 도입기

by @sangseophwang 2024. 7. 25.

 

Prologue

 emotion 도입 이후 도메인별로 작성된 스타일시트를 컴포넌트 레벨로 분리하며 가독성과 생산성이 향상하는 성과를 이뤘습니다. 하지만 최근 프로젝트부터는 scss module을 도입하는 방향으로 변경했는데, 그 이유와 과정, 결과에 대해 작성해보고자 합니다. 

 

문제점

 

시작은 Next.js의 app router 적용부터였습니다.

Next.js 13.4 릴리즈와 함께 app router의 안정화가 이루어졌고, 이에 맞춰 전체 서비스를 마이그레이션 하는 프로젝트를 진행했습니다. 클라이언트-서버 컴포넌트라는 새로운 패러다임에 맞춰 전체 구조를 변경하는 과정에서 초기 렌더 시 스타일이 주입되지 않는 이슈가 있었는데, 문제는 다음과 같았습니다.

 

  • 런타임 자바스크립트가 필요한 css-in-js 라이브러리는 서버 컴포넌트에서 지원하지 않는다.
  • emotion 또한 클라이언트 컴포넌트에서만 스타일 주입이 가능하다.
  • 기존에 적용하던 공통 스타일 래퍼 컴포넌트는 클라이언트 컴포넌트로 변경하고 Layout(서버 컴포넌트)으로 이동했다.
  • 이때 클라이언트-서버 컴포넌트의 스타일 렌더 시점 불일치로 초기 렌더 시 적용되지 않는 문제가 발생했다. 

 

이 문제를 해결하기 위해서는 구조 변경이 필요했는데 이를 위해 고려해야 할 점들이 많았기에 wrapper는 scss, 그 외 컴포넌트는 emotion을 사용하는 임시방편을 마련하게 되었습니다.

 

두 번째 문제는 babel이었습니다.

emotion은 브라우저에서 해쉬화된 클래스네임으로 렌더되기 때문에 개발 환경에서 구조를 확인하기 위해서는 source map이 필요했습니다. 소스맵을 적용하기 위해서는 babel 패키지와 함께 `@emotion/babel-plugin`이라는 패키지로 간단하게 적용할 수 있습니다.

 

소스맵 적용 예시. 출처: emotion 공식 문서

 

하지만 babel을 적용하게 되면 Next.js의 기본 컴파일러인 SWC를 사용할 수 없고, 이는 빌드 시 이점을 활용할 수 없다는 문제로 이어졌습니다. 실제로 아래 결과와 같이 swc와 비교해 빌드 테스트를 진행했을 때 유의미한 성능 차이가 있다는 점을 확인했습니다.

 

 

마지막은 emotion의 두 번째 메인테이너인 Sam Magura가 기고한 아티클입니다. 이 글은 react 18 출시 후 css-in-js가 주는 장점보다 단점이 더 크다는 내용을 담고 있는데, 요약하자면 다음과 같습니다.

  • 스타일 삽입 시 css로 직렬화하는 과정이 필요하고, 이는 런타임 오버헤드를 추가한다.
  • css-in-js는 번들 크기를 늘린다.
  • 조건부 스타일의 경우 DOM 노드에서 재연산을 해야 하기 때문에 브라우저 성능에 좋지 않다.

이런 여러 문제로 인해 팀 내에서 스타일 적용 방식 변경에 대한 필요성을 제기했고, 여러 제안(제로 런타임 css, StyleX 등)이 나왔지만 공통적인 의견은 다음과 같았습니다.

  • 현재 css-in-js는 표준이라 할만한 라이브러리가 없다.
  • 새로운 css 라이브러리를 채택하게 되면 컨벤션에 따라 의존성이 높아질 수 있다.
  • 학습과 마이그레이션 비용이 발생한다.

이에 라이브러리에 의존적이지 않고 안정적이며 emotion의 이점을 가져갈 수 있는 대안으로 scss module을 채택하게 되었습니다.

 

scss module

우선 emotion의 스타일 컨벤션은 다음과 같았습니다.

// style.ts

const box = css`
  width: 30px;
  height: 30px;
  background-color: red;
`;

const ComponentStyle = { box };

export default ComponentStyle;


// Component.tsx

import ComponentStyle from './style';

const Component = () => {
  ...
  return (
    <div className={ComponentStyle.box} />
  )
};

export default Component;

 

초기 설계 당시 css module과 유사한 형태로 스타일을 적용했기 때문에 마이그레이션 작업은 꽤 수월했습니다. 아래는 마이그레이션을 적용한 예시입니다.

// style.scss

.box {
  width: 30px;
  height: 30px;
  background-color: red;
}
// Component.tsx

import ComponentStyle from './style.scss';

const Component = () => {
  ...
  return (
    <div className={ComponentStyle.box} />
  )
};

export default Component;

 

조건부 스타일의 경우 clsx 패키지를 활용해 컴포넌트 레벨에서 제어하도록 적용했습니다.

// as-is

<div className={Style.box({ isSelected })}> // 함수 형태의 스타일에 prop 전달

// to-be

<div className={clsx(Style.box, { [Style.red]: isSelected })}>

 

 

타입스크립트에서는 모듈 타입 정의가 필요하기 때문에 타입 정의 파일(d.ts)에 다음과 같은 설정을 추가했습니다.

declare module '*.module.scss' {
  const classes: { [key: string]: string };
  export default classes;
}

 

마지막으로 타입스크립트 기반 scss module 사용과 IDE 자동 완성 기능을 위해 typescript-plugin-css-modules 패키지를 devDependencies에 적용했습니다.

 

패키지 적용 예시. 출처 - typescript-plugin-css-modules npm 문서

적용기 회고

신규 프로젝트에서 위와 같은 방식으로 scss module을 도입해 큰 비용 없이 마이그레이션을 진행할 수 있었습니다. css module과 유사한 형태로 구성했기 때문에 가능한 일이었지만, 다르게 생각하면 초기 기술 선택 시 scss module로 선택했으면 어땠을까 하는 아쉬움도 남았습니다. 이번 케이스를 통해 배운 점은 다음과 같습니다.

  • framework agnostic 기준으로 기술을 고려하는 것이 좋을 수 있다.
    • app directory과 같이 예상치 못한 변경에 영향을 최소화할 수 있다.
    • 라이브러리의 유지보수 등 의존성에서 보다 자유로워질 수 있다.
    • 기술 사용이나 마이그레이션을 위한 비용을 최소화할 수 있다.

단순히 코드 레벨에서 개선하거나 사용의 편리함이라는 달콤함만 고려해 기술을 선택했을 때 처음엔 이점만 보일 수밖에 없습니다. 기존에 안고 있던 문제나 불편함을 해결하고자 적용했을 테니 말이죠. 하지만 일반적으로 프로젝트는 개인이 아닌 다수가 참여하게 되고, 다수가 참여하는 만큼 사용 범위는 예측할 수 없을 정도로 확장됩니다. 그리고 예고 없이 문제가 발생했을 때 전체 서비스까지 영향을 미칠 수 있습니다. 그만큼 기술 선택에 있어서는 보수적이고 신중하게 해야 한다는 점을 몸소 깨달을 수 있었던 경험이었습니다.

 

반응형

댓글