모던 CSS : 1. CSS-in-JS

ysok
19 min readNov 5, 2018

--

컴포넌트 기반 프론트엔드 개발 방법이 발전하면서 CSS 개발 방법도 함께 변화중입니다. 새로운 방법론과 도구들이 기존 CSS 개발의 어려움을 해소해주고 있습니다.

2018년 지금의 새로운 CSS 개발 도구들을 알아보겠습니다.

  1. CSS-in-JS
  2. CSS Modules
  3. 기타

CSS-in-JS

그중 CSS-in-JS라고 부르는 자바스크립트 기반 개발 방법은 주목할만 합니다.

자바스크립트 코드에서 CSS를 작성하는 방법이다. 2014년 Facebook 개발자인 Christopher Chedeau aka Vjeux 의 발표에서 이야기한 방법입니다. 기존 CSS 관리의 어려운 문제들을 해결하는 Facebook의 사례를 소개하고 있고, 이 발표 이후 Movement라고 불릴만큼 많은 라이브러리가 만들어졌습니다. 발표내용은 2018년 지금도 유효하고 다시봐도 명료하게 정리했습니다.

핵심 내용은 다음과 같습니다. 이해한 수준에서 간단히 코멘트 해보겠습니다.

  1. Global namespace
    : 클래스 이름 중복문제
  2. Dependencies
    : 스타일 상속에 의한 중복
  3. Dead Code Elimination
    미사용 코드 처리
  4. Minification
    : 클래스이름 최소화
  5. Sharing Constants
    : 자바스크립트 코드와 값을 연동
  6. Non-deterministic Resolution
    CSS 로드 순서에 따른 캐스테이드 스타일 변화
  7. Isolation
    상속에 의한 영향이 없도록 격리

CSS를 좀 다뤄본 사람이라면 모두 공감할 내용이면서, 기술적인 한계로 뾰족한 해결책을 내놓기 힘든 이슈들입니다. (이 이슈들을 모두 설명하고자 하는 것은 아니므로 자세한 내용은 발표를 참고) Facebook은 이런 문제들의 해결책으로 컴포넌트 기반 자바스크립트 인라인 스타일을 선택했다고 합니다.

여기서 CSS-in-JS 라는 명칭은 방법론이며, 실제로 작성하기 위해서는 라이브러리를 사용하는 것이 수월합니다. 2018년 현재 라이브러리는 아주 많습니다. 비슷한 라이브러리가 많다는 것은 해당 기술이 한참 발전 하고 있다는 지표라고 생각합니다.

CSS-in-JS 라이브러리 경향 (NPM trends)

많이 사용하고 있는 라이브러리를 비교해봅니다.
(2017.11 ~ 2018.10, 다운로드 수 기준)

많긴 많습니다. 다운로드 수도 점차 늘어가는 중이고, 글로벌 기술 기업들에서도 많이 사용 중입니다.

구현

구현방식은 크게 2가지가 있습니다.

  1. CSS 클래스 자동 생성
    작성한 스타일이 minify된 클래스로 변환되고, 문서에는 <style> 로 삽입되어 사용
  2. 인라인 스타일로 생성
    작성한 스타일을 태그에 인라인 스타일로 직접 삽입
    (인라인 스타일로 작성하는 방법과는 다릅니다.)

세부적인 구현 방식에 차이가 있긴 하지만, 라이브러리의 기본 철학은 동일해보입니다.

이 중 몇 가지 라이브러리를 선택해서 간단한 버튼을 만들어 보겠습니다. 라이브러리의 장단점이나 구현방식의 세부사항 보다는 나무를 살펴보는 데 중점을 두겠습니다.

구현방식이 조금씩 다른 다음의 라이브러리로 선택했습니다.

  • Radium
    (inline style 방식, React에서 사용합니다.)
  • JSS
    (Framework 관계없이 사용가능, material-ui 에서 사용합니다.)
  • Styled-components
    (현재 가장 인기가 많습니다. 컴포넌트 기반 Framework에 최적화되어 있습니다.)

간단한 버튼 컴포넌트를 만들어보겠습니다. 라이브러리 설치 및 기타 설정과정은 생략합니다. 먼저 간단한 명세부터 작성합니다.

기능

  • 선택 시 애니매이션 효과
  • 비활성 상태 지정 가능
  • 사이즈 선택 가능

스타일

  • button, a 태그를 동시에 사용가능
  • 애니매이션 : CSS ripple
  • 360px 이하 해상도에서 너비 100% 블록 버튼으로 변경
  • small, medium(기본), large 3가지 사이즈

컴포넌트 구현

  • React를 사용합니다.

1. 기본 스타일 작성

JSS, Radium

