들어가며
variables.less 파일 하나로 시작했던 프로젝트가 2년쯤 지나자 Less 파일이 80개를 넘어섰다. 새 기능 하나를 추가하려고 스타일을 짜면, 어디선가 이미 비슷한 mixin이 있는지 찾는 데 스타일 작성 시간보다 더 오래 걸리는 지경이 됐다. !important가 파일 곳곳에 40개 넘게 박혀 있었고, 특정 컴포넌트의 배경색을 바꾸려면 어느 파일의 어느 규칙이 이기고 있는지 개발자 도구로 하나하나 추적해야 했다.
이 글은 이 프로젝트의 Less 코드베이스를 정리하면서 세운 원칙들, 그리고 그 과정에서 배운 것들을 정리한 기록이다. Less 자체의 문법보다는 "여러 명이 오래 건드리는 스타일 코드를 어떻게 관리 가능한 상태로 유지하는가"에 초점을 맞췄다.
1. 문제의 근원 — 왜 Less 코드는 시간이 지나면 지저분해지는가
1-1. 전역 스코프라는 근본적 특성
Less로 컴파일된 CSS는 결국 전역 스코프에서 동작한다. 클래스명이 겹치면 나중에 로드된 규칙이 이긴다. 이 특성 자체는 CSS의 근본 성질이라 Less의 잘못은 아니지만, Less는 이 문제를 해결해주는 도구가 아니라 문법적 편의(변수, 중첩, mixin)만 제공하는 도구라는 걸 오해하기 쉽다. 변수와 mixin이 있다고 해서 전역 스코프 충돌 문제가 저절로 해결되는 게 아니다.
1-2. 중첩(Nesting)의 양날의 검
Less의 중첩 문법은 처음엔 편리하다.
// 처음엔 이렇게 짜기 시작한다
.card {
padding: 16px;
.card-title {
font-size: 18px;
.card-title-icon {
margin-right: 8px;
&:hover {
opacity: 0.8;
}
}
}
}
문제는 이 패턴이 프로젝트 전체에 관행처럼 퍼지면, 컴파일된 CSS의 선택자 명시도(specificity)가 계속 쌓인다는 점이다. 위 코드는 실제로 .card .card-title .card-title-icon:hover라는, 명시도가 상당히 높은 선택자로 컴파일된다. 나중에 이 아이콘의 hover 스타일을 다른 곳에서 덮어쓰려고 하면, 어지간히 구체적인 선택자를 새로 쓰지 않는 한 이길 수가 없다. 결국 !important를 쓰게 되고, 그 다음 사람은 그 !important를 이기기 위해 또 다른 !important를 쓰는 악순환이 시작된다.
실제로 정리를 시작하면서 !important 사용처를 전수 조사했는데, 대부분이 "깊은 중첩으로 명시도가 너무 높아진 선택자를 다른 곳에서 덮어쓰려다 생긴" 흔적이었다. 근본 원인은 !important 자체가 아니라, 그 앞에 쌓여있던 중첩 구조였다.
2. 원칙 1 — 중첩 깊이를 제한한다
가장 먼저 도입한 규칙은 중첩을 최대 2단계로 제한하는 것이었다. 정확히는, 컴파일된 CSS 선택자가 클래스 3개 이상 이어지지 않도록 제한했다.
// 지양: 3단계 이상 중첩
.card {
.card-header {
.card-header-title {
font-weight: bold;
}
}
}
// 지향: BEM 스타일 클래스명으로 평탄화하고, 중첩은 상태(&:hover 등)나 반응형에만 사용
.card-header-title {
font-weight: bold;
&:hover {
color: @primary-color;
}
}
중첩을 아예 쓰지 말자는 게 아니라, 구조를 표현하는 용도(부모-자식 관계를 억지로 선택자에 반영하는 것)로는 쓰지 않고, 상태나 변형(hover, active, 반응형 미디어쿼리)에만 쓰기로 범위를 좁혔다. 구조 표현은 클래스명 자체(BEM 컨벤션)로 하고, 중첩은 "이 클래스에 상태가 붙었을 때"만 사용하도록 역할을 나눈 것이다.
.card-header-title {
font-weight: bold;
// 상태는 중첩으로 표현 (허용)
&:hover {
color: @primary-color;
}
&--disabled {
// BEM modifier도 중첩으로 표현 가능 (허용)
color: @disabled-color;
}
// 반응형도 중첩으로 표현 (허용)
@media (max-width: 768px) {
font-size: 14px;
}
}
이 규칙 하나만으로도 새로 작성되는 코드의 명시도 문제가 눈에 띄게 줄었다.
3. 원칙 2 — 변수와 mixin을 계층화한다
3-1. 흩어져 있던 변수를 정리
정리를 시작하기 전 variables.less를 열어보니, @primary-color, @main-blue, @brand-color, @button-blue가 전부 미묘하게 다른 파란색을 가리키고 있었다. 각자 필요할 때마다 비슷한 변수를 새로 만들어 쓴 결과였다.
이 문제를 해결하기 위해 변수를 원시값(Primitive)과 의미값(Semantic) 두 계층으로 나눴다.
// _colors-primitive.less — 실제 색상 값만 정의, 다른 곳에서 직접 참조하지 않음
@blue-500: #2563eb;
@blue-600: #1d4ed8;
@gray-100: #f3f4f6;
@gray-900: #111827;
@red-500: #dc2626;
// _colors-semantic.less — 용도에 따른 이름으로 매핑, 실제 스타일 코드는 이것만 참조
@color-primary: @blue-500;
@color-primary-hover: @blue-600;
@color-danger: @red-500;
@color-text-default: @gray-900;
@color-background-subtle: @gray-100;
실제 컴포넌트 스타일에서는 절대 @blue-500을 직접 쓰지 않고 @color-primary만 참조하도록 팀 컨벤션을 정했다. 이렇게 나눠두면, 나중에 브랜드 컬러가 바뀌더라도 _colors-semantic.less 파일 한 줄만 수정하면 되고, "이 파란색이 primary 버튼 색인지, 그냥 아무 파란색인지"를 변수명만 보고 알 수 있다는 이점도 생긴다.
3-2. Mixin 라이브러리 정리
비슷하게, mixin도 파일마다 중복 정의된 게 많았다. 특히 flexbox 정렬처럼 자주 쓰는 패턴이 파일마다 조금씩 다른 이름으로 재정의되어 있었다.
// _mixins.less — 공용 mixin을 한 곳에 모음
.flex-center() {
display: flex;
align-items: center;
justify-content: center;
}
.text-ellipsis(@lines: 1) when (@lines = 1) {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.text-ellipsis(@lines) when (@lines > 1) {
display: -webkit-box;
-webkit-line-clamp: @lines;
-webkit-box-orient: vertical;
overflow: hidden;
}
text-ellipsis mixin에 조건부 로직(when 가드)을 활용해서, 한 줄 말줄임과 여러 줄 말줄임을 인자 하나로 처리하도록 만든 것도 이 정리 과정에서 나온 개선이었다. 이전에는 .ellipsis-1-line, .ellipsis-2-line, .ellipsis-3-line처럼 파일 곳곳에 비슷한 클래스가 따로따로 정의되어 있었다.
3-3. import 구조와 빌드 성능
Less는 @import로 파일을 엮는데, 이게 많아지면 빌드 시간에도 영향을 준다. 특히 각 컴포넌트 파일이 전역 mixin/변수 파일을 매번 @import하는 구조였는데, 파일 수가 80개를 넘어가면서 빌드 시간이 체감될 정도로 늘어났다.
// 각 컴포넌트 파일 최상단에 이런 게 반복됨
@import "../variables.less";
@import "../mixins.less";
이 문제는 빌드 도구(webpack의 less-loader) 설정에서 전역 파일을 자동으로 모든 엔트리에 주입하도록 바꾸면서 해결했다.
// webpack.config.js
{
test: /\.less$/,
use: [
'style-loader',
'css-loader',
{
loader: 'less-loader',
options: {
lessOptions: {
globalVars: {},
},
additionalData: `@import "${path.resolve(__dirname, 'src/styles/variables.less')}";`,
},
},
],
}
이렇게 설정하면 각 파일에서 매번 @import를 반복해서 적을 필요가 없어지고, 빌드 도구가 중복 import를 캐싱된 형태로 처리해줘서 컴파일 시간도 개선됐다.
4. 원칙 3 — 컴포넌트 단위로 파일을 짝지어 관리한다
4-1. 기존 구조의 문제
기존에는 스타일 파일이 기능별 폴더가 아니라 별도의 styles/ 폴더 아래에 전부 모여 있었다.
src/
components/
Button.jsx
Card.jsx
Modal.jsx
styles/
button.less
card.less
modal.less
(그리고 이것들과 미묘하게 이름이 겹치는 legacy 파일들…)
컴포넌트를 삭제할 때 대응하는 스타일 파일을 지우는 걸 깜빡하는 일이 반복됐고, 반대로 스타일 파일만 보고는 이게 지금도 쓰이는 컴포넌트의 스타일인지 죽은 코드인지 구분이 안 됐다.
4-2. 파일을 나란히 두는 구조로 전환
src/
components/
Button/
Button.jsx
Button.less
Card/
Card.jsx
Card.less
컴포넌트와 스타일 파일을 같은 폴더에 짝지어 두는 것만으로, "이 컴포넌트를 지우면 이 스타일도 같이 지운다"는 원칙이 훨씬 지키기 쉬워졌다. 이 구조로 전환하는 과정에서 실제로 더 이상 어떤 컴포넌트에서도 참조하지 않는 죽은 Less 파일을 12개 넘게 찾아서 정리할 수 있었다.
5. Less를 계속 써야 하는가 — CSS Modules, CSS-in-JS와의 비교
정리를 진행하면서 자연스럽게 나온 질문이 "이참에 CSS Modules나 styled-components로 아예 옮기는 게 낫지 않나"였다. 실제로 검토했던 내용을 정리하면 이렇다.
Less (전역 스코프) CSS Modules CSS-in-JS (styled-components 등)
| 스코프 격리 | 컨벤션(BEM 등)으로 수동 관리 | 빌드 시 클래스명 자동 해싱으로 격리 | 컴포넌트 단위로 자동 격리 |
| 런타임 비용 | 없음 (빌드 시점에 끝남) | 없음 | 라이브러리에 따라 런타임 오버헤드 존재 |
| 동적 스타일링 | 클래스 토글 방식으로만 가능 | 클래스 토글 방식으로만 가능 | prop 기반으로 직접 스타일 값 주입 가능 |
| 기존 코드 마이그레이션 비용 | - | 상대적으로 낮음 (문법 유사) | 상대적으로 높음 (컴포넌트 구조 자체를 바꿔야 함) |
결론적으로, 전면적인 CSS-in-JS 전환은 하지 않기로 했다. 이유는 두 가지였다.
- 런타임 비용: 당시 검토했던 CSS-in-JS 라이브러리 상당수가 스타일 계산을 런타임에 처리하는 방식이었고, 이미 렌더링 성능이 민감한 페이지들이 있어서 굳이 추가 비용을 감수할 이유가 없었다.
- 마이그레이션 비용 대비 실익: 문제의 근본 원인은 "Less라는 도구 자체"가 아니라 "스코프 격리를 컨벤션에만 의존한 관리 방식"이었다. CSS Modules로 전환하면 스코프 격리 문제는 빌드 도구가 강제로 해결해주지만, 80개가 넘는 기존 파일을 전부 옮기는 비용이 상당했다.
대신 선택적으로 CSS Modules를 도입했다. 신규로 작성하는 컴포넌트는 .module.less로 작성해서 클래스명 충돌 걱정 없이 스코프를 격리하고, 기존 파일은 이번에 정리한 BEM + 계층화된 변수 체계를 유지하면서 점진적으로 옮겨가는 쪽을 택했다.
/* Button.module.less — 빌드 시 클래스명이 자동으로 해싱되어 전역 충돌 걱정이 없음 */
.button {
.flex-center();
padding: 8px 16px;
background: @color-primary;
&:hover {
background: @color-primary-hover;
}
}
기존에 정리해둔 mixin과 변수 체계는 .module.less에서도 그대로 @import해서 재사용할 수 있었기 때문에, 이전 작업이 헛수고가 되지 않고 새 방식과 자연스럽게 이어졌다.
6. 결과와 회고
정리 작업 이후 체감한 변화는 다음과 같았다.
- !important 사용처가 40여 개에서 5개 미만으로 감소 (남은 것들은 서드파티 위젯 스타일을 강제로 덮어써야 하는, 불가피한 경우였다)
- 신규 스타일 작성 시 "이미 비슷한 게 있는지" 찾는 시간이 체감상 크게 줄었다 (계층화된 변수/mixin 문서를 팀 위키에 정리해둔 효과도 컸다)
- 빌드 시간이 전역 import 중복 제거로 다소 개선
돌아보면 아쉬운 지점도 있다. 중첩 깊이 제한이나 BEM 컨벤션을 린트 규칙으로 강제하지 못하고 코드리뷰에만 의존했던 것이다. stylelint의 selector-max-compound-selectors 같은 규칙을 처음부터 CI에 넣었다면, 사람이 매번 리뷰에서 지적하지 않아도 됐을 텐데, 이 부분은 정리 작업 후반부에야 도입했다.
마치며
Less 자체는 문제가 아니었다. 변수, 중첩, mixin이라는 편의 기능이 "관리를 대신해주는 것"이 아니라 "관리를 더 잘할 수 있게 도와주는 도구일 뿐"이라는 걸 잊고 규칙 없이 써온 게 진짜 문제였다. 결국 어떤 전처리기를 쓰든, CSS Modules로 가든 CSS-in-JS로 가든, 스코프와 명시도를 어떻게 다룰 것인가에 대한 팀의 명시적인 원칙이 없으면 시간이 지나며 다시 같은 문제를 반복하게 될 거라는 확신이 들었다.
참고하면 좋을 자료
- Less 공식 문서의 Merge / Maps 관련 기능 — 변수 계층화에 참고할 만한 기능
- stylelint의 selector-max-compound-selectors 규칙 — 중첩 깊이를 린트로 강제하는 방법
- BEM 공식 방법론 문서 — 클래스명 기반 구조화 컨벤션
'Frontend' 카테고리의 다른 글
| [Frontend] React Error Boundary(죽지 않는 UI 만들기) (0) | 2026.07.14 |
|---|---|
| [Frontend] React key, 그리고 인덱스를 key로 쓰면 생기는 일 (0) | 2026.07.09 |
| [Frontend] Typescript는 빌드할 때, 무슨 일을 하는가? (0) | 2026.07.09 |
| [Frontend] Promis.race와 Promise.allSettled 구현하기 (0) | 2026.07.09 |
| [Frontend] React 19, 실제 프로젝트에 적용해본 후기 (0) | 2026.07.09 |