Custom Style System 구축하기

Seungwoo Lee
creatrip
Published in
14 min readJul 18, 2024

안녕하세요.

크리에이트립 프론트엔드 개발자 이승우라고 해요 :)

이 글에서는 Custom Style System을 구축한 경험을 공유해보려고 해요.

Style System 구축 필요성을 느낀 지점

크리에이트립 프론트엔드는 스타일링 라이브러리로 styled-components 를 사용하고 있어요. styled-components 는 CSS-IN-JS 방식으로 컴포넌트 기반으로 CSS를 작성할 수 있어요.

이 방식은 태그에 의미를 부여하여 직관적으로 어떤 용도로 사용하는지 파악할 수 있고 className이 지저분해지는 것을 방지할 수 있지만, css를 지정해야 할 태그들은 모두 스타일 컴포넌트로 만들어야 하는 번거로움 또한 가지고 있어요.

const WrapperA = styled.div`
display: flex;
align-items: center;
justify-content: space-between;
gap: 20px;
`;

const WrapperB = styled.div`
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
gap: 20px;

${(p) => p.theme.media.tablet} {
gap: 30px;
}

${(p) => p.theme.media.desktop} {
gap: 40px;
}
`;

// 비슷한 패턴의 스타일 컴포넌트가 자주 쓰이는 상황

프로젝트가 커질수록 스타일 컴포넌트의 증가는 필연적이었고 만들어진 스타일 컴포넌트들에게서 특히 반복적으로 자주 쓰이는 스타일링 패턴들을 발견하였는데 그 중 대표적인 게 flexbox와 grid를 통한 레이아웃 스타일링이었어요.

그래서 저는 확장성 있는 style system을 구축하여 레이아웃 부분부터 적용해나가면 좋겠다고 반복되는 스타일 컴포넌트의 문제를 줄일 수 있겠다고 생각하게 되었어요.

Style System 구축시 지키고자 한 원칙

1. 기존의 환경 위에서 동작하는 것

스타일 시스템 만을 위한 의존성이 추가 된다거나, 기존에 styled-components 에서 하였던 설정과 별도로 관리되는 것이 없는 것을 지향하였어요. 스타일 시스템은 자주 스타일링되는 패턴들을 컴포넌트화하여 쉽게 스타일링할 수 있게 해주는, 편의성을 위한 시스템이라고 판단하였기 때문에 이것을 위해 기존의 설정에서 관리포인트가 늘어나는 것은 배보다 배꼽이 더 크다고 생각했어요.

2. 인터페이스 및 구현체의 제어권을 가지는 것

구현체의 제어권을 온전히 가지게 되면 더도 말고 덜도 말고 우리가 필요한 만큼의 기능만을 구현할 수 있고 유지보수를 편하게 할 수 있어 선호해요. 또한, 스타일 시스템을 구축하기 전에 중요하게 생각했던 포인트가 하나의 컴포넌트가 과도하게 인터페이스가 열려있지 않고 역할에 부합하는 인터페이스만 가지는 거였어요. 어떠한 역할을 수행하는 컴포넌트를 위해 얼마나 인터페이스를 열 것인가는 유즈 케이스, 현재 프로젝트 수준 등 많은 부분에 따라 달라질 수 있고 구성원 간의 합의를 통해 유연하게 조정될 수 있어야 하기에 인터페이스의 제어권을 가지는 것도 중요하게 생각했어요.

3. css 속성을 props로 사용할 때 과도한 축약 표현을 지양하는 것

다른 스타일 시스템에서는 width를 w로, height를 h로 축약하는 등의 줄임말을 쓰는 것을 심심찮게 볼 수 있어요. 스타일 시스템 구축과 관련하여 의견을 구하는 자리에서 축약된 표현의 인터페이스가 신규 팀원의 불필요한 진입장벽을 높일 수도 있다는 우려를 포함하여 css 속성의 풀네임으로 하는 것이 혼란을 없게 하는 측면에서 선호한다는 의견이 나왔고 저 역시도 스타일 시스템 구축에서 혼동의 여지를 줄 필요는 없다고 생각했어요. 이 부분도 온전히 인터페이스의 제어권을 우리가 가짐으로써 결정할 수 있는 부분이었어요 :)

4. 성능에 영향이 없도록 하는 것

1에서 말씀드렸던 것과 같이 기존의 스타일 컴포넌트를 생성하는 방식에서도 스타일링을 하는 데에 문제가 되는 부분은 없었어요. 반복적인 비슷한 패턴의 스타일링 코드를 줄이고 좀 더 생산적으로 UI를 구성할 수 있도록 도와주는 것이 스타일 시스템을 구축하는 주요 목표였어요. 따라서, 스타일 시스템을 구축하여 사용한다고 해서 기존 구현체보다 성능이 나빠지면 안된다고 생각했어요.

