본문 바로가기

React/CSS

CSS #3 - SASS (2) with CSS Module

CSS Module은 CSS파일 작성의 잠재적 문제 발생을 방지합니다.

CSS potential danger

저번 시간에 SASS의 장점에 대해서 소개했습니다. SASS를 통해서 CSS를 더 쉽게, 그리고 가독성 있게 작성할 수 있습니다. 또한 번들러 / 전처리기의 결과물이 결국은 CSS파일과 같기 때문에 컴파일(build / deploy)을 하는 시간을 제외하면 같은 성능을 제공할 수 있습니다. 그러나 다음과 같은 CSS파일을 작성할 때 개발자가 신경을 써야 하는 부분은 해소되지 못했습니다.

 

CSS override

ansrlm.tistory.com/19 글의 1 - 6)을 보시면, CSS파일에서 마지막 선언이 가장 높은 우선순위를 가집니다.

자바스크립트 파일들과 CSS 파일들은 성능을 위해서 하나의 자바스크립트 파일과 하나의 CSS파일, 혹은 특정 비즈니스 요건에 따라서 특정 구조 / 목적에 맞게 병합을 하는 과정을 번들링 과정에 수행합니다. 그렇다는 뜻은 중복된 클래스 명칭이나 셀렉터 특정성을 가진 파일들이 하나의 파일로 합쳐질 수 있음을 의미합니다. 

번들링 과정이 아니더라도 A라는 컴포넌트(jsx)가 a라는 스타일(scss)을 import 하고 있고 B라는 컴포넌트가 b라는 스타일을 import 하고 있는 파일들이 있을 때, A가 B를 import 해서 선언하는 경우 의도와 다른 스타일 적용이 될 수 있습니다.

 

// A.jsx

import React from 'react';
import './A.scss';
import B from './B';

const A = () => (
	<div className="A">
		<header className="A-header">
			<p className="P">This is my App</p>
			<B/>
		</header>
	</div>
);

export default A;

// A.scss

.A {
	text-align: center;
}

.P {
	color: green;
}

 

// B.jsx

import React from 'react';
import './B.scss';

const B = () => (
	<div className="B">
		<p className="P">I just want to treat styles as default</p>
	</div>
);

export default B;

// B.scss

.P {
	background-color: yellow;
}

 

만약 B 컴포넌트를 <B/>로 다른 곳에서 호출을 한다면, default-setting인 color: block과 우리가 작성한 background-color: yellow만 적용이 될 것입니다. 그러나 A 컴포넌트를 호출을 한다면, A안의 B컴포넌트는 default-setting이 아닌 color: green과 중앙 정렬이 적용되게 됩니다. (물론 파일에 셀렉터를 저런 극단적인 방식으로 선언하는 경우는 절대 없을 것입니다.)

 

앞서 말씀드린 경우는 컴포넌트 속의 컴포넌트에 있는 스타일 적용이 되어 있지 않다면 상위 컴포넌트의 스타일이 적용되는 경우입니다. 그 반대로, 하위 컴포넌트인 B가 다음과 같은 코드를 가지고 있다면, 상황은 반대가 됩니다.

 

// additional codes of B.scss

.A {
	text-align: right;
}

 

B.scss는 A.jsx의 'A'라는 클래스 명칭을 가진 division tag부터 시작해서, 스타일 상속이 가능한 하위 컴포넌트에 모두 A.scss가 의도한 중앙 정렬을 우측으로 변경하게 됩니다. 이 이유는 글로벌 네임스페이스 때문인데, CSS에서 모든 것은 글로벌 공간에 선언되기 때문에 파일을 나누어 작성했다고 하더라도 명칭이 같은 경우 over-riding이 일어나게 되는 것입니다. 버그 리포트 / 이슈 트레킹이 매우 어려울 수 있기 때문에 이런 잠재적 문제를 방지하기 위해서 명명 규칙 등으로 분할할 필요가 있습니다.

 

CSS wrapper naming

