지나가던 개발(zigae)

우리가 몰랐던 styled-components 동작원리

2022년 6월 27일 • ☕️☕️☕️ 13 min read

styled components logo

styled-components를 처음 사용하기 시작했을 때를 기억해 보면 마법처럼 느껴졌다.

처음엔 약간 모호했던 반은 문자열, 반은 함수로 작성되고 CSS를 우회하여 React 컴포넌트에 마크업을 하기도 했다. 이후 필자를 포함한 많은 개발자들이 styled-components를 사용하는 방법을 배웠지만 내부에서 무슨 일이 일어나고 있는지 제대로 이해하지 못했을 것이다.

모든 프로그래밍은 동작 원리를 이해하는 것이 중요하다. 운전을 하기 위해 차량 작동 방식을 이해할 필요는 없지만 차량이 길가에서 고장 났을 때 도움이 되는 것은 확실하다.

CSS를 디버깅하는 것은 도구를 레이어에 추가하지 않고는 꽤나 어렵다. 하지만 styled-components를 이해함으로써 기존보다 적은 비용으로 CSS 문제를 진단하고 고칠 수 있을 것이다.

본 글은 개발에 어느 정도 익숙한 프론트개발자를 위해 작성되었다.

필자는 React, styled-components, FP(functional programming)에 대한 지식이 있다고 가정한다.

최대한 쉽게 설명하기 위해 노력했으나, 러닝커브가 꽤 존재하며 어려울 수 있다.

시작

먼저 공식 문서에서 가져온 간단한 예시이다.

const Title = styled.h1`
  font-size: 1.5em;
  text-align: center;
  color: palevioletred;
`;

styled-components는 각각 DOM 노드에 해당하는 helper method 집합을 제공한다. h1, header, button 등 외에도 수십 가지가 존재한다. (line, path와 같은 SVG 컴포넌트도 제공)

helper method는 태그가 지정된 템플릿 리터럴 문법을 사용하여 CSS의 청크로 호출된다. 다음과 같이 작성 되었다고 간주하고자 한다.

const Title = styled.h1(`
  font-size: 1.5em;
  text-align: center;
  color: palevioletred;
`);

h1styled객체에 대한 helper method이며 단일 매개변수인 문자열을 받아 호출된다.

이러한 helper method는 작은 팩토리 컴포넌트이다. 호출할 때마다 완전히 새로운 React Component를 얻는다.

다음은 위 내용을 기반하여 스케치하였다.

// 1. 함수 호출
function h1(styles) {
  // 2. 새로운 컴포넌트 생성
  return function NewComponent(props) {
    // 3. 다음 HTML Element를 생성
    return <h1 {...props} />;
  };
}

const Title = styled.h1(...)와 같이 실행하면 새로운 컴포넌트가 Title 상수에 할당된다. 그리고 앱에서 Title 컴포넌트를 렌더링 하면 <h1> DOM 노드를 생성한다.

함수에 전달한 styles 매개변수 및 h1 태그 사용법이다.

Title 컴포넌트를 렌더링 할 때 다음과 같은 동작이 발생한다.

  • stylesdKamQW 또는 iOacVe처럼 무작위로 생성된 문자열으로 해시함으로써 유니크한 클래스명을 생성한다.
  • *lightweight CSS preprocessor Stylis를 통해 CSS를 실행한다.
  • 해시 된 문자열을 이름으로 사용하고 styles 문자열의 모든 CSS를 함하는 새로운 CSS 클래스를 페이지에 주입한다.
  • 반환된 HTML Element에 class name을 적용한다.

*lightweight CSS preprocessor: Sass/Less와 비슷하게 필요에 따라 vendor에 prefix를 적용하고 몇가지 syntax sugar를 제공한다.

코드로 보면 다음과 같다.

function h1(styles) {
  return function NewComponent(props) {
    const uniqueName = generateUniqueName(styles);
    const createdStyles = createStylesThroughStylis(styles);

    createWithInjectCSSClass(uniqueName, createdStyles);

    return <h1 className={uniqueName} {...props} />;
  };
}