// 기본 스타일 정의
const styles = {
btn: {
overflow: 'hidden',
position: 'relative',
display: 'inline-block',
flex: '1 0 auto',
padding: '.475em .825em',
fontSize: '14px',
lineHeight: '1.2em',
verticalAlign: 'top',
boxSizing: 'border-box',
border: '1px soldi #06c',
borderRadius: 0,
backgroundColor: '#fff',
color: '#06c',
cursor: 'pointer'
}
}

Styled-Components

// Styled Components
const Button = styled.button`
overflow: hidden;
position: relative;
display: inline-block;
padding: .475em .825em;
font-size: 14px;
line-height: 1.2em;
vertical-align: top;
box-sizing: border-box;
border: 1px solid #06c;
border-radius: 0;
background-color: #fff;
color: #06c;
cursor: pointer;
`

Javascript template literal로 바로 작성합니다. ( JSS에서도 작성 Template literal로 작성 가능하지만 아직 지원이 불완전합니다.)

2. 스타일 사용

작성한 마크업에 스타일을 추가합니다. 사용 방법이 조금씩 다릅니다. Javascript object로 생성된 클래스를 마크업에 맞추어 사용하면 됩니다.

JSS

const { classes } = jss.createStyleSheet(styles).attach()
const StyledButton = () =>
<button
type="button"
className={ classes.btn }>
다운로드
</button>

Radium

const Button = () =>
<button type="button"
style={[styles.btn]]}>
다운로드
</button>
// 스타일을 컴포넌트에 적용한다.
const RadiumButton = Radium(Button);

Styled-Components

// 스타일 사용 대상을 컴포넌트로 지정한다.
const Button = styled.button`
overflow: hidden;
position: relative;
...
const Button = () =>
<Button>
다운로드
</Button>

3. 렌더링

브라우저 렌더링 상태를 확인해 보겠습니다.

JSS
Radium
Styled-Componets

서두에 언급했던 것처럼

  1. class name 이 자동 생성되어 삽입되거나
  2. inline style로 삽입됩니다.

중요한 지점입니다. 어떤 방법이든 스타일 코드가 자바스크립트 코드 내에서 관리 되기에 가능한 부분입니다. 클래스 네이밍이 겹칠 가능성이 거의 없어보이고, 클래스이름을 자동을 생성하기에 특이성(Specificity)문제도 함께 해결해줍니다. 컴포넌트 마다 동일한 이름 규칙을 사용할 수도 있겠습니다.

더는 “이 클래스를 쓰고 있을까?”라는 의문과 함께 전체 코드베이스를 검색하지 않아도 됩니다.

4. 스타일 초기화

CSS-in-JS에서는 전역 속성의 완전한 제거를 추천하기에 해당 스타일에 필요한 모든 속성을 선언하는 것을 권장합니다. 브라우저 초기화에 필요한 스타일은 필요할 것 같습니다. 글로벌 스타일로 작성해봅니다.

JSS

import normalize from 'normalize-jss';// normalize css
jss.createStyleSheet(normalize).attach();
// Global style 선언
const styles = {
'@global': {
body: {
color: 'green'
}
}
}

Radium

// App.js
import normalize from 'radium-normalize';
<Style rules={normalize} />

Inline style이므로 구현 방식이 달라집니다. 별도로 제공하는 <Style />컴포넌트를 통해 최상위 컴포넌트로 선언해야 합니다.

Styled-Components

const GlobalStyle = createGlobalStyle`
body {
color: red;
}
`

Global 선언 방법을 제공합니다. 별도로 제공하는 모듈도 있습니다.

5. 가상선택자 추가

hover 상태의 가상선택자와 ripple 효과를 추가해보겠습니다. 버튼 선택 시 나타날 ripple 효과는 가상 엘리먼트를 사용하여 작성하겠습니다.

JSS

// Hover style
const styles = {
btn: {
...
'&:hover': {
backgroundColor: 'rgba(0, 102, 204, .1)'
}
...
}
}

SCSS와 동일하게& 를 사용할 수 있습니다.

Radium

// Hover style
const styles = {
btn: {
...
':hover': {
backgroundColor: 'rgba(0, 102, 204, .1)'
}
...
}
}

& 없이도 사용할 수 있습니다.

Styled-Components

// Styled Components
const Button = styled.button`
...
:hover {
background-color: rgba(0, 102, 204, .1);
}
...
`

CSS와 동일한 코드로 사용가능합니다.

6. 가상 엘리먼트, CSS 애니매이션 추가

  • 애니매이션 효과를 위해 ::after 가상 엘리먼트를 사용합니다.
  • ripple 애니매이션을 작성
  • 클릭시 사용할 상태에 대한 스타일도 함께 만듭니다.

JSS

// Ripple Style
const styles = {
btn: {
...
'&::after': {
position: 'absolute',
top: '50%',
left: '50%',
width: '10px',
height: '10px',
borderRadius: '50%',
backgroundColor: '#06c',
opacity: 0,
boxSizing: 'border-box',
transform: 'translate(-50%, -50%)',
animationDuration: '700ms',
content: '""'
zIndex: -1,
}
...
},
// 선택 시 적용할 클래스
isSelected: {
'&::after': {
animationName: ripple,
zIndex: 1
}
},
// Ripple animation keyframes
'@keyframes ripple': {
'0%': {
opacity: '.3',
},
'100%': {
width: '300px',
height: '300px',
opacity: 0
}
}
}

CSS와 작성방법 자체는 다르지 않습니다.

Styled-Components

// keyframes를 별도로 작성
const keyFramesRipple = keyframes`
0% {
opacity: .3;
}
100% {
width: 300px;
height: 300px;
opacity: 0;
}
`
// ::after 엘리먼트const Button = styled.button`
...
::after {
position: absolute;
top: 50%;
left: 50%;
width: 10px;
height: 10px;
border-radius: 50%;
background-color: #06c;
opacity: 0;
box-sizing: border-box;
z-index: -1;
transform: translate(-50%, -50%);
animation-duration: 700ms;
content: ""
}
...
// 클릭시 스타일 변경은 컴포넌트의 props로 반응한다.
...
${props =>
props.isClicked && css`
::after {
animation-name: ${keyFramesRipple};
z-index: 1;
}
`};
...

