여러분들은 styled-components
에 대해서 얼마나 아시나요? 저는 지난 멋사 커뮤니티 제작 프로젝트와 해커톤에서 다루어 보았었는데요, 사실 코드 활용법만 알고 있었고 그 개념과 동작 원리는 잘모르고 있었어요. 그래서 해커톤 협업 과정에서 디자인 시스템에 적응할 때 약간의 문제를 겪기도 했었죠.
그렇기 때문에 오늘 글에서는 styled-components의 개념과 동작 원리, 디자인 시스템 코드 작성까지 다루어보려 해요. 먼저, styled-components는 css-in-js
기술을 활용한 리액트 라이브러리라고 합니다. 그럼 css-in-js가 styled-components와 어떤 연관이 있는지 그 개념을 짧게 소개하고 이해해보도록 합시다.
css-in-js는 이름 그대로, 자바스크립트 코드로 css를 작성하는 방식을 말합니다. css의 복잡성 문제를 해결하기 위한 아이디어로 등장했습니다. css-in-js를 제안한 vjeux의 자료를 따라 요약하자면 기존의 css에서 해결한 문제점들은 아래와 같습니다.
- Global namespace: 글로벌 공간에 선언된 이름의 명명 규칙 필요
- Dependencies: CSS 간의 의존 관계를 관리
- Dead Code Elimination: 미사용 코드 검출
- Minification: 클래스 이름의 최소화
- Sharing Constants: JS와 CSS의 상태 공유
- Non-deterministic Resolution: CSS 로드 우선순위 이슈
- Isolation: CSS와 JS의 상속에 따른 격리 필요 이슈
어떻게 이런 문제들을 해결할 수 있었을까요? css-in-js는 js runtime에서 스타일시트를 생성, 관리하며 프로그래밍 언어의 동적인 특징을 이용할 수 있도록 설계되었습니다.
따라서 CSS와 자바스크립트 변수나 상태를 공유하고, 조건식에 따라 스타일에 변화를 줄 수도 있었습니다. 장점과 함께 문제점을 어떻게 해결했는지 짧게 살펴보겠습니다.
- gloabl namespace: 신경쓸 필요가 없습니다. JS로 표현된 것을 CSS로 컴파일할 때, 컴파일러가 고유한 이름을 생성합니다.
- dependencies : 모든 스타일을 runtime에 주입하므로, CSS간의 의존관계 관리의 문제가 발생하지 않습니다.
- minification : 자동 이름 생성으로 클래스 이름을 최소화합니다.
- sharing Constnats : JS와 CSS가 변수나 상태를 공유할 수 있습니다.
- 런타임에 스타일시트가 처리되기 때문에 CSS 로드 우선순위 이슈가 없습니다.
- css-in-js는 문서레벨이 아니라 컴포넌트 레벨로 CSS모델을 추상화하고, 중복 및 의존성을 줄여 유지보수를 용이하게 합니다.
하지만 css-in-js에 단점 역시 존재합니다.
- js 해석과정이 추가로 실행되기 때문에 페이지 전환등의 속도가 느려집니다. 해석 시간 비교 참고 자료
위 참고자료에서 확인할 수 있듯이, CSS-in-JS
를 CSS-in-CSS
와 비교하면, 같은 페이지 임에도 358ms vs 90.5ms라는 속도 차이를 확인할 수 있습니다.
따라서 css-in-js를 선택하고자 고민한다면 css 모듈 방식과 페이지 로드 속도에 대한 요구사항 비교 후 라이브러리 선택이 중요합니다.
다음으로 살펴볼 주제는 Template Literal입니다.
styled-components는 Tagged Template Literal 문법을 토대로하고 있습니다. 기존의 Template Literal은 내장된 표현식을 허용하는 문자열 리터럴입니다. 백틱(`) 기호를 사용하며, 플레이스 홀더를 이용해 표현식을 넣을 수 있습니다. 이는 기호 $와 중괄호를 조합해 아래(`${expression}`)처럼 표현할 수 있습니다.
const name = 'react';
const message = `hello ${name}`;
console.log(message);
// "hello react"
위와 같이 작성하면 플레이스 홀더안의 표현식과 그 사이 텍스트는 한 번에 함께 함수로 전달됩니다. 기본 함수는 이를 단일 문자열로 concat시켜 줍니다.
하지만 만약에 Template Literal을 사용할 때 표현식에 일반 문자열이나 숫자가 아닌 객체를 넣는다면, 아래와 같은 결과를 얻을 수 있습니다.
const object = { a: 1 };
const text = `${object}`
console.log(text);
// "[object Object]"
또는 함수를 넣는다면 그 결과는 아래와 같습니다.
const fn = () => true
const msg = `${fn}`;
console.log(msg);
// "() => true"
그리고 이런 구조는 응용하면 아래와 같이 사용할 수 있습니다.
const red = '빨간색';
const blue = '파란색';
function favoriteColors(texts, ...values) {
console.log(texts);
console.log(values);
}
favoriteColors`제가 좋아하는 색은 ${red}과 ${blue}입니다.`
이렇게 Tagged Template Literal을 사용하면, 내부에 넣은 JS value를 조회하면 함수에 prop으로 넘겼던 값들을 확인할 수 있습니다. 여기까지, 벨로퍼트와 함께하는 모던 리액트의 styled-components 파트의 Template Literal 문법 예제들을 확인해 보았는데요, 이제 응용 문법인 Tagged Template Literal의 예제를 분석해보도록 하겠습니다.
태그를 사용하면, Template Literal을 함수로 파싱하는 것인데요, 태그 함수의 첫번째 인수는 문자열 배열을 포함합니다. 나머지 인수는 표현식을 포함합니다.
위의 예제로 쓰인
favoriteColors`제가 좋아하는 색은 ${red}과 ${blue}입니다.`
Tagged Template Literal에서는 첫번째 인수로 texts 문자열 배열에 ["제가 좋아하는 색은", "과", "입니다."]
를, 나머지 인수로 ["빨간색", "파란색"]
을 넣었습니다. 특히, 함수 파라미터의 나머지 인수 부분에서 rest 문법이 사용되어 호출 시 인수를 분해해 호출할 수 있음을 볼 수 있습니다.
만약 이를 reduce 함수와 조합한다면 아래와 같은 작업을 할 수 있습니다.
const red = '빨간색';
const blue = '파란색';
function favoriteColors(texts, ...values) {
return texts.reduce((result, text, i) => `${result}${text}${values[i] ? `<b>${values[i]}</b>` : ''}`, '');
}
favoriteColors`제가 좋아하는 색은 ${red}과 ${blue}입니다.`
// 제가 좋아하는 색은 <b>빨간색</b>과 <b>파란색</b>입니다.
이런 복잡한 함수를 바로 사용하진 않겠지만, styled-components 에서는 이러한 문법을 사용해 컴포넌트의 props를 읽어 옵니다.
function sample(texts, ...fns) {
const mockProps = {
title: '안녕하세요',
body: '내용은 내용내용 입니다.'
};
return texts.reduce((result, text, i) => `${result}${text}${fns[i] ? fns[i](mockProps) : ''}`, '');
}
sample`
제목: ${props => props.title}
내용: ${props => props.body}
`
/*
"
제목: 안녕하세요
내용: 내용은 내용내용 입니다.
"
*/
sample이란 tagged template literal의 texts 배열에는 ["제목:", "내용:"]
이 들어있고, fns 배열에 [props=> props.title, props=>props.body]
가 들어있을 거에요.
때문에 fns[i](mockProps)
는 배열의 i번째 요소인 mockProps.title
과 mockProps.body
를 리턴하게 됩니다.
복잡한 구조지만 하나씩 천천히 뜯어보니 이해할만 합니다. styled-components에선 이러한 Tagged Template Literal의 문법적 특성을 활용해서, 스타일링에 비구조화 할당을 응용하기도 합니다.
이제 styled-components의 개념으로 넘어가보도록 하겠습니다.
그래서 여기까지 css-in-js와 템플릿 리터럴의 개념을 살펴보았어요. 그럼 이제 본격적으로 styled-component의 개념에 대해 알아봅시다.
앞서 다뤄본 내용처럼, styled-components는 내부 작동을 위해 Tagged Template Literal이라는 문법을 활용합니다. 그리고 이를 이용해 컴포넌트와 스타일링 간의 매핑을 지우고, 마치 평범한 리액트 컴포넌트를 작성하듯 스타일을 만들어낼 수 있습니다. 무엇보다, React component system의 스타일링을 위해 CSS를 대체할 방법으로 개발되었습니다.
조건부 스타일링은 가변 스타일링이라고도 불리는데요, 간단한 Button 컴포넌트 예제와 함께 살펴보겠습니다.
아래와 같은 버튼 컴포넌트를 App에서 호출하였다고 가정하겠습니다.
//src/component/Button.js
import React from "react"
import styled, { css } from "styled-components"
const StyledButton = styled.button`
padding: 0.375rem 0.75rem;
border-radius: 0.25rem;
font-size: 1rem;
line-height: 1.5;
border: 1px solid lightgray;
${(props) =>
!props.primary &&
css`
color: black;
background: gray;
border-color: gray;
`}
${(props) =>
props.primary &&
css`
color: white;
background: navy;
border-color: navy;
`}
`
const Button = ({ name, ...props }) => {
return <StyledButton {...props}>{name}</StyledButton>
}
export default Button;
import React from "react"
import Button from "./Component/Button"
const App = () => {
return (
<>
<Button name="버튼 1" />
<Button name="버튼 2" primary/>
</>
);
};
export default App;
Button 컴포넌트는 name 속성에 이름을 전달할 수 있는데요, 지금 App에서 보다시피 버튼 2는 primary 속성도 넘겨주고 있습니다.
따라서 버튼 1은
${(props) =>
!props.primary &&
css`
color: black;
background: gray;
border-color: gray;
`}
위 부분에 !props.primary
조건을 만족해 조건부 스타일링으로 gray 바탕의 black 색상 텍스트를 갖게 됩니다.
반면, 버튼 2는
${(props) =>
props.primary &&
css`
color: white;
background: navy;
border-color: navy;
`}
위 부분의 props.primary
를 만족하게 되어 navy 바탕의 white 색상 텍스트를 표현하게 됩니다.
또 다른 특징으로, styled-component는 객체지향 프로그래밍과 비슷한 모습을 보입니다.
const Box = styled.div`
background-color: ${(props) => props.bgColor};
width: 100px;
height: 100px;
`;
const Circle = styled(Box)`
border-radius: 50px;
`;
위와 같이 코드를 작성하면, Circle component들은 Box component의 style attribute들을 상속하여 사용하게 됩니다. 객체지향 프로그래밍의 상속의 개념과도 같은 모습이죠.
여기까지 살펴본 styled-components의 기본적인 문법과 특성을 토대로 디자인 시스템을 구성해봅시다. 디자인 시스템은 프로젝트 UI 전반에서 공통적으로 쓰이는 layout과 typography, theme등으로 구성할 수 있는데요, 이번엔 layout 구성 예제를 살펴보겠습니다.
import styled, { css } from "styled-components";
import { BgColor, Border, Flex, BoxStyle } from "./layout.types";
import { getStyle, toMarginPaddingString } from "./layout.utils";
/**
* @prop {number} `rounded` border radius
* @prop {number} `m`, `p` margin, padding
* @prop {number} `mv`, `pv` vertical
* @prop {number} `mh`, `ph` horizontal
* @prop {number} `ml`, `mr`, `mt`, `mb`, `pt`, `pb`, `pl`, `pr` allowed
* @prop {number | string} `w` width
* @prop {number | string} `h` height
* @prop {'flex-start' | 'flex-end' | 'center'} `alignItems` align-items
* @prop {'flex-start' | 'flex-end' | 'center' | 'space-between' | 'space-evenly'} `justifyContent` justify content
* @prop {ColorKeys} `bgColor` background color
*/
export const LayoutBase = styled.div<BgColor & Flex & BoxStyle & Border>`
${({
theme,
p, ph, pv, pt, pr, pb, pl,
m, mh, mv, mt, mr, mb, ml,
w, h,
flex,
rounded,
z,
outline,
alignItems = "flex-start",
bgColor = "TRANSPARENT",
justifyContent = "flex-start",
cursor,
}) => css`
${getStyle("padding", toMarginPaddingString(p, ph, pv, pt, pr, pb, pl))}
${getStyle("margin", toMarginPaddingString(m, mh, mv, mt, mr, mb, ml))}
${getStyle("width", w)}
${getStyle("height", h)}
${getStyle("flex", flex)}
${getStyle("border-radius", rounded)}
${getStyle(
"border-color",
outline && outline in theme ? theme[outline] : outline
)}
${getStyle("z-index", z)}
${typeof rounded === "number" ? "overflow: hidden;" : ""}
${typeof outline === "string"
? "border-width: 1px; border-style: solid;"
: ""}
display: flex;
flex-direction: column;
align-items: ${alignItems};
justify-content: ${justifyContent};
background-color: ${bgColor && bgColor in theme ? theme[bgColor] : bgColor};
cursor: ${cursor};
`}
`;
export const FlexRow = styled(LayoutBase)`
flex-direction: row;
`;
export const FlexCol = styled(LayoutBase)`
flex-direction: column;
`;
위 코드는 'flex' property를 가진 layout을 위한 코드입니다. 먼저 LayoutBase
를 FlexRow
와 FlexCol
컴포넌트에서 상속받고 있는 모습을 확인할 수 있어요. LayoutBase는 theme, 패딩, 마진, flex 속성, width, heigth, border-radius 등을 props로 받을 수 있습니다.
import { DefaultOrNumber } from './layout.types';
export const toPx = (value: 'default' | number) =>
value === 'default' ? 20 : value;
export const toMarginPaddingString = (
...[all = 0, h = 0, v = 0, t = 0, r = 0, b = 0, l = 0]: (
| DefaultOrNumber
| undefined
)[]
) => {
const top = toPx(t) || toPx(v) || toPx(all);
const right = toPx(r) || toPx(h) || toPx(all);
const bottom = toPx(b) || toPx(v) || toPx(all);
const left = toPx(l) || toPx(h) || toPx(all);
return `${top}px ${right}px ${bottom}px ${left}px;`;
};
export const getStyle = (key: string, value: number | undefined | string) => {
if (value === undefined) return '';
if (['z-index', 'flex'].includes(key)) return `${key}: ${value};`;
if (typeof value === 'number') return `${key}: ${value}px;`;
return `${key}: ${value};`;
};
이 유틸함수에선 Pixel 단위를 사용하고 있어요. 따라서, rem 단위로 활용하려면 base font size를 지정한 뒤 rem 단위로 변환해주는 함수가 필요해요.
// Define your base font size in pixels
const baseFontSizeInPx = 16; // Example, 16px
export const pxToRem = (pxValue: number) => {
// Convert pixels to rems based on the base font size
const remValue = pxValue / baseFontSizeInPx;
// Return the value with 'rem' unit
return `${remValue}rem`;
};
혹은 유틸함수 단위 자체를 처음부터 rem으로 작성할 수도 있습니다. 이럴 경우, 프로젝트 팀원들과 어떠한 단위를 사용할지 상의하고 디자인 가이드 문서도 확인하시길 바랍니다!
export type DefaultOrNumber = "default" | number;
export type Margin = {
m?: DefaultOrNumber;
mh?: DefaultOrNumber;
mv?: DefaultOrNumber;
mt?: DefaultOrNumber;
mb?: DefaultOrNumber;
ml?: DefaultOrNumber;
mr?: DefaultOrNumber;
};
export type Padding = {
p?: DefaultOrNumber;
ph?: DefaultOrNumber;
pv?: DefaultOrNumber;
pt?: DefaultOrNumber;
pb?: DefaultOrNumber;
pl?: DefaultOrNumber;
pr?: DefaultOrNumber;
};
export type Border = {
rounded?: number;
outline?: string;
};
export type BoxStyle = {
z?: number;
w?: string | number;
h?: string | number;
} & Margin &
Padding;
export type BgColor = {
bgColor?: string;
};
type FlexType = "center" | "flex-start" | "flex-end";
export type Flex = {
flex?: number;
alignSelf?: FlexType;
alignItems?: FlexType;
justifyContent?: FlexType | "space-between" | "space-evenly";
cursor?: "pointer" | "grab" | undefined;
};
그리고 위 코드는 layout component props에 사용된 type들을 정의해둔 코드입니다. 예시로 Flex가 들어있지만, Grid에 맞춰 커스텀해 쓸 수 있어요. 특히 FlexType이란 유니온 타입을 확장해서 justifyContent을 만들어두었는데, 이렇게 하나의 layout.types.ts
문서를 통해 디자인시스템에서 사용할 props를 편리하게 관리할 수 있습니다.
import { L } from "@/design-system";
const someComponent = () => {
return(
<>
<L.FlexCol w={"100%"} h={"100%"} justifyContent={"space-between"}>
{/* inner content */}
</L.Flexcol>
</>
)
};
이를 종합하면 위의 코드처럼, 'L'이라는 약어로 레이아웃 컴포넌트 모듈을 import해서 쉽게 스타일링에 활용할 수 있습니다. 만약 이런 디자인시스템을 활용한다면, 팀원끼리 css를 수정할 상황이 발생했을 때 소통하는 시간이 줄어들고 코드 가독성도 높아질 것입니다.
여기까지, styled-components의 등장 배경이 된 css-in-js와 핵심 문법인 Tagged Template Literal, 디자인 시스템 사용 예시까지 살펴보았습니다.
이전까지 프로젝트에선 tailwindCSS를 주로 쓰고 styled-components를 주의깊게 살펴볼 기회가 없었는데요, nextjs에서 tailwind를 권장해서 써오긴 했지만 이번 글을 정리하며 styled-components의 개발 가치관과 토대가 된 기술을 확인할 수 있었습니다.
개념적으론 이런 부분들을 공부할 수 있었고, 잘 만들어진 디자인 시스템을 목표로 한다면 라이브러리를 어떻게 활용해야 할까?또한 고민해볼 수 있었습니다. 특히나 프로젝트에서는 유지보수가 중요한 부분인데, 수정하기 쉽고 재사용하기 편한 코드를 만들기 위해선 디자인 시스템 코드에 대한 이해도를 더욱 더 높여야겠다고 생각했습니다.
앞으로는 vanilla-extractCSS나 tailwindCSS로 구성한 디자인 시스템 코드도 소개하고 다루어보아야 겠어요. 그리고 각각의 CSS 라이브러리가 왜 어떻게 도입된건지 개념을 이해해 적재적소에 활용해서 편리한 서비스를 만들도록 노력해보겠습니다!
다들 화이팅!
공식문서
velopert styled-components
styled-components 디자인 시스템 참고