<Title>Hello World</Title>를 렌더링 하면 다음과 같이 HTML 결과를 볼 수 있다.

<style>
  .dKamQW {
    font-size: 1.5em;
    text-align: center;
    color: palevioletred;
  }
</style>
<h1 class="dKamQW">Hello World</h1>

생략 된 내용이 많다.

실제 styled-components 코드 베이스는 이보다 훨씬 더 복잡합니다. 필자는 최적화를 위한 많은 코드들을 건너뛰었다.

작성된 코드를 그대로 사용하면 렌더링 할 때마다 새로운 CSS 클래스가 생성된다. 하지만 실제로 개발 환경에서는 useMemo, useEffect 훅을 통해 필요할 때만 작업을 수행한다.

Lazy CSS Inject

React에서는 일부 JSX를 조건부로 렌더링 할 수 있다. 아래 예시 코드는 Items가 주어졌을 경우에만 <Wrapper> 컴포넌트를 렌더링하고 있다.

styled-components는 이 경우에 개발자가 작성한 CSS로 아무것도 하지 않는다는 것은 놀라운 사실이다. 실제로 background 선언은 DOM에 추가되지 않는다.

styled-components가 정의될 때마다 CSS 클래스를 생성하고, 스타일을 페이지에 주입하기 전에 컴포넌트가 렌더링 될 때까지 기다린다.

실제로 더 큰 어플리케이션에서 *수백 킬로바이트의 사용되지 않은 CSS가 브라우저로 전송되는 것은 드문 일이 아니다. styled-components를 사용하면 작성한 CSS가 아닌 렌더링 한 CSS에 대해서만 비용을 지불한다.

*수백 킬로바이트의 사용되지 않은 CSS가 브라우저로 전송: 기술적으로 일부 사용되지 않는 CSS가 번들에 포함되지만 SSR에서는 가치 있는 비용이다. JS를 받기 전에 페이지를 그릴 수 있지만 CSS를 받기 전엔 페이지를 그릴 수 없다.

이렇게 동작할 수 있는 이유는 자바스크립트의 클로저가 있기 때문이다. styled.h1에서 생성된 모든 컴포넌트는 CSS 문자열을 포함하는 자신만의 스코프를 가지고 있다. Wrapper 컴포넌트를 렌터링할 때 아무리 몇 초/분/시간 후가 되더라도 필자가 작성한 스타일에 대한 독립적인 액세스 권한을 가진다.

Lazy CSS Inject를 하는 이유는 하나 더 있다. 바로 interpolated 스타일 때문이다. 본 글의 말미에 다루도록 하겠다.

동적 CSS rules

createWithInjectCSSClass 함수가 어떻게 동작하는지 궁금할 것이다. 자바스크립트 내에서 새로운 CSS 클래스를 생성할 수 있을까? 정답은 “예”이다.

<style> 태그를 만든 후에 원시 CSS 코드를 문자열으로 작성하는 방법이 있다.

const styleTag = document.createElement('style');

document.head.appendChild(styleTag);

const newRule = document.createTextNode(`
.dKamQW {
  font-size: 1.5em;
  text-align: center;
  color: palevioletred;
}
`);

styleTag.appendChild(newRule);

위 방법은 유효하지만 번거롭고 느리다. 동일한 기능을 하는 보다 나은 방법은 DOM의 CSS 버전인 CSSOM을 사용하는 것이다. CSSOM은 자바스크립트를 사용하여 CSS 규칙을 추가하거나 제거할 수 있는 더 편리한 방법을 제공한다.

const styleSheet = document.styleSheets[0];

styleSheet.insertRule(`
.dKamQW {
  font-size: 1.5em;
  text-align: center;
  color: palevioletred;
}
`);

오랫동안 CSSOM을 통해 생성된 스타일은 크롬 개발자 도구에서 수정할 수 없었다. 개발자 도구에서 회색으로 표시된 스타일을 본 적이 있다면 다음과 같은 제한 사항 때문이다.

현재는 고맙게도 크롬85에서 수정이 가능하도록 변경되었다. 관심이 있다면 크롬 팀에서 styled-components와 같은 CSS-in-JS 라이브러리에 대한 지원을 개발자 도구에 추가한 방법에 대한 배경글이 있다.