@keyframes 는 별도로 작성하고, 선택 상태는 Component 의 props를 사용하는 방식입니다. 상태에 대해서 별도로 클래스를 만들 수가 없습니다.

Radium

// Ripple Style은 JSS와 동일함.
const styles = {
btn: {
...
}
}
// keyframes은 별도로 선언한다.
const rippleKeyFrames = Radium.keyframes(
{
'0%': {
opacity: '.3',
},
'100%': {
width: '300px',
height: '300px',
opacity: 0
}
}
)
// 가상 엘리먼트 대체...
<button type="button">
<span style={[styles.isSelected]}></span>
{children}
</button>

Radium은 문제가 있습니다. Inline style의 특성때문에 가상 엘리먼트를 작성할 수 없습니다. (DOM에만 스타일 작성 가능) 그러므로 모든 가상 엘리먼트는 별도의 태그로 변경해야 합니다. 당연하지만, 다른 것들과 차이나는 부분입니다.

7. 비활성 상태 추가

거의 모든 버튼에는 비활성 상태가 있습니다. 추가해보겠습니다.

JSS

...
isDisabled: {
'&:disabled': {
backgroundColor: '#ccc',
opacity: '.6'
}
}
...

Radium

...
isDisabled: {
':disabled': {
backgroundColor: '#ccc',
opacity: '.6'
}
},
...

Styled-Components

...
${props =>
props.disabled && css`
:disabled {
background-color: #ccc;
opacity: .6;
}
`}
...

상태에 따른 스타일 변경은 props로 변경합니다.

8. Media query

360px 이하의 viewport에서는 너비가 100%인 블록 버튼으로 변경합니다.

JSS, Radium 공통

// Media Query
const styles = {
...
'@media (max-width:360px)': {
display: 'block',
width: '100%'
}
...
}

두 가지 모두 동일한 방법으로 선언할 수 있습니다. 다만, @media 구문 내에서 keyframe 선언이 불가능합니다.

Radium

inline style 이므로 @media 구문 처리가 걱정입니다. 이를 위한 별도의 방법을 제공합니다. <StyleRoot /> 라는 스타일 처리 Root 컴포넌트를 제공하는데, 이 컴포넌트에 @media 구문을 삽입하는 방식입니다.

// Media query 처리를 위한 <StyleRoot />...
// Root 컴포넌트
...
render() {
return (
<div>
<StyleRoot>
<RadiumButton>
다운로드
</RadiumButton>
</StyleRoot>
</div>
)
}

이렇게 렌더링 됩니다.

inline style로 media query 렌더링

Styled-Components

...
@media (max-width: 360px) {
.button {
display: block;
width: 100%;
}
}
...

특별히 어색한 점은 없다. 다른 라이브러리와 마찬가지로 @keyframes 등은 선언할 수 없습니다.

9. 타입 추가하기

버튼 크기를 여러가지로 만들어보겠습니다. small, medium(기본스타일), large 이렇게 추가합니다. 버튼의 padding을 em으로 지정해두었기 때문에 font-size정도만 변경해주겠습니다.

JSS, Radium

// Variation Style
const styles = {
...
large: {
minWidth: '200px',
fontSize: '2em'
},
small: {
fontSize: '10px'
}
}

두 라이브러리 모두 동일한 방식으로 사용합니다.

Styled-Components