파일 간의 독립적인 공간을 가지고 스타일을 적용하기 위해서 컴포넌트 파일에서는 해당 컴포넌트의 최상위 container tag에 해당하는 부분에 파일 명칭과 같은 클래스 명칭이나 Id를 붙이고 (ex : MyComponent.jsx는 <div className="MyComponent>) CSS파일도 해당 부분에 맞게 다음과 같이 wrapper를 하는 방식을 사용하기도 합니다.

 

// MyComponent.scss

.MyComponent {
	.A {
		padding-left: 5px;
	}
	.B {
		width: 200px;
	}
	...
}

 

파일 명칭이 하나의 디렉토리 안에서는 겹치지 않으나 디렉토리 구조가 세분화된 경우가 프로젝트 대부분이라 CSS파일의 최상이 wrapper class 명칭이 겹치지 않기 위해서 src-business-component-page-MyComponent와 같이 하는 케이스도 보았습니다. 이런 부분이 올바른 방법인지는 잘 모르겠습니다. 매 파일마다 소스 코드를 감싸고 있는 스타일 셀렉터를 추가하는 것은 결국 파일 용량이 커지는 것이기도 하며 명칭을 정하는 것도 작성자가 추가적인 업무가 될 가능성이 높습니다.

 

BEM

명명 규칙을 통해서 효과적으로 CSS override를 막는 방법이 하나 더 있습니다. BEM(Block Element Modifier)라고 불리는 CSS 제작 방법론입니다. ClassName을 적극적으로 사용하는 방식이며 그 구성요소는 다음과 같습니다.

1) Block : 독립적이며 재사용이 가능하도록 구성해야 합니다. 구조를 담당합니다. (`${blockName}`)

2) Element : Block에 종속적이며 Block의 실제 기능을 구현하는 부분입니다. (`${blockName}__${elementName}`, underscore 2개로 이름을 이어 작성합니다.)

3) Modifier : Block / Element의 속성 값이며 Block, Element에 각각 적용해서 외관이나 상태를 변화시킵니다. (`${blockName}`--${modifierName}` || `${blockName}`__${elementName}--${modifierName}`, hyphen 2개로 이름을 이어 작성합니다.)

자세한 명명 규칙과 그 원리는 getbem.com/namingen.bem.info/methodology/naming-convention를 참조하시면 되겠습니다. (더 좋은 정리 글 알려주시면 열심히 읽겠습니다. 감사합니다~)

명확한 규칙이 존재하고, 이를 통해서 재활용성을 높일 수 있습니다. 다만 컴포넌트 단위의 작성을 지향하는 React에서 BEM을 같이 사용할 경우 HOC / render props를 사용하거나 하는 것들에 대해서 어떻게 파일을 작성하고 파일을 나눠야 하는지 갈피를 못 잡는 경우가 저는 있었습니다. 또한 이 방식은 프로젝트를 진행하는 모든 구성원이 정확히 알고 룰을 지켜 나가는 것이 중요해 한번 어기기 시작하면 큰 재앙으로 작용할 수도 있습니다.

CSS Module

CSS 모듈은 파일들을 컴파일해서 ICSS라는 포맷의 파일로 만듭니다. (자세한 설명은 glenmaddern.com/articles/interoperable-css에 있습니다.) CSS 클래스는 `_${fileName}_${className}_${hash}`의 형태로 고유한 값으로 만들어 줍니다. (nav.css 의 .nav의 경우 _nav_nav_afd97dfs867와 같은 값으로 생성합니다.) 이 과정을 통해서 컴포넌트 스타일 중첩을 방지할 수 있습니다. 유니크한 이름을 만들어 주는 것을 통해서 다음과 같은 장점이 생깁니다.

 

class naming releaved from the danger of overriding

실제 사용하는 클래스 명칭은 자동으로 생성되며, 유일하기 때문에 클래스명이 겹치는 것을 고민하지 않아도 되어서 쉽게 결정할 수 있습니다. CSS Module을 사용하지 않을 경우 button class를 작성하게 된다면 다음과 같을 수 있습니다.

 

/* components/submit-button.css */
.Button { /* all styles for basic button */ }
.Button--submit { /* overrides for submit */ }
.Button--delete { /* overrides for delete */ }
.Button--choose { /* overrides for choose */ }

 

Button이라는 공통 클래스를 선언하고, 해당 부분을 override 하기 위해서 HTML, JS파일은 다음과 같이 작성되어야 합니다.

 