함수형 프로그래밍으로 헬퍼 함수 작성

필자는 이전에 styled.h1을 에뮬레이트하기 위한 h1 팩토리 함수를 다음과 같이 만들었다.

function h1(styles) {
  return function NewComponent(props) {
    const uniqueName = generateUniqueName(styles);
    const createdStyles = createStylesThroughStylis(styles);

    createWithInjectCSSClass(uniqueName, createdStyles);
    
    return <h1 className={uniqueName} {...props} />;
  };
}

이 함수는 잘 동작하지만, button, links, footer, aside 등 더 많은 헬퍼 함수가 필요하다.

또 다른 문제는 보시다시피 styled는 직접 함수로 호출될 수도 있다.

const AlternativeSyntax = styled('h1')`
  font-size: 1.5em;
`);

styled 객체는 함수이자 객체이다. 놀랍겠지만 Javascript에서는 가능하다. 다음과 같이 작성할 수 있다.

function magic() {
  console.log('✨');
}

magic.hands = function() {
  console.log('👋')
}

magic(); // log: '✨'
magic.hands(); // log: '👋'

이러한 새로운 요구사항을 지원하기 위해 styled-components 미니 클론 버전을 업데이트하겠다. 필자는 요구사항을 가능하게 하기 위한 함수형 프로그래밍에서 몇 가지 방법을 차용하고자 한다.

const styled = (Tag) => (styles) => {  return function NewComponent(props) {
    const uniqueName = generateUniqueName(styles);

    createWithInjectCSSClass(uniqueName, styles);

    return <Tag className={uniqueName} {...props} />  }
}

styled.h1 = styled('h1');
styled.button = styled('button');

커링이라고 하는 함수형 프로그래밍의 대표적인 기법이다. Tag 매개변수를 preload 할 수 있게 되었다.

만약 이 글을 읽고 있는 독자가 커링을 처음 본다면 커링은 이해하기 어려울 수 있다. 그러나 커링을 이용하면 간단하게(?) 함수를 만들 수 있어 유용하다.

// A
styled.h1(`
  color: peachpuff;
`);

// A 와 동일
styled('h1')(`
  color: peachpuff;
`);

커링에 대한 보다 자세한 내용은 여기에서 확인할 수 있다.

변수를 렌더링?

위의 코드에서 함수 매개변수 Tag를 가져와 마치 컴포넌트인 것처럼 <Tag>를 렌더링 한다.

이상할 수 있다. 필자는 Tag 컴포넌트를 정의하지 않았다. Tag는 문자열을 포함하는 변수인데 말이다. 실수가 아닐까?

React에서는 Tag와 같은 PascalCase 이름은 사용자 지정 컴포넌트, button과 같은 소문자 이름은 HTML Element라는 것을 전제 조건으로 한다. 하지만 그리 간단하지 않다.

아래는 겉보기에 문제 있어 보이지만 크게 문제 되지 않는 예제이다.

► 더보기(클릭)
function App() {
  const Tag = 'button';

  return <Tag>Hello</Tag>
}

render(<App />)

여기서 JSX가 Javascript로 컴파일 되는 과정을 살펴보면 도움이 될 것이다. 다음은 Tag가 예시 코드에서 변형되는 과정이다.

const Tag = 'button';

React.createElement(Tag, {}, "Hello");

Tag는 변수이므로 다음과 같이 문자열로 재해석 된다.

React.createElement('button', {}, "Hello");

더 정확하게는 PascalCase는 변수와 같이 취급되는 반면 소문자 이름은 문자열로 취급된다.

<Button> // React.createElement(Button);
<button> // React.createElement('button');

const Tag = 'button';
<Tag> // React.createElement(Tag);
<tag> // React.createElement('tag');

Custom components 래핑

styled-components의 장점 중 하나는 커스텀 컴포넌트와 일반 컴포넌트가 혼합 가능하다는 것이다. 다음을 살펴보자.

언뜻 보기엔 신기한 경우처럼 보인다. 이러한 styled 커스텀 컴포넌트에 어떻게 적용하고 있을까?

