CSS가 우리에게 닿기까지

Doeunnkimm·2024년 6월 17일
4

React

목록 보기
5/5
post-thumbnail

이번 글에서는 다양한 CSS를 작성하는 방식을 살펴보며, 번들부터 렌더링까지 어떠한 과정이 일어나는지를 자세히 알아보려고 합니다.

최근 프로젝트를 새로 시작하게 되었어요. 스타일링 라이브러리를 선택하고 또 그것을 어떻게 사용할 것인지를 정하다 보니 제대로 공부해봐야겠다는 마음이 들어 작성해보려고 합니다 🙏🏽

브라우저에게 스타일이란

CSS를 파싱해서 CSSOM(CSS Object Model) 트리로 파싱을 한 후, DOM과 결합하여 페인트하는 것은 잘 알려진 브라우저 렌더링 과정의 일부인데요.

브라우저는 어떻게 스타일 속성을 알고 적용할 수 있는걸까요?

CSS는 W3C에서 정의한 표준입니다. 브라우저의 렌더링 엔진이 CSS 표준을 기반으로 동작합니다. CSS 명세를 구현하여 스타일 속성을 해석하고 적용하는 기능을 가지고 있어 이러한 명세를 기반으로 동작하여 스타일링을 처리합니다.

css module

*.(module).css 파일을 생성하고, CSS를 정적으로 분석하여 별도의 CSS파일로 추출하는 방식

코드로 미리보면 아래와 같습니다.

/* styles.module.css */
.button {
  background-color: blue;
  color: white;
}
// Button.js
import React from 'react';
import styles from './styles.module.css';

const Button = () => {
  return <button className={styles.button}>BUTTON</button>
};

export default Button;

css module를 사용하면, 컴포넌트 기반 스타일링이 가능해서 클래스명 충돌을 방지하고 스타일을 모듈화할 수 있어요. 번들링 과정에서 모듈 간 동일한 className을 사용하더라도 충돌을 방지하게 위해 고유한 클래스 이름으로 변환됩니다.

브라우저에서 렌더링 되기 위해서는 아래 JSX를 실제 DOM 노드로 변환합니다. (위에서 말한 클래스 이름 변환 포함) 이제 이걸로 브라우저에 렌더링하게 되는 것이죠!

const Button = () => {
  return <button className={styles.button}>BUTTON</button>
}

렌더링을 했으니 스타일을 적용해야 하는데, 스타일은 어디서 가져와 적용하는걸까요?

번들 과정에서 번들러는 CSS를 처리하는 로더를 통해 <head> 태그 안에 <style> 태그를 삽입하게 됩니다.

이제 유니크한 className을 통해 스타일 속성값들을 찾고 DOM에 적용합니다.

정리해보자면 아래와 같습니다.

  1. 브라우저에서 사용자 URL을 입력하고 접속하면 웹 서버에서 HTML 파일을 브라우저에 내려준다.
  2. 브라우저가 HTML 파일을 파싱한다. 파싱하면서 DOM을 생성한다.
  3. <link> 태그를 만나면 외부 리소스를 다운로드한다.
  4. css 외부 리소스를 다운로드하면 css 파일로 CSSOM을 생성한다.
  5. <script> 태그를 만나면 JS 파일을 다운로드한다.
  6. DOM + CSSOM을 합쳐 Render Tree를 만든다.
  7. Element의 위치와 간격을 계산하는 Layout 과정이 일어난다.
  8. 레이어 별로 실제 그리기 작업을 수행하는 Paint 과정이 일어난다.

위와 같은 방식은 CSS를 정적으로 분석하여 별도의 CSS 파일로 추출하는 방식입니다. 그러므로 동적으로 스타일을 적용하기 위해서는 inline으로 조건문을 통해 적용하는 방식이 되어야 했습니다 😨

css-in-js

css module과의 이야기

css-in-js는 자바스크립트 내에서 CSS를 작성하는 방식을 의미합니다.

css module의 마지막에 언급했던, 동적인 스타일링의 어려움을 극복하고자 런타임 개념을 도입하여 해결했어요.

prop가 변할 때마다 동적으로 생성하여 JS 코드를 동적인 스타일링이 가능해요. 즉, 빌드 타임에서 모든 스타일을 생성하는 것이 아닌, 런타임을 활용한 것입니다.

런타임에 스타일을 생성하는 방식은 대부분 문제가 없지만, 스타일 계산 비용이 커지기 때문에 스타일이 복잡한 컴포넌트에서는 차이가 발생한다고 합니다.

👇 카카오웹툰에서 비교한 css module vs css-in-js 성능