위의 원칙을 지키면서 스타일 시스템을 구축하기 위해서는 직접 구현하는 것이 가장 좋다고 판단하였고 그렇게 Custom Style System을 구축을 시작하게 되었어요!

Custom Style System 구축기

1단계. 인터페이스 정의하기

인터페이스를 정의할 때에 가장 중요하게 생각했던 포인트는 확장성이었어요. 그래서 연관성 있는 속성들을 하나로 묶어서 컴포넌트에 주입하는 방식으로 정의하였어요. 이렇게 되면 style system 기반으로 만들어진 컴포넌트는 컴포넌트의 역할과 책임에 따라서 적절하게 정의된 인터페이스들 중 필요한 것들만 조합하여 사용할 수 있기 때문이에요.

import type { CSSProperties } from 'styled-components';

export type ResponsiveProperty<T> = T | [T, T, T];
export type AlignProps = {
justifyContent?: ResponsiveProperty<CSSProperties['justifyContent']>;
alignItems?: ResponsiveProperty<CSSProperties['alignItems']>;
};

위의 코드는 실제 프로젝트에서 정의된 인터페이스 중 하나예요. 반응형 디자인을 위해 모든 값은 [mobile, tablet, desktop] 으로도 줄 수 있도록 타입을 지정하였고 styled-components 에서 제공하는 CSSProperties 타입을 사용하여 해당 속성에 해당하는 값의 타입을 규정해주었어요. 여기서 정의된 인터페이스는 정렬과 관련된 속성들이에요. 현재 프로젝트 내에 사용되고 있는 유즈 케이스를 고려하였을 때, justifyContent, alignItems 로 구성하였고 모든 css 속성의 풀네임 만을 사용하였어요.

2단계. props → css 적용 로직 구현하기

위의 인터페이스가 실제로 컴포넌트의 prop으로 넘어왔을 때 css를 적용하는 부분에 대해서 설명 드릴게요.

prop는 모두 camelCase로 작성되기 때문에 실제 적용될 css 속성의 이름으로 변환해주는 부분이 필요해요. 그래서 각 인터페이스와 매칭되는 매퍼를 정의해주었어요.

import { StandardLonghandPropertiesHyphen, StandardShorthandProperties } from 'csstype';

...
export type CSSPropertyNames =
| keyof StandardLonghandPropertiesHyphen
| keyof StandardShorthandProperties;

export const alignCssPropertyNameMapper: Readonly<Record<keyof AlignProps, CSSPropertyNames>> = {
justifyContent: 'justify-content',
alignItems: 'align-items',
};

인터페이스는 marginTop, marginBottom 과 같은 keyof StandardLonghandPropertiesHyphen 타입을 허용하면서 margin: 16px 24px 20px 18px 과 같이 여러 개별 속성들을 축약하여 한 번에 적용해주는 keyof StandardShorthandProperties 타입도 포함시키기 위해 csstype 에서 keyof StandardLonghandPropertiesHyphen | keyof StandardShorthandProperties 를 실제 css 속성으로 넣어줄 수 있는 타입으로 지정하였어요.

다음으로는 매퍼에 해당하는 props들이 실제로 스타일링에 반영될 수 있도록 props를 CSSObject로 변환해주는 작업이 필요해요.

import type { CSSProperties, CSSObject, ThemedStyledProps, DefaultTheme } from 'styled-components';

type HTMLElementPropsWithoutRef = Omit<
React.DetailedHTMLProps<React.HTMLAttributes<HTMLElement>, HTMLElement>,
'ref'
>;
type CSSPropertiesObject = {
[cssProperty in keyof CSSProperties]: ResponsiveProperty<CSSProperties[cssProperty]>;
};

export function getCssObjectFromProps(
props: ThemedStyledProps<HTMLElementPropsWithoutRef & CSSPropertiesObject, DefaultTheme>,
customCssPropertyNameMapper: { [cssProperty: string]: string },
): CSSObject {
return (Object.keys(props) as Array<keyof typeof props>).reduce((prev, current) => {
const cssPropertyName = customCssPropertyNameMapper[current];

if (cssPropertyName === undefined) {
return prev;
}

const value = props[current];

...
// 반응형 디자인을 위해 `[mobile, tablet, desktop]` 으로 들어온 값을 처리해주는 로직
...
return {
...prev,
[cssPropertyName]: value,
};
}, {} as CSSObject);
}

styled-components 로 정의된 스타일 컴포넌트가 기본적으로 가지는 props을 위해 ThemedStyledProps 을 사용하여 컴포넌트의 props로 올 수 있는 범위를 맞춰주고 css prop을 실제 css 속성 이름으로 매핑하는 매퍼를 매개변수로 받았어요.