핵심은 props 위임에 있다. 다음은 delegated 개체를 로그로 확인했을때 표시되는 내용이다.

function Message({ children, ...delegated }) {
  console.log(delegated);
  // { className: 'OwjivF' }
  
  return (
    <p {...delegated}>
      받은 메세지: {children}
    </p>
  );
}

className의 출처가 궁금할 것이다. 정답은 styled 헬퍼 함수 내에 있다. 바로 작성해 보자

const styled = (Tag) => (styles) => {
  return function NewComponent(props) {
    const uniqueName = generateUniqueName(styles);
    
    createWithInjectCSSClass(uniqueName, styles);
    
    return <Tag className={uniqueName} {...props} />  }
}

동작 순서는 다음과 같다.

  1. Message 컴포넌트를 구성하는 styled-components인 UrgentMessage를 렌더링 한다.
  2. className(OwjivF)을 생성하고, Tag 변수(Message component)를 className prop으로 렌더링 한다.
  3. Message 컴포넌트가 렌더링 되어 className prop을 <p> element 에 전달한다.

이 방법은 className prop을 컴포넌트 내의 HTML 노드에 적용하는 경우에만 동작한다. 다음은 예시는 동작하지 않는다.

function Message({ children }) {
  /*
    className prop을 무시하고 있기 때문에 스타일은 설정되지 않는다.
  */
  return (
    <p>
      받은 메세지: {children}
    </p>
  );
}

Message가 수신하는 모든 prop을 렌더링 하는 <p> element에 위임하여 이 패턴을 사용할 수 있도록 한다. 다행히도, 많은 서드파티 컴포넌트들(eg: react-router의 Link 컴포넌트)은 이 규칙을 따른다.

Composing styled-components

자체 컴포넌트를 래핑 하는 것 외에도 styled-components를 함께 구성할 수도 있다.

예를 들어

필자는 styeld-components가 서로 다른 스타일을 알아서 병합하여 스타일 코드를 포함하는 새로운 “만능 클래스”를 생성한다 생각했다. 그러나 실제로는 두 개의 별개의 클래스를 생성한다.

생성된 HTML/CSS는 다음과 같을 것이다.

<style>
.abc123 {
  background-color: transparent;
  font-size: 2rem;
}

.def456 {
  background-color: pink;
}

</style>

<button class="abc123 def456">Hello World</button>

이번 단계 목표는 PinkButton의 스타일이 Button의 스타일을 extends 하도록 하는 것이다. 충돌이 발생하면 PinkButton이 우선시 되어야 한다.

필자는 Javscript에서 복잡한 작업을 수행하는 대신 CSS를 사용하여 복잡한 작업을 위임하였다.

