styled-components가 런타임에 css를 주입하는 방법

creatrip-seoyeon
creatrip
Published in
34 min readMay 8, 2024

안녕하세요. 크리에이트립(Creatrip)에서 프론트엔드 개발을 하는 김서연입니다!

styled-components 는 프론트엔드 개발자들에게는 너무나 익숙한 라이브러리일 텐데요. 이번 글을 통해 스스로 정리해본 해당 라이브러리의 동작 원리에 대해 공유해보려고 합니다.

styled-components 의 런타임 오버헤드..?

크리에이트립에서는 스타일링을 위한 라이브러리로 가장 대표적인 CSS-in-JS 라이브러리 중 하나인 styled-components 를 사용하고 있습니다. 그러던 중 파트리더분이 최근 성능 개선을 위해 크리이트립의 페이지 퍼포먼스를 분석하며 메인 스레드의 병목이 되는 구간들을 점검하는 시간을 가지고 계셨었는데요, 저희가 잘 사용해오던 styled-components 의 런타임 css 주입을 위한 메소드 실행이 스레드의 많은 부분을 점유하고 있다는 사실을 공유해주셨습니다.

이전까지 저는 styled-components 가 런타임에 스타일을 주입한다는 사실은 알고 있었지만 구체적인 내부 동작 과정은 정확히 모르고 있었습니다. 위 계기로 도대체 어떤 로직들이 실행되기에 전체 하이드레이션 과정에서 약 8% 정도의 점유율을 차지하는지가 궁금해졌고, 이번 블로그 글을 통해 그 동작 과정을 정리해보고자 합니다.

styled-components v6.1.8 을 기준으로 작성했습니다.

1. styled 함수 호출 및 스타일링 컴포넌트 생성

1.1. styled

아래 코드는 styled-components 라이브러리 내에서 styled함수를 초기화하고 모든 HTML 엘리먼트에 대한 스타일링 함수를 제공하는 방식을 구현하는 부분입니다. 이를 통해 개발자는 HTML 태그나 커스텀 컴포넌트에 스타일을 쉽게 적용할 수 있게 됩니다.

// https://github.com/styled-components/styled-components/blob/main/packages/styled-components/src/constructors/styled.tsx

import createStyledComponent from '../models/StyledComponent';
import { WebTarget } from '../types';
import domElements, { SupportedHTMLElements } from '../utils/domElements';
import constructWithOptions, { Styled } from './constructWithOptions';

const baseStyled = <Target extends WebTarget>(tag: Target) =>
constructWithOptions<'web', Target>(createStyledComponent, tag);

// A.
const styled = baseStyled as typeof baseStyled & {
[E in SupportedHTMLElements]: Styled<'web', E, JSX.IntrinsicElements[E]>;
};

// B. Shorthands for all valid HTML Elements
domElements.forEach(domElement => {
// @ts-expect-error some react typing bs
styled[domElement] = baseStyled<typeof domElement>(domElement);
});


export default styled;

A. 앱에서 라이브러리를 호출하면 styled 고차 함수에 할당된 baseStyled함수가 생성됩니다. 이 함수는 createStyledComponent함수와 사용자로부터 입력 받은 HTML 태그 이름 또는 React 엘리먼트를 constructWithOptions 함수의 인자로 전달해, 스타일링이 적용된 컴포넌트를 생성할 수 있는 방법을 제공합니다.

B. domElements 배열을 순회하면서 각 HTML 엘리먼트에 대한 styled 함수를 정의 합니다. 이는 styled.div styled.a 와 같이 tagged template literal 문법을 사용해서 편리한 방법으로 스타일을 정의하는 메소드를 사용할 수 있는 방법을 제공합니다.

1.2. constructWithOptions

// https://github.com/styled-components/styled-components/blob/main/packages/styled-components/src/constructors/constructWithOptions.ts#L84