{ /* components/submit-button.html */ }
<button class="Button">all styles for basic button</button>
<button class="Button Button--submit">overrides for submit</button>
<button class="Button Button--delete">overrides for delete</button>
<button class="Button Button--choose">overrides for choose</button>

 

BEM에 입각해서 파일을 작성했다고 하더라도, 버튼의 hierarchy가 2 level depth보다 더 깊으면 스타일을 적용하는 CSS파일도, HTML 파일도 exponential로 증가하게 됩니다. (disabled인데 border style이 버튼이 mandatory 한 것을 표현하기 위해서 각 위 상태에 따라서 다르게 색을 설정해야 한다거나 하는 상황 등이 있습니다.)

이에 비해서 CSS Module은 다음과 같이 작성합니다.

 

/* components/submit-button.module.css */
.normal { /* all styles for basic button */ }
.submit { /* all styles for submit */ }
.delete { /* all styles for delete */ }
.choose { /* all styles for choose */ }

 

파일의 명칭을 **. module. {css, scss}로 작성하는 것을 통해서 파일을 CSS Module로 사용할 수 있습니다. syntax가 변하지 않기 때문에 CSS를 작성했던 이전 방식처럼 쉽게 작성할 수 있습니다. 이 파일을 사용하는 JSX파일은 다음과 같습니다.

 

/* components/Buttons.jsx */
import React from 'react';
import styles from './submit-button.module.css';

const Buttons = () => (
	<div>
		<button className={styles.normal}>normal</button>
		<button className={styles.submit}>submit</button>
		<button className={styles.delete}>delete</button>
		<button className={styles.choose}>choose</button>
	<div/>
);

export default Buttons

 

**. module. {css, scss}로 작성된 파일과, 해당 파일을 import 하는 곳은 어떻게 컴파일을 해서 ICSS라고 불리는 포맷으로 만드는 것인지는 webpack.config.js 에서 그 실마리를 찾을 수 있습니다.

 

...

// "postcss" loader applies autoprefixer to our CSS.
// "css" loader resolves paths in CSS and adds assets as dependencies.
// "style" loader turns CSS into JS modules that inject <style> tags.
// In production, we use MiniCSSExtractPlugin to extract that CSS
// to a file, but in development "style" loader enables hot editing
// of CSS.
// By default we support CSS Modules with the extension .module.css

{
	test: cssModuleRegex,
	use: getStyleLoaders({
		importLoaders: 1,
		sourceMap: isEnvProduction ? shouldUseSourceMap : isEnvDevelopment,
		modules: {
			getLocalIdent: getCSSModuleLocalIdent,
		},
	}),
},

...

 

getCSSModuleLocalIdent.d.ts에 존재하며, 해당 로더를 통해서 Interoperable CSS (ICSS)로 CSS파일을 변경하고, 그에 따라 import 하고 있는 JS파일도 변경을 하게 됩니다.(해당 부분은 github.com/css-modules/icss를 참조하시면 되겠습니다.) 이 과정을 거쳐서, 다음과 같은 CSS / JS 파일을 얻게 됩니다.

 

// JS
...

<button class="components_submit_button_normal__3nnPn">normal</button>

...

// CSS
.components_submit_button_normal__3nnPn {
	{ /* styles */ }
}

 

CSS파일 Import 범위 조정

Lodash와 같은 라이브러리를 사용할 경우, import _ from 'lodash'로 사용하는 것은 사용하지 않는 항목들까지 다 가져오는 것을 의미합니다. 그래서 만약 연속된 이벤트 발생에 있어서 이벤트 핸들러의 호출을 제어하고 싶은 상황이 있다고 가정했을 때, import { throttle, debounce } from 'lodash'로 사용하는 것으로 필요한 것들만 가져와 트리 셰이킹을 가능하게 하고, 전체 자바스크립트 파일의 크기를 줄이며 이로 인해서 성능 저하를 막을 수 있습니다. 또한 해당 방식으로 인해 어떤 의도로 import 했는지를 쉽게 파악할 수 있습니다.

 

/* components/SubmitButton.jsx */
import React from 'react';
import { submit } from './submit-button.module.css';