CSS에는 충돌을 해결하는 방법을 제어하는 규칙 레이어가 있다. ID선택자(‘#btn’)는 클래스 선택자(‘.btn’) 보다 우선순위가 높다.

하지만 필자가 작성한 예시의 경우에는 두 개의 클래스가 있고, 두 선택자 .abc123.def456은 동일한 규칙을 따른다. 따라서 알고리즘은 2번째 규칙으로 되돌아간다.

스타일시트에 정의된 순서를 확인해보면 .def456.abc123 뒤에 정의가 되었기 때문에 더 높은 우선순위를 따른다. 필자의 버튼은 분홍색이 될 것이다.

중점: 클래스를 적용하는 순서는 중요하지 않다. 다음 경우를 고려해보자

<style>
.red {
  color: red;
}
.blue {
  color: blue;
}
</style>

<p class="blue red">Hello</p>
<p class="red blue">Hello</p>

첫 번째 단락(paragraph)은 빨간색이고 두 번째 단락은 파란색 일 것으로 예측된다. 아쉽게도 이는 잘못된 예측이다. 두 단락 모두 파란색이다. class 어트리뷰트의 classes 순서는 중요하지 않다.

styled-components 라이브러리는 CSS 규칙이 올바른 순서로 삽입되어 스타일이 개발자가 의도한 대로 동작하도록 노력했다. 이것은 쉬운 문제가 아니다. 우리는 React에서 다양한 동적 작업을 수행하고 라이브러리는 합리적인 순서를 유지하면서 클래스를 지속적으로 추가/제거하고 있다.

본론으로 돌아가서 styled-components 클론 작업을 계속해 보자.

필자는 두 클래스를 모두 적용하도록 코드를 업데이트하고자 한다. 다음과 같이 병합을 시도할 수 있다.

const styled = (Tag) => (styles) => {
  return function NewComponent(props) {
    const uniqueName = generateUniqueName(styles);
    const createdStyles = createStylesThroughStylis(styles);
    
    createWithInjectCSSClass(uniqueName, createdStyles);

    const combinedClasses = [uniqueName, props.className].join(' ');              return <Tag {...props} className={combinedClasses} />  }
}

PinkButton을 렌더링 할 때 Tag 변수는 Button 컴포넌트와 동일하다. PinkButton은 유니크한 클래스명(def456)을 생성하고 이를 className prop으로 Button에 전달한다.

이 과정이 헷갈릴 수 있다. 하지만 전혀 문제 되지 않는다. styled-components가 styled-components를 렌더링 하는 재귀하고 있으며 러닝커브에 도달했다. 필자 역시 본 글을 쓰면서 이 부분이 헷갈렸고, 스스로 생각을 정리하는 시간을 필요로 했다.

하지만 중요한 것은, 이것을 수행하는 정확한 Javascript 매커니즘은 중요하지 않다는 것이다. 아래는 구현 세부 사항이다. 다음 사항을 이해하고 있는 것이 더 중요하다.

  • PinkButton을 렌더링 할 때 Button로 렌더링 된다.
  • 각 styled-components는 abc123 또는 def123과 같은 유니크한 클래스를 생성한다.
  • 모든 클래스가 기본 DOM 노드에 적용된다.
  • styled-components는 이러한 규칙을 올바른 순서로 주입하여 PinkButton의 스타일이 Button의 스타일을 오버라이딩하도록 한다.

Interpolated styles

최소한으로 실행 가능한 styled-components클론을 거의 완료했지만 한 가지 빼먹은 내용이 있다. 바로 Interpolated styles이다. 다소 어려울 수 있지만 여정의 끝이 보인다.

interpolation는 번역하면 ‘보간’ 이라는 뜻이다. 필자에게도 꽤 낯선 단어이기에 영문 그대로 사용하고자 한다. 때로는 한글보다 원어가 직관적일 때가 있다.

필자는 간간이 CSS는 React props에 의존한다. 예를 들어 아래 이미지는 maxWidth props를 사용한다.

이 이미지를 렌더링 한 후의 DOM은 다음과 같다.

<style>
  .JDMLk {
    display: block;
    margin-bottom: 8px;
    width: 100%;
    max-width: 200px;
  }
  .oXyedZ {
    display: block;
    margin-bottom: 8px;
    width: 100%;
  }
</style>

<img
  alt="해맑게 웃고 있는 누렁이"
  src="https://images.unsplash.com/photo-1543466835-00a7907e9de1"
  class="sc-bdzxRM JDMLk"
/>
<img
  alt="해맑게 웃고 있는 누렁이"
  src="https://images.unsplash.com/photo-1543466835-00a7907e9de1"
  class="sc-bdzxRM oXyedZ"
/>

첫 번째 클래스인 bdzxRM는 렌더링 된 React Components(ContentImage)를 유니크하게 식별하는 데 사용된다. 이는 어떤 스타일도 제공하지 않으며, 목적에 따라 무시할 수 있다.

흥미로운 점은 각 이미지에 완전히 다른 고유 클래스가 부여된다는 것이다! (필자는 굉장히 신기했다.)

겉보기에 무작위로 생성된 것 같은 클래스 이름 JDMLkoXyedZ는 실제로 적용될 스타일의 해시값이다. 다른 prop에서 interpolation 하면 다른 maxWidth 스타일 세트를 얻을 수 있으므로 고유한 클래스가 생성된다.

이때문에 클래스를 pre-generate 할 수 없는 것이다. 동일한 style.img 인스턴스가 항상 동일한 스타일을 생성하지는 않기 때문에 어떤 CSS가 적용될지 알기 전에 컴포넌트가 렌더링될 때까지 가다려야한다.

interpolation은 하나의 특정 컴포넌트 인스턴스의 스타일을 커스텀 할 수 있는 유일한 방법은 아니다. 개인적으로 가장 좋아하는 방벙븐 CSS 변수를 사용하는 것이다. 다음과 같다.

HTML을 inspect 해보면 두 컴포넌트가 동일한 CSS 클래스를 공유한다는 것을 알 수 있다.

<style>
  .JDMLk {
    display: block;
    margin-bottom: 8px;
    width: 100%;
    max-width: var(--max-width);
  }
</style>

<img
  alt="해맑게 웃고 있는 누렁이"
  src="https://images.unsplash.com/photo-1543466835-00a7907e9de1"
  class="sc-bdzxRM JDMLk"
/>
<img
  alt="해맑게 웃고 있는 누렁이"
  src="https://images.unsplash.com/photo-1543466835-00a7907e9de1"
  class="sc-bdzxRM oXyedZ"
/>

현대 CSS는 개발자들을 위해 동적인 일을 하도록 함으로써, 우리는 적은 CSS 코드를 생성한다. 이것 역시 잠재적인 성능 향상으로 볼 수 있다. 동적 데이터가 변경될 때 완전히 새로운 CSS가 생성하여 페이지에 추가하지 않아도 된다.

styled-components는 최적화가 잘 되어 있는 라이브러리이기 때문에 대부분의 상황에서 유의미한 차이를 만들지 않을 것이다.

컴포넌트 기록 수정

앞서 필자는 이 두 가지를 동일하게 볼 수 있다고 언급했던 바가 있다.

const Title = styled.h1`
  font-size: 1.5em;
  text-align: center;
  color: palevioletred;
`;
const Title = styled.h1(`
  font-size: 1.5em;
  text-align: center;
  color: palevioletred;
`);

하지만 interpolation 기능이 추가되면 더 이상 동등하지 않다.

태그가 지정된 템플릿 리터럴은 작동 방식이 복잡하여 본 글에서 작업을 설명하려면 완전히 별도의 글이 필요하다.

여기서 알아야 할 중요한 점은 이러한 작은 interpolation 함수가 모두 컴포넌트가 렌더링 될 때 호출되고 스타일 문자열의 내용을 채우는데 사용된다는 것이다.

다음은 간단한 예시 코드이다.

const styled = (Tag) => (rawStyles, ...interpolations) => {
  return function NewComponent(props) {
    // 템플릿 문자열, interpolation 함수에 제공된 prop 으로 스타일을 계산한다.
    const styles = reconcileStyles(
      rawStyles,
      interpolations,
      props
    )

    const uniqueName = generateUniqueName(styles);
    const createdStyles = createStylesThroughStylis(styles);
    createWithInjectCSSClass(uniqueName, createdStyles);
    const combinedClasses =
      [uniqueName, props.className].join(' ');
    return <Tag {...props} className={combinedClasses} />
  }
}

컴포넌트를 렌더링 할 때 reconcileStyles은 props를 통해 전달된 데이터로 interpolation된 각 함수를 호출할 수 있다. 결국에 채워진 값이 있는 스타일 문자열만 남는다.

props가 변경되면 프로세스가 반복되고 새로운 CSS 클래스가 생성된다.

마무리

본 글은 꽤 많은 내용을 다루고 있다. 아마도 필자가 작성한 다른 글보다 러닝커브가 있는 내용을 다루기에 필자 역시 모든 것을 이해하기 쉽지 않다.

특히 이 글은 독자에게 큰 깨달음이 없을 수도 있다. 다만 styled-components를 사용한다면 언젠가 글의 내용이 도움이 되길 바란다.

본 글은 도구를 이해함으로써 얻을 수 있는 실질적인 이점 외에도 styled-components 팀의 챌린지에 공감하는 것에 의미를 두고 있다.

글의 종지부에 도착한 독자에게 경의를 표하며 마치겠다.