Scriping은 2배 가까운 성능 차이를 보였는데, CSS-in-JS는 당연하게도 JS를 CSS로 변환하는 과정이 필요하기 때문이였어요. 빌드 타임에 모든 CSS가 만들어지는 것이 아니라 동적으로 추가되는 과정이 필요하기 때문에 아무래도 느릴 수 밖에 없는 것이죠.

결론적으로, css-in-js는 런타임 동적 스타일 생성이 필요하기 때문에 성능적인 차이가 발생할 수 있다.

번들과 렌더

런타임에 동적으로 스타일을 생성해야 하는 경우에 대해서?

최근 emotion을 자주 사용하고 있어서, emotion을 기준으로 css-in-js가 번들링 되고 렌더링 되는 과정을 살펴보려고 해요.

간단하게 다음과 같이 런타임에 동적으로 스타일링 해야 하는 간단한 코드가 있다고 해봅시다.

// styles.ts
import { css } from '@emotion/react';

export const buttonCss = (color: string) =>
  css({
    backgroundColor: color,
  });

// Button.tsx
import { buttonCss } from './styles';

export const Button = (props: ButtonProps) => {
  const { color } = props;

  return <button css={buttonCss(color)}>BUTTON</button>;
};

buttonCss 함수는 호출될 때마다 동적으로 className을 생성하고, emotion은 이를 <style> 태그에 삽입합니다.

Button 컴포넌트는 buttonCss 함수를 호출하여 동적으로 className을 생성하여 필요한 스타일을 적용합니다.

동적 스타일의 불가피함

같이 있는 정적인 스타일 속성들까지 모두 다시 로드 필요

css 함수가 호출될 때마다 고유한 className을 생성하여 스타일을 적용한다고 했습니다.

보통은 css 함수 내에서 일부는 정적으로, 일부는 동적으로 사용하는 경우가 많아 다음과 같이 극단적인 상황이 떠올랐는데요.

const dynamicStyle = (color) => css({
	color: ${color};
  	// ... 100줄의 정적인 스타일 속성들
  	backgroundColor: white;
})

위 코드에서 color 값이 바뀔 때마다 전체 스타일 블록이 다시 생성되므로, 모든 스타일 속성이 다시 로드되게 됩니다.

만약 위와 같이 극단적인 상황에서 개선을 해봅다면 아래와 같을 것 같아요.

export const MyComponent = ({ color }) => {
  return (
    <div css={[staticStyle, dynamicColorStyle(color)]}>
      Hello, Emotion!
    </div>
  );
}

동적인 부분만 다시 생성되고, 정적인 부분은 캐싱된 스타일을 재사용할 수 있도록 동적으로 변경되는 color 속성을 분리해서 결합하는 형태로 주입할 수 있겠습니다.

복잡한 스타일 계산에서 발생하는 Runtime overhead

자주 변경되는 스타일을 브라우저가 지속적으로 CSS 속성을 재계산하고 DOM 요소에 적용해야 합니다.

동적 스타일링은 실행 중에 JS에 의해 계산되고 적용이 되는데, 이는 상태 변경, 사용자 입력, API 호출 등의 이벤트에 따라 스타일이 변경될 수 있음을 의미해요. 상태 변화가 있을 때마다 스타일을 다시 계산하고 적용해야 함을 의미하기도 합니다.

이러한 문제를 해결하기 위해 zero-runtime을 주장하는 라이브러리들이 등장하게 됩니다.

🤔 빌드 타임에 동적인 스타일링 한계로 런타임을 도입했는데, 런타임 없이?

zero-runtime css-in-js

zero-runtime = 런타임에 동작 X, = 동적으로 스타일 생성 X

빌드 타임에 미리 css를 생성해두는 방식으로, 런타임 이전에 미리 필요한 css를 빌드 시점에 생성하여 제공하는 형식입니다.

따라서 동적 스타일링은 사전에 정의한 스타일의 조합을 기반으로만 가능합니다. 결론부터 말해보자면 일반적으로 css variable를 이용해 해결합니다.

👆 위 말의 의미가 와닿진 않아 대표적인 라이브러리 하나를 잡고 빌드 결과를 살펴보려고 합니다.

linaria

https://linaria.dev/

zero-runtime 스타일링 라이브러리 라이브러리 중 linaria를 기준으로 살펴보려고 합니다.

import { styled } from '@linaria/react';

const backgroundByType = {
  default: 'grey',
  primary: 'blue',
  danger: 'red',
};

export const Button = styled.button<ButtonProps>`
  background-color: ${({ type }) => backgroundByType[type]};
`;

위와 같은 코드가 빌드되면 아래와 같아져요.

.Button {
  background-color: var(--background-color);
}

export const Button = ({ type, ...props }) => (
  <button
    className={styles.Button}
    style={{ '--background-color': backgroundByType[type] }}
    {...props}
  />
);