const SubmitButton = () => (
	<div>
		<button className={submit}>submit</button>
	<div/>
);

export default SubmitButton

 

CSS Module의 경우도 같은 방법을 선택할 수 있으며 권장하고 있습니다. 사실 사용하지 않을 CSS class를 작성하지 않을뿐더러 CSS Module이 아닌 Vanilla CSS에서도 사용이 가능하다는 점에서 CSS Module만의 장점이라고 보긴 어려울 듯합니다. 그러나 import './submit-button.css'로 Vanilla CSS파일을 import 하는 경우가 많기 때문에, 비구조화 할당을 하면서 얻는 CSS Module의 부가적인 작은 장점이라고 너그러이 넘어가 주시면 감사하겠습니다.

 

Composition : 확장성과 공통화

앞서 소개드렸던 것처럼, CSS Module을 사용하는 것을 통해서 각각 클래스 명칭에 맞는 모든 스타일을 작성하는 것으로 BEM에 입각해서 작성할 때 많은 클래스 명칭을 필요로 하는 것을 해결할 수 있습니다. 그런데 만약 공통된 속성이 있는 경우 각 클래스 명칭에 맞는 모든 스타일을 작성하면 DRY(Don't Repeat Yourself) 하지 않은 코드로 다가오게 됩니다.

 

/* components/submit-button.module.css */
.normal { /* all styles for basic button */ }
.submit { /* all styles for submit */ }
.delete { /* all styles for delete */ }
.choose { /* all styles for choose */ }


/* By using Composition, can write below */
.common {
  /* all the common styles you want */
}
.normal {
  composes: common;
  /* anything that only applies to normal */
}
.submit {
  composes: common;
  /* anything that only applies to submit */
}
.delete {
  composes: common;
  /* anything that only applies to delete */
}
.choose {
  composes: common;
  /* anything that only applies to choose */
}

 

composes라는 키워드를 사용하는 것을 통해서, ansrlm.tistory.com/20에서 소개드렸던 SASS에서 @extend 키워드를 사용해서 공통된 스타일을 관리하는 것과 같은 방식을 적용할 수 있습니다. 다만 SASS가 CSS파일로 트렌스파일 하는 과정과 다르게 CSS Module은 자바스크립트 파일에 적용된 클래스들을 변경합니다.

 

// The case by using SCSS
.Button--common { 
  /* all the common styles you want */
}
.Button--normal {
  @extends .Button--common;
  /* anything that only applies to normal */
}
.Button--submit {
  @extends .Button--common;
  /* anything that only applies to submit */
}
.Button--delete {
  @extends .Button--common;
  /* anything that only applies to delete */
}
.Button--choose {
  @extends .Button--common;
  /* anything that only applies to choose */
}


// Compile into like below...
.Button--common, .Button--normal, .Button--submit, .Button--delete, .Button--choose {
  /* all the common styles you want */
}
.Button--normal {
  /* anything that only applies to normal */
}
.Button--submit {
  /* anything that only applies to submit */
}
.Button--delete {
  /* anything that only applies to delete */
}
.Button--choose {
  /* anything that only applies to choose */
}

 

// The case by using CSS Module
.common { 
  /* all the common styles you want */
}
.normal {
  composes: common;
  /* anything that only applies to normal */
}
.submit {
  composes: common;
  /* anything that only applies to submit */
}
.delete {
  composes: common;
  /* anything that only applies to delete */
}
.choose {
  composes: common;
  /* anything that only applies to choose */
}


// Webpack's css-loader replaces local-scoped identifier with a global unique name
.components_submit_button__common__1q2w3e4r {
  /* all the common styles you want */
}
.components_submit_button__normal__azsxdcfv {
  /* anything that only applies to normal */
}
.components_submit_button__submit__qwertyui {
  /* anything that only applies to submit */
}
.components_submit_button__delete__0987poiu {
  /* anything that only applies to delete */
}
.components_submit_button__choose__alskdjfj {
  /* anything that only applies to choose */
}

// JS file
styles: {
  common: "components_submit_button__common__1q2w3e4r",
  normal: "components_submit_button__common__1q2w3e4r components_submit_button__normal__azsxdcfv",
  submit: "components_submit_button__common__1q2w3e4r components_submit_button__submit__qwertyui",
  delete: "components_submit_button__common__1q2w3e4r components_submit_button__delete__0987poiu",
  choose: "components_submit_button__common__1q2w3e4r components_submit_button__choose__alskdjfj",
}

 

delete button에 대해서 SASS를 통한 CSS는 타겟 HTML tag에 하나의 클래스 명칭을 적용하고 공통된 속성은 common, normal, submit, delete, choose에 대해서 적용될 수 있도록 작성하는 것을 확인하실 수 있습니다. 이에 비해서 CSS Module을 통한 CSS는 타겟 HTML tag에 여러 개의 클래스 명칭을 적용하고 공통된 속성은 common하나에 대해서 작성하게 됩니다. CSS Module을 사용할 경우 CSS파일에서 하나의 클래스에 대한 스타일 작성이 보통 하나로 구성되기 때문에 어떤 속성이 어디서 적용되었는지 백 트레킹 하기 용이합니다.

 

Selector for test(Enzyme, Cypress, ...) code

CSS in JS의 방식 중 하나인 styled-component를 사용해서 소스 코드를 작성할 경우 해시 값(class="sc-dkPtyc cOZxDK")만으로 클래스 명칭을 적용하기 때문에 DOM test를 하기 위해서 data-testid Attribute를 추가적으로 tag에 적용하거나, className을 적용하는 작업이 필요합니다. SCSS를 통해서 작성된 파일은 클래스 명칭이 바뀌지 않기 때문에 간편하게 테스트 코드를 작성할 수 있습니다. CSS Module은 SCSS만큼 명확하게 작성할 수는 없지만, 그리고 data-testid를 추가하는 것이 더 명확하지만, testing을 위한 selector에 wildcard를 적용하는 것을 사용할 수 있습니다.

CSS 명칭이 `${fileName}_${className}__${hash}`로 구성되기 때문에, 다음과 같은 방식으로 선택을 가능하게 합니다.

 

// for Enzyme
const findDeleteButton = (wrapper: ReactWrapper) => {
  // return wrapper.find('[className^="${fileName}_${className}__"]').at(0);
  return wrapper.find('[className^="components_submit_button__delete__"]').at(0);
};

// for Cypress
const findChooseButton = () => {
  // cy.get('*[class^="${fileName}_${className}__"]');
  cy.get('*[class^="components_submit_button__choose__"]');
}

 

장점이라고 말씀드리기는 사실 어렵습니다. Cypress의 가이드라인(docs.cypress.io/guides/references/best-practices#Selecting-Elements)은 data-testid를 꼭 사용했으면 좋다고 하며, class의 경우 DOM에 그려지는 위치가 생각한 것과 달라서 다른 컴포넌트를 선택할 수 있기 때문입니다. 자동화 테스트를 급히 구성해야 하는 경우나 data-testid를 사용하기에 시간적 여건이 좋지 않다면 최후의 보루로 선택할 수 있는 방법입니다.

 

 

지금까지 CSS Module을 사용하는 것으로 얻을 수 있는 장점들에 대해서 소개해 보았습니다.

사실 이번 글에 CRA로 CSS Module을 SASS와 함께 적용하는 것을 공유드리려고 했으나 이렇게 긴 글을 작성할 줄 몰랐습니다. (ㅜㅜ) 다음 글에 꼭!! 적용하는 기본 방식을 공유해 보도록 하겠습니다.

 

 

※ 잘못된 부분이나 궁금한 점 댓글로 작성해 주시면 최대한 답변하겠습니다. 읽어주셔서 감사합니다.

※ CSS #4 - SASS (3)에서 SCSS파일 변경과 CSS Module 작성 방식을 소개해 볼 예정입니다.

'React > CSS' 카테고리의 다른 글

CSS #6 - CSS-in-JS (2) Styled-Component with Storybook  (0) 2021.06.27
CSS #5 - CSS-in-JS (1) Pros and Cons  (2) 2021.06.20
CSS #4 - SASS (3) with CSS Module  (0) 2021.05.23
CSS #2 - SASS (1)  (0) 2021.04.19
CSS #1 - CSS priority  (1) 2021.04.18