이제는 prop이 매퍼가 가지고 있는 key와 매칭되면 CSSObject에 포함시키기만 하면 되고 getCssObjectFromProps 함수의 로직 부분에서 그걸 해주고 있어요.

3단계. 컴포넌트 구현하기

custom style system을 위한 구성 요소들이 완성되었고 이걸 바탕으로 어떻게 컴포넌트를 확장성 있게 구현하는지 보여드릴게요.

아래는 세로로 자식 요소들을 정렬할 때 사용되는 VStack 컴포넌트를 구현한 코드예요.

VStack은 Vertical Stack의 약자예요.

import type { AlignProps, GapProps, SizingProps, SpacingProps } from '@creatrip-web/style';
import {
alignCssPropertyNameMapper,
gapCssPropertyNameMapper,
getCssObjectFromProps,
sizingCssPropertyNameMapper,
spacingCssPropertyNameMapper,
} from '@creatrip-web/style';
import styled, { css } from 'styled-components';

const vStackCssPropertyNameMapper = {
...sizingCssPropertyNameMapper,
...spacingCssPropertyNameMapper,
...alignCssPropertyNameMapper,
...gapCssPropertyNameMapper,
} as const;

export type VStackProps = AlignProps & GapProps & SizingProps & SpacingProps;

const VStack = styled.div<VStackProps>`
display: flex;
flex-direction: column;
${(p) =>
css`
${getCssObjectFromProps(p, vStackCssPropertyNameMapper)}
`}
`;

export default VStack;

VStack이라는 컴포넌트의 역할과 책임을 바탕으로 어떤 인터페이스들이 필요할 지 정의하고 그에 필요한 Props와 매퍼를 주입 받아서 구성해요. styled-components 로 스타일 컴포넌트를 정의하고 기본적으로 적용될 스타일을 정의한 후 동적으로 props를 통해서 스타일링이 되는 부분은 getCssObjectFromProps 함수를 통해서 로직을 적용해주어요.

// AS-IS
const WrapperA = styled.div`
display: flex;
align-items: center;
justify-content: space-between;
gap: 20px;
`;

// TO-BE
<HStack alignItems="center" justifyContent="space-between" gap={20}>
...
</HStack>

// AS-IS
const WrapperB = styled.div`
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
gap: 20px;

${(p) => p.theme.media.tablet} {
gap: 30px;
}

${(p) => p.theme.media.desktop} {
gap: 40px;
}
`;

// TO-BE
<VStack gap={[20, 30, 40]} justifyContent="center" alignItems="center">
...
</VStack>

이런 식으로, custom style system을 기반으로 만든 컴포넌트로 맨 처음의 WrapperA, WrapperB 코드를 대체하면 위와 같이 변경되어요. 현재, 프로젝트 내에서는 이 시스템을 기반으로 동료 개발자 분들과 컴포넌트마다 얼마나 인터페이스를 열어둘 지 논의를 거쳐 VStack, HStack, Center, Grid 등의 컴포넌트를 구현하여 사용 중이에요.

마치며

styled-system 과 같은 라이브러리를 사용하지 않고 직접 만들기로 한 의사결정 과정에서부터 확장성 있고 변경에 유연한 컴포넌트를 만들기 위해 어떤 구조가 필요할 지 설계하고 구현하기까지 많은 고민이 있었어요.

고민을 하는 과정 속에서 동료 분들과 논의를 통해 프로젝트의 현재 시점에서 적합한 방향이 무엇일까에 대한 실마리를 찾을 수 있었고 무사히 구축까지 갈 수 있었다고 생각해요. 이 자리를 빌어서 다시 한 번 동료 개발자 분들께 감사의 말씀을 전해요!

custom styled system 기반으로 만든 컴포넌트의 사용으로 이제 비슷한 패턴의 스타일 컴포넌트 선언을 많이 줄이는 동시에 좀 더 생산성 높게 UI를 구성할 수 있게 되었고 다른 분들께도 도움이 될 수 있어서 뿌듯했어요 :)

마지막으로, 현재는 프로젝트에서 styled-components 를 사용하기에 CSS-IN-JS 기반의 스타일 시스템을 구축하였지만, 서연님의 글에서처럼 고질적인 CSS-IN-JS 의 런타임 오버헤드로 인한 성능 저하 문제가 있기에 구현체를 tailwind, vanilaextractjs 와 같이 런타임 오버헤드가 없는 방식으로 변경하면 성능 향상을 더 할 수 있지 않을까 라는 생각도 해보았어요.

레퍼런스

--

--