export default function constructWithOptions(
componentConstructor: IStyledComponentFactory<R, StyledTarget<R>, object, any>,
tag: StyledTarget<R>,
options: StyledOptions<R, OuterProps> = EMPTY_OBJECT){

// ... 생략

const templateFunction = <Props extends object = BaseObject, Statics extends object = BaseObject>(
initialStyles: Styles<Substitute<OuterProps, Props>>,
...interpolations: Interpolation<Substitute<OuterProps, Props>>[]
) =>
componentConstructor<Substitute<OuterProps, Props>, Statics>(
tag,
options as StyledOptions<R, Substitute<OuterProps, Props>>,
// css 함수는 스타일 문자열과 인터폴레시연 (변수나 함수 등)을 결합하여 최종 CSS 문자열을 생성합니다.
css<Substitute<OuterProps, Props>>(initialStyles, ...interpolations)
);

// ... 생략

return templateFunction;
}

constructWithOptions 함수는 componentConstructor, tag, options 를 인자로 받아 실제 스타일링된 컴포넌트를 생성하는 함수를 반환합니다. 이때 사용자가 정의한 스타일(initialStyles)과 인터폴레이션(interpolations)을 받아 componentCounstructor를 통해 컴포넌트를 생성하고 필요한 옵션과 스타일 속성들을 함께 전달합니다. componentCounstructor로는 위에서 봤던 createStyledComponent함수가 주입될거라는것을 예상할 수 있습니다.

변수명에서도 언급된김에 조금 더 수월한 이해를 위해서 인터폴레이션(interpolation) 이란 개념에 대해 잠시 짚고 넘어가도록 하겠습니다.