동적 스타일을 추출하여 정적 CSS로 변환하게 되는데요. 스타일 시트를 새로 만들지 않고 css variable을 빌드 타임에 미리 생성해두고, 이를 상황에 맞게 적용하는 방식을 사용합니다.

스타일을 변경할 때 자세히 보게되면, css variable명은 그대로이지만 그 값만 바꾸면서 스타일을 적용하고 있습니다.

🤔 css variable을 어떻게 뭘 생성?

동적 스타일을 추출하여 아래와 같이 정적 CSS로 변환하게 된다고 했는데요.

css variable을 --root에서 선언해 사용하는 것이 아닌 스타일을 적용한 컴포넌트 스콥 안에서 css variable을 선언해 사용합니다. 덕분에 css variable을 사용하지만, 컴포넌트 별로 스타일 적용할 수 있는 것이죠.

🤔 빌드 타임에 미리 생성한다는 것?

빌드될 때 이미 변수들이 정의되어 있는 상태를 의미할 수 있습니다.

예를 들어 React 코드에서 prop에 따른 css variable을 객체로 정의하고 이를 매핑해 스타일을 적용하게 되면, 런타임에 스타일 시트를 새로 생성하지 않고도 css varibale만 수정하여 달라지는 조건에 대해 스타일을 다르게 줄 수 있는 것입니다.

이 방법을 사용하면 런타임에 새로운 스타일 시트를 생성할 필요 없이, 미리 정의된 css variable만 수정하여 다양한 스타일을 적용할 수 있습니다.

🤔 css variable을 수정하는 것도 zero-runtime?

보통은 런타임에 상태에 따라 css variable을 수정해야 할 것으로, 이게 정녕 zero-runtime이 맞나 의심이 들었어요.

zero-runtime이라고 소개하는 라이브러리들은 런타임에 JS를 실행하지 않지만, css varaible을 런타임에 수정해야 하는 것은 JS를 사용합니다.

🤔 zero-runtime이 그래서 더 좋긴 하다는건가?

zero-runtime 라이브러리는 컴파일 타임에 css를 추출하므로, 런타임에 JS가 스타일을 계산하고 적용하는 과정을 생략할 수 있습니다.

또, 동적으로 css variable을 수정한다고 해도 이미 존재하는 css variable의 값을 변경하는 것이므로 스타일 시트를 재로드하는 것이 아닌 해당 변수의 값만을 업데이트하고 해당 값을 참조하는 스타일만을 재계산합니다.

반면 런타임에 JS를 사용하여 동적으로 생성하고 관리하는 css-in-js는 아래와 같이 동적인 변경이 필요할 때마다 className을 재생성합니다. 이말은 곧, 새로운 스타일 규칙을 생성하고 이를 DOM에 추가하는 과정이 반복됨을 의미합니다.

emotion에 zero-runtime 도입해보기

linaria를 실제로 install해서 사용해보니, 평소에 emotion에서 자주 쓰던 기능들을 못 쓴다는 것과 linaria를 사용하기 위해서는 컴파일 시점에 css를 추출하기 위한 추가적인 설정이 필요하는 등 불편함이 존재하더라구요.

그래서 평소에 잘 쓰는 emotionzero-runtime 개념을 도입하여 css-in-js의 런타임 오버헤드를 개선해보고자 합니다.

prop에 따라 css varaible 값을 결정하는 객체를 매핑하여 스타일을 적용했더니 아래와 같이 className 재생성 없이 linaria와 비슷하게 동작할 수 있었습니다.

마무리

이번 글에서는 다양한 스타일링 방식에 대해 살펴보았습니다.

동적인 스타일링은 거의 필수적으로 필요한 부분이므로 완벽한 zero-runtime은 불가능하겠지만, css variable을 통해 css-in-js에서의 문제점이였던 className을 매번 생성하여 DOM에 반영하는 불편함(?)을 해소할 수 있음을 알 수 있었어요.

실제로 이 글을 작성하면서 linaria를 사용해보았는데, 몇 가지 불편함이 있었어요. 추가적인 config가 필요했다는 점과 문제 해결 관련해서 정보를 얻기 힘들다는 점이였는데요 🤔 emotion이나 styled-components에 비해서 말이죠 😂

이미 css-in-js로 프로젝트를 진행하고 있거나, 익숙한 API로 스타일링을 원하시는 분들은 css variable을 통해 부분적으로 반 zero-runtime 개념을 도입해보는 것도 성능 개선에 도움이 될 것 같습니다. 👍🏽

Reference

profile
개발자와 사용자 모두의 눈👀을 즐겁게 하는 개발자가 되고 싶어요 :) 👩🏻‍💻

2개의 댓글

comment-user-thumbnail
2024년 6월 18일

좋은 글 잘보고 갑니다 ! 😄

1개의 답글