...
${props =>
props.largetype && css`
font-size: 2em;
`};
`

앞선 상태 추가 방식과 동일하게 작성합니다.

이쯤에서 드는 생각이 있습니다. 지금 클래스 이름에 대한 고민이 거의 없습니다. CSS 작성 시 타입, 상태 등을 추가할 때면 클래스 네이밍 때문에 고민이 많았습니다. 넘버링을 해야 할지, _type을 붙일지, 이것을 어찌해야 할지… 이런 작업은 사소한 것 같지만 CSS 개발 작업의 큰 부분을 차지하는 일이었습니다.

CSS-in-JS에서는 실제 사용할 클래스 이름을 라이브러리가 생성하기에 기본적인 네이밍 세트만 생각하면 됩니다. 컴포넌트마다 같은 이름이라도 괜찮습니다. 3.렌더링에서와 같은 생각이 듭니다.

10. 개선

거의 끝나갑니다. 마지막으로 자바스크립트 스타일 작성의 혜택을 누려보겠습니다. 조금만 변경해봅니다. 이번에는 JSS 한가지만 확인합니다.

// 필요한 디폴트값을 선언
const buttonBaseStyle = {
color: '#06c'
}
const rippleStyle = {
name: 'ripple',
initWidth: '10px',
initHeight: '10px',
color: '#06c'
}
// ripple 최대 크기 지정. 초기값의 10배로 반환
const getRippleSize = (multiple = 1) => {
return `${(multiple * 10) * parseFloat(rippleStyle.initWidth, 10)}px`
}
// 스타일에서 JS변수 및 계산값을 바로 사용
..
boxSizing: 'border-box',
border: ['1px', 'solid', buttonBaseStyle.color],
borderRadius: 0,
backgroundColor: '#fff',
...
'&::after': {
animationName: `${rippleStyle.name}`,
zIndex: 1
}
...
'100%': {
width: getRippleSize(3),
height: getRippleSize(3),
opacity: 0
}
...

코드 일부를 변수를 사용하여 지정했고, 자바스크립트를 사용하여 값을 계산했습니다. 서두의 발표에서 상수 교환Sharing Constant 이라는 것이 이런 점을 이야기한 것인가 봅니다. 확실히 편하지만, 동시에 생각해볼 점들이 생겨납니다.

  • 스타일이 자바스크립트와 밀접하게 결합한다.
    컴포넌트 기반 개발 방법론에서는 관리 범위를 달리합니다. CSS 개발로 이야기하면 스타일의 관리 범위를 컴포넌트로 한정하는 것입니다. 관리의 범위가 달라질 뿐입니다. 컴포넌트 내부에서 JS와의 결합은 큰 문제가 되지 않는다고 생각합니다.
    단, CSS 개발자가 따로 있는 경우 이야기는 달라지겠습니다.
  • SCSS로도 가능한 부분 아닌가?
    SCSS의 mixin, function 등이 비슷한 역할을 하지만, 똑같이 가능하지는 않습니다. JS로 계산된 값들은 코드 내에서 바로 사용할 수 있습니다. JS 와 전처리기는 할 수 있는 일이 다릅니다.

완성

수고했습니다. 우리가 작성한 전체 코드를 확인해보겠습니다. (JSS만 제공)

전체 코드 (JSS)

더 생각해볼 것들

외부 라이브러리와 스타일 중복문제

실무에서는 여러 가지 라이브러리 혹은 프레임워크를 함께 사용하는 일이 흔합니다. 간단한 테스트로 Bootstrap과 함께 사용해 보았습니다. 역시 CSS-in-JS 특성상 스타일 충돌은 거의 발생하지 않았습니다. 브라우저 초기화 스타일 중 상속되는 스타일이 일부 발생하지만 사소한 정도입니다.

CSS-in-JS 에서 Isolation(완전한 scoped style 형태로 상속 방지)이라는 개념을 제공합니다. 라이브러리에서는 여러 가지 방법을 통해 구현하고 있는데, CSS의 상속 특성으로 인해 완전한 scoped style 구현은 어려운 것이 사실입니다.(Shadow DOM을 사용하는 방법이 있지만, 논외로 하겠습니다.) 이 부분은 별도 주제로 다뤄야 할 부분이겠습니다.

Vender-prefix?

실제 구현 형태는 조금씩 다르지만, 모두 Automatic vendor prefixing 기능을 제공합니다. Autoprefixer 라이브러리와 같이 지원 범위 전체의 prefix를 제공하는 것이 아니라, 브라우저에 맞는 코드만 제공하는 방식입니다.

정리

CSS-in-JS로 간단한 버튼을 만들어 보았습니다. 이 방법은 CSS 개발의 고질적인 이슈들, 개발자가 머리로 고민하고 수작업으로 작성하고 구성원 간 커뮤니케이션으로 풀어야 했던 문제들을 일정 부분 라이브러리가 대신 하도록 도와줍니다.

다음은 CSS-in-JS와는 조금은 다른 CSS Modules에 대해 알아봅니다.

--

--