인터폴레이션(Interpolation)은 템플릿 리터럴 내에서 변수나 표현식의 값을 문자열 중간에 삽입하는 기법을 의미합니다. ES6(ES2015) 이상에서는 백틱(`)을 사용한 템플릿 리터럴에서 이 기능을 널리 사용합니다. 인터폴레이션을 통해 탬플릿 리터럴 내에서 직접 JS 변수나 표현식을 스타일 선언에 삽입할 수 있습니다.

const person = "Mike";
const age = 28;

function myTag(strings, personExp, ageExp) {
const str0 = strings[0]; // "That "
const str1 = strings[1]; // " is a "
const str2 = strings[2]; // "."
const ageStr = ageExp < 100 ? "youngster" : "centenarian";
// We can even return a string built using a template literal
return `${str0}${personExp}${str1}${ageStr}${str2}`;
}

// interpolation (person, age)
const output = myTag`That ${person} is a ${age}.`;

console.log(output);
// That Mike is a youngster.
import styled from 'styled-components';

const Button = styled.button`
padding: 10px 15px;
background-color: ${props => props.primary ? 'blue' : 'gray'};
`;

여기서 ${props => props.primary ? 'blue' : 'gray'} 부분이 인터폴레이션의 사용 예입니다. 이를 통해 Button 컴포넌트의 primary prop에 따라 배경색을 동적으로 결정합니다.

css<Substitute<OuterProps, Props>>(initialStyles, ...interpolations)

componentConstructor의 세번째 인자로 css 메소드 실행의 결과값을 넘기고 있는데요, 이 함수는 스타일 규칙(rules)들을 생성하는데 사용되며 여러 종류의 입력을 받아 처리할 수 있는 함수입니다. 문자열 뿐만이 아니라, 객체나 인터폴레이션된 함수 등 등 다양한 형태의 입력을 받아 최종적으로 배열로 가공해 반환합니다. (반환된 리스트는 이후에 최종적으로 스타일을 주입하는 과정에서 CSS 문자열을 생성하는 부분에서 사용됩니다.)

1.3. createStyledComponent

constructWithOptions의 첫번째 인자로 전달된 createStyledComponent 함수는 스타일링된 컴포넌트를 생성하고, 스타일을 적용하는 역할을 수행합니다.

// https://github.com/styled-components/styled-components/blob/main/packages/styled-components/src/models/StyledComponent.ts#L196

function createStyledComponent<
Target extends WebTarget,
OuterProps extends object,
Statics extends object = BaseObject,
>(
target: Target,
options: StyledOptions<'web', OuterProps>,
rules: RuleSet<OuterProps>
// css() 호출로 반환된 결과 잆니다. 동적 스타일 적용을 위한 함수를 포함하고 있습니다.
): ReturnType<IStyledComponentFactory<'web', Target, OuterProps, Statics>> {
...

// A
const {
attrs = EMPTY_ARRAY,
componentId = generateId(options.displayName, options.parentComponentId),
displayName = generateDisplayName(target),
} = options;

// ... 생략

// B
const componentStyle = new ComponentStyle(
rules,
styledComponentId,
isTargetStyledComp ? (styledComponentTarget.componentStyle as ComponentStyle) : undefined
);

// C
function forwardRefRender(props: ExecutionProps & OuterProps, ref: Ref<Element>) {
return useStyledComponentImpl<OuterProps>(WrappedStyledComponent, props, ref);
}

// ... 생략

let WrappedStyledComponent = React.forwardRef(forwardRefRender) as unknown as IStyledComponent<
'web',
any
> &
Statics;
WrappedStyledComponent.attrs = finalAttrs;
WrappedStyledComponent.componentStyle = componentStyle;
WrappedStyledComponent.displayName = displayName;

// ... 생략
}

A. 인자로 받은 options을 통해 스타일링에 필요한 컴포넌트들의 속성들을 생성하는 과정입니다. generateId 함수를 호출해 컴포넌트의 고유 해시 ID를 생성합니다. generateDisplayName 함수를 호출해 리액트 개발자 도구에서 보여질 컴포넌트 이름을 생성합니다.

B. ComponentStyle 인스턴스는 컴포넌트에 적용된 모든 스타일 규칙을 관리합니다. 이 인스턴스는 스타일 규칙을 반으로 css 문자열을 만들어 실제 DOM 요소에 반영하는 메소드를 포함하고 있습니다.

C. forwardRefRender 함수는 컴포넌트의 props와 함께 ref를 받아 스타일링 생성 및 주입 로직을 담당하는 훅(useStyledComponentImpl)을 사용해 스타일링된 리액트 컴포넌트를 반환합니다. (여기서 styled-components 에서는 forwardRef API를 사용해서 외부에서 받은 ref로 내부 요소나, 컴포넌트에 직접 접근하며, 또 사용자에게는 ref를 자식 컴포넌트에 직접 전달할 수 있게끔 구현됐다는 사실을 알 수 있습니다.)

D: WrappedStyledComponent는 React.forwardRef를 통해 생성된 컴포넌트로 ref를 포함해, 위에서 정의된 여러 속성과 메소드 (attr, componentStyle, displayName 등)을 가집니다.

여기까지 styled 함수를 호출 했을 때, 주어진 태그나 컴포넌트를 기반으로 새로운 스타일링된 컴포넌트를 생성하는 전체적인 흐름을 살펴보았습니다. 이제 스타일링 컴포넌트 생성시 호출된 useStyledComponentImpl 함수를 살펴보면서, 최종적으로 스타일링에 필요한 속성들이 동적으로 결정되고 주입되는 과정을 살펴보도록 하겠습니다.

2. 최종 스타일링 속성 결정 및 스타일 주입

2.1. useStyledComponentImpl

useStyledComponentImpl 함수는 스타일링된 컴포넌트에 스타일, 테마, 속성을 전달하고, 스타일 규칙을 동적으로 주입하는 동작을 실행합니다.

// https://github.com/styled-components/styled-components/blob/main/packages/styled-components/src/models/StyledComponent.ts#L113

function useStyledComponentImpl<Props extends object>(
forwardedComponent: IStyledComponent<'web', Props>,
props: ExecutionProps & Props,
forwardedRef: Ref<Element>
) {
const {
attrs: componentAttrs,
componentStyle,
defaultProps,
foldedComponentIds,
styledComponentId,
target,
} = forwardedComponent;

// A.
const contextTheme = React.useContext(ThemeContext);
const ssc = useStyleSheetContext();
const shouldForwardProp = forwardedComponent.shouldForwardProp || ssc.shouldForwardProp;

// B.
const theme = determineTheme(props, contextTheme, defaultProps) || EMPTY_OBJECT;

// C.
const context = resolveContext<Props>(componentAttrs, props, theme);
// C-1.
const elementToBeCreated: WebTarget = context.as || target;
// C-2.
const propsForElement: Dict<any> = {};
for (const key in context) {
if (context[key] === undefined) {
// Omit undefined values from props passed to wrapped element.
// This enables using .attrs() to remove props, for example.
} else if (key[0] === '$' || key === 'as' || key === 'theme') {
// Omit transient props and execution props.
// C-3.
} else if (key === 'forwardedAs') {
propsForElement.as = context.forwardedAs;
// C-4.
} else if (!shouldForwardProp || shouldForwardProp(key, elementToBeCreated)) {
propsForElement[key] = context[key];
...
}
}

// D.
const generatedClassName = useInjectedStyle(componentStyle, context);

let classString = joinStrings(foldedComponentIds, styledComponentId);

if (generatedClassName) {
classString += ' ' + generatedClassName;
}
if (context.className) {
classString += ' ' + context.className;
}


// E
propsForElement[
// handle custom elements which React doesn't properly alias
isTag(elementToBeCreated) &&
!domElements.has(elementToBeCreated as Extract<typeof domElements, string>)
? 'class'
: 'className'
] = classString;

propsForElement.ref = forwardedRef;

return createElement(elementToBeCreated, propsForElement);
}

A. useContext를 통해 현재 테마 컨텍스트와, shouldForwardProp등 최종 props 를 결정하는데 필요한 정보를 가져옵니다.

B. determinTheme 함수를 통해 컴포넌트에 적용될 테마를 결정합니다.

C. resolveContext 함수를 사용해서 컴포넌트의 최종 컨텍스트를 결정합니다. 이는 컴포넌트 속성 (props), 사용자 정의 테마(theme),.attrs()를 통해 정의된 추가적인 속성(componentAttrs)을 결합해서 최종 컨텍스트 객체를 생성하는 역할을 합니다. styled component 에서 제공하는 API가 최종적으로 어떻게 적용되는지에 대한 부분을 포함하고 있는 부분이라 조금더 살펴보겠습니다.

  • C-1. context.as 또는 기본적으로 지정된 target 을 통해 실제로 생성될 HTML 엘리먼트나 React 컴포턴트를 결정합니다. (elementToBeCreated)
  • C-2. 컴포넌트에 전달될 최종 속성 (propsForElement) 를 결정하기 위해 context의 각 속성을 순회하며 정의되지 않은 속성, 임시(transient) 속성, as, theme과 같은 특수 속성을 제외합니다.
  • C-3. fowardedAs 속성이 있을 경우 이를 propsForElement.as 에 할당해서 최종 렌더링될 엘리먼트의 타입을 재지정할 수 있습니다.
  • C-4. showForwardProp 함수를 통해 해당 속성이 실제 엘리먼트나 컴포넌트에 전달될지 여부를 판단합니다.

D. 드디어, 스타일 시트에 사용자가 정의한 스타일이 주입되는 부분이 나왔습니다. useInjectedStyle 함수에서는 componetStyle, context를 받아 스타일 규칙을 동적으로 주입하는 로직을 포함하고 있습니다. (자세한 과정은 이후에 이어서 알아보도록 하겠습니다.) 이때 생성된 고유한 className은 생성된 element 에 추가됩니다.

E. createElement 함수를 사용해서 결정된 요소와 props를 가지고 엘리먼트를 생성해 반환합니다. 이때, ref를 포함한 모든 최종 계산된 속성이 엘리먼트에 바인딩됩니다.

2.2. useInjectedStyle

useInjectedStyle은 컴포넌트의 스타일을 동적으로 생성하고, 스타일 시트에 삽입하는 과정을 처리하는 훅 입니다. ComponentStyle 클래스로 스타일을 관리하는 인스턴스를 생성하는 부분을 위에서 본적이 있으실텐데요.

생성된 인스턴스(componentStyle)와, 앞선 과정들에서 계산된 최종 속성(resolvedAttrs)을 인자로 받아서 generateAndInjectStyles함수를 실행하고, 고유한 클래스 이름을 생성해 반환합니다.

// https://github.com/styled-components/styled-components/blob/main/packages/styled-components/src/models/StyledComponent.ts#L58

function useInjectedStyle<TextendsExecutionContext>(
componentStyle: ComponentStyle,
resolvedAttrs: T
) {
// A.
const ssc = useStyleSheetContext();

// B.
const className = componentStyle.generateAndInjectStyles(
resolvedAttrs,
ssc.styleSheet,
ssc.stylis
);

if(process.env.NODE_ENV !== 'production') useDebugValue(className);
return className;
}

A. useStyleSheetContext 훅을 사용해 스타일 시트 컨텍스트를 가져옵니다. 해당 컨텍스트는 stylesheet 인스턴스와 CSS 전처리기(stylis)를 포함하고 있습니다.

B. componentStyle의 generateAndInjectStyles 메소드를 호출해서 최종 속성과 함께 스타일 시트 및 CSS 전처리기(stylis) 정보를 전달합니다. 이 메소드는 컴포넌트의 최종 스타일을 계산하고, 계산된 스타일을 페이지에 스타일시트에 삽입한 다음, 스타일을 식별하는데 사용될 수 있는 고유한 클래스 이름을 생성해 반환합니다.

지금까지 흐름을 따라오면서 styleSheet 인스턴스에 대한 내용이 나오지 않았기 때문에 추가로 살펴보겠습니다.

StyleSheet 클래스

StyleSheet 클래스는 스타일 시트 관련 주요 로직을 포함하고 있습니다. 스타일 규칙의 삽입, 업데이트, 캐싱에 대한 역할을 담당하고, 클라이언트 사이드 및 서버사이드 렌더링에서 효율적으로 스타일을 적용하기 위한 매커니즘을 제공합니다.

각 메소드별 역할과 동작방법까지 포함하면 글이 너무 길어질 것 같아 소스코드 링크만 첨부하고 넘어가겠습니다.

2.3. ComponentStyle.generateAndInjectStyles

componentStyle인스턴스의 generateAndInjectStyles 함수는 스타일이 정적인지, 동적인지에 따라서 정의된 속성들을 전처리기를 사용해 주어진 스타일 규칙을 css 문자열로 변환하고, 이를 styleSheet 인스턴스에서 제공하는 메소드를 사용해 <script> 태그로 DOM에 주입하는 역할을 수행합니다.

generateAndInjectStyles 함수를 살펴보기에 앞어서 인스턴스를 생성할 때 실행되는 초기화 로직에 대해서 살펴보겠습니다.

// https://github.com/styled-components/styled-components/blob/main/packages/styled-components/src/models/ComponentStyle.ts

const SEED = hash(SC_VERSION);

/**
* ComponentStyle is all the CSS-specific stuff, not the React-specific stuff.
*/
export default class ComponentStyle {
baseHash: number;
baseStyle: ComponentStyle | null | undefined;
componentId: string;
isStatic: boolean;
rules: RuleSet<any>;
staticRulesId: string;

constructor(rules: RuleSet<any>, componentId: string, baseStyle?: ComponentStyle | undefined) {
this.rules = rules;
this.staticRulesId = '';

// A.
this.isStatic =
process.env.NODE_ENV === 'production' &&
(baseStyle === undefined || baseStyle.isStatic) &&
isStaticRules(rules);

// B.
this.componentId = componentId;
this.baseHash = phash(SEED, componentId);
this.baseStyle = baseStyle;

// NOTE: This registers the componentId, which ensures a consistent order
// for this component's styles compared to others
StyleSheet.registerId(componentId);
}

generateAndInjectStyles(
executionContext: ExecutionContext,
styleSheet: StyleSheet,
stylis: Stringifier
): string {
// ... 생략 (아래에서 이어서 계속 설명)
}

A. isStatic 플래그는 컴포넌트의 스타일이 정적인지 동적인지를 나타냅니다. 정적 스타일은 컴포넌트의 props나 상태에 의존하지 않고, 한 번 계산되면 변경되지 않습니다. 이 플래그는 프로덕션 환경에서만 활성화되고 주어진 rulesbaseStyle이 모두 정적일 때 true로 설정됩니다.

  • 동적 스타일이란 ${(p)⇒ p.isActive?: “red”: “black”} 과 같이 런타임의 컴포넌트의 속성에 따라 동적으로 결정되는 값들을 의미합니다.

B. 컴포넌트 ID를 기반으로 고유한 해시값을 계산합니다. 이 해시값은 나중에 동적 스타일을 생성할 때 클래스 이름을 생성하는데 사용됩니다.

C. StyleSheet에 컴포넌트 ID를 등록합니다. 이는 컴포넌트의 스타일이 일관된 순서로 DOM에 주입되도록 보장하는데 도움이 됩니다. 스타일 시트에서는 <style> 태그에 이 규칙들을 삽입하는 역할을 합니다.

위 초기화 과정을 통해 componentStyle 인스턴스는 런타임에 스타일을 동적으로 적용할 수 있는 기반을 마련합니다. 다음으로 사용자가 지정한 스타일들이 실제로 적용될 때 실행되는 generateAndInjectStyles 함수를 살펴보겠습니다.

// https://github.com/styled-components/styled-components/blob/main/packages/styled-components/src/models/ComponentStyle.ts#L39

generateAndInjectStyles(
executionContext: ExecutionContext,
styleSheet: StyleSheet,
stylis: Stringifier
): string {

// A. 기본 스타일 처리
let names = this.baseStyle
? this.baseStyle.generateAndInjectStyles(executionContext, styleSheet, stylis)
: '';

if (this.isStatic && !stylis.hash) {
// B. 정적 스타일 처리

if (this.staticRulesId && styleSheet.hasNameForId(this.componentId, this.staticRulesId)) {
names = joinStrings(names, this.staticRulesId);
} else {
const cssStatic = joinStringArray(
flatten(this.rules, executionContext, styleSheet, stylis) as string[]
);

const name = generateName(phash(this.baseHash, cssStatic) >>> 0);

if (!styleSheet.hasNameForId(this.componentId, name)) {
const cssStaticFormatted = stylis(cssStatic, `.${name}`, undefined, this.componentId);
styleSheet.insertRules(this.componentId, name, cssStaticFormatted);
}

names = joinStrings(names, name);
this.staticRulesId = name;
}
} else {
// C. 동적 스타일 처리

let dynamicHash = phash(this.baseHash, stylis.hash);
let css = '';

for (let i = 0; i < this.rules.length; i++) {
const partRule = this.rules[i];

if (typeof partRule === 'string') {
css += partRule;
if (process.env.NODE_ENV !== 'production') dynamicHash = phash(dynamicHash, partRule);
} else if (partRule) {
const partString = joinStringArray(
flatten(partRule, executionContext, styleSheet, stylis) as string[]
);

// The same value can switch positions in the array, so we include "i" in the hash.
dynamicHash = phash(dynamicHash, partString + i);
css += partString;
}
}

if (css) {
const name = generateName(dynamicHash >>> 0);
if (!styleSheet.hasNameForId(this.componentId, name)) {
styleSheet.insertRules(
this.componentId,
name,
stylis(css, `.${name}`, undefined, this.componentId)
);
}

names = joinStrings(names, name);
}
}

return names;
}
}

A: 기본 스타일 처리. this.baseStyle이 존재하는 경우, 기본 스타일의 generateAndInjectStyles 함수를 호출해서 초기 CSS 클래스 이름을 얻습니다.

  • 기본 스타일이 존재하는 경우는, 아래와 같이 이미 스타일된 컴포넌트를 재정의해서 추가적인 스타일을 적용하는 경우입니다.
const BasicButton = styled.button`   
padding: 10px 20px;
`;

const NewButton = styled(BasicButton)`
background-color: green;
`;

B: 정적 스타일 처리. 이미 처리된 정적 규칙(this.staticRulesId) 이 있고, 해당 ID가 스타일 시트에 이미 존재하는 경우 해당 ID를 className에 추가하고, 이를 반환해 함수를 종료합니다. 한편 새로운 정적 CSS 규칙을 처리하는 경우에는 규칙들을 flatten() 하고 최종 CSS 문자열을 생성한 뒤, 이를 스타일시트에 주입한 후 해당 규칙의 클래스 이름을 반환합니다.

C: 동적 스타일 처리. this.rules 배열을 반복하며 각 규칙을 평가하는 과정을 포함합니다. 이 과정에서 동적인 부분(함수)는 executionContext 를 기반으로 평가되어 최종 CSS 문자열을 생성합니다. 최종적으로 생성된 동적 CSS 규칙을 스타일 시트에 주입하고, 해당 규칙의 클래스 이름을 반환합니다.

최종적으로 아래와 같이 각 컴포넌트의 고유한 클래스 이름을 기준으로 동적으로 계산된 스타일 규칙들이 스타일 시트에 추가된 형태를 확인할 수 있게 됩니다.

크리에이트립 프로덕트를 예시로 든 사진이라, 글에서 설명한 styled-components 버전과 다른점 양해 부탁드려요

참고) styled-components 는 스타일 시트의 CSS 규칙 목록에 새로운 규칙을 추가하기 위해 CSSStyleSheet object 의 insertRule() 메소드를 사용합니다. CSSStyleSheet 는 CSSOM이라고 하는 CSS의 상태를 나타내는 모델에 접근할 수 있는 인터페이스를 제공합니다. 해당 방식을 사용하면 DOM에 직접 접근해 개별 인라인 스타일을 적용하는 것 보다 더효율적으로 스타일 변경을 통합해서 한번에 처리할 수 있게 됨으로, 성능상의 이점이 존재합니다.

3. styled-components의 장단점과 대안들

여기까지 정의된 스타일링과 컴포넌트가 실행되는 맥락에 따라서 동적으로 스타일 속성이 결정되고, 이것이 실제로 DOM 에 주입되는 과정을 알아보았습니다. 이렇게 많은 부분이 추상화되어 있어, 개발하는 입장에서는 단순히 styled 태그로 스타일드 컴포넌트를 생성하고 사용하기만 되는 편리성을 제공합니다. 또한 CSS의 전통적인 문제들, 특히 전역 네임스페이스를 사용하면서 발생하는 className 충돌이나 예상치 못한 스타일 오버라이드를 신경쓰지 않아도 된다는 장점이 있습니다.

하지만 CSS-in-JS 방식의 라이브러리는 스타일 속성이 런타임에 동적으로 생성됩니다. 스타일드 컴포넌트를 생성할 때마다 각 컴포넌트 인스턴스에 대해 고유한 클래스 이름을 생성하고, 해당 클래스에 적용될 CSS 규칙을 계산해야 합니다. 이러한 동작은 런타임 성능에 부정적인 영향을 미칠 수 있습니다.

성능적인 이슈와 관련해서, CSS-in-JS 라이브러리가 리액트 18의 동시성 모드의 성능에 미칠 수 있는 영향과 개선할 수 있는 방법에 대해 논의된 토론 또한 존재합니다. 요약하자면, 동시성 모드에서 런타임에 <style> 을 주입하게 될경우, 렌더링 도중에 스타일이 새롭게 추가될 경우 브라우저가 스타일 규칙을 다시 계산하고, 다시 렌더링하는 과정을 반복하면서 성능에 안좋은 영향을 미칠 수 있다는 내용입니다.

이러한 문제에 대응하기 위해 CSS 모듈, PostCSS, Tailwind CSS와 같은 도구를 사용할 수 있습니다. 이는 빌드 시점에 스타일을 처리하기 때문에 런타임 성능 문제를 해결할 수 있습니다. 나아가 최근 페이스북에서는 styledX와 같이 CSS-in-JS 와 유사한 API를 제공하면서도 성능상의 이점을 추구하는 새로운 기술들을 소개했습니다. 이는 JS의 편리성과 선언적 스타일링 방식을 유지하면서도 런타임 오버헤드를 최소화 하기 위한 다양한 최적화 기법들을 제공하기 때문에 좋은 대안 중 하나로 대두되고 있습니다.

정리하며

그동안 너무 편리하게 잘 사용해온 styled-components의 내부 동작 과정과 코드를 살펴보면서 스타일드 컴포넌트 생성시 해싱, 직렬화, CSS 주입등 여러 무거운 로직들이 실행되어 어느정도의 오버헤드가 불가피함을 깨달았습니다. 그리고 이를 해결하기 위한 커뮤니티에서의 여러 논의와 새로운 기술들에 대해서도 알게 되었는데요. 개인적으로 아직 styledX와 같이 새로운 스타일링 라이브러리를 사용해본 경험이 없어 정확히 어느정도의 개선이 있을지는 잘 가늠이 되지 않았습니다. 앞으로 작은 프로젝트나 컴포넌트를 대상으로 비교 하는 POC를 진행하게 된다면 그 차이점이 더 명확하게 다가올 것 같습니다.

결국, 기존에 사용하던 익숙한 도구들에 매몰되지 않고 이들의 장단점을 인지한 상태로 프로젝트의 요구 사항, 유지보수성, 성능, 편의성 등 여러가지 면을 고려해 개선할 수 있는 방안을 팀내부적으로 꾸준히 논의하고 실험하며 적절한 도구를 선택하는 것이 중요하다는 생각이 들었습니다.

참고 자료

--

--