1. 들어가며...
(1) 전역 CSS은 언제 편할까?
현재 회사에서 작업 중인 Vue 프로젝트는 main.js
에서 전역 CSS를 한꺼번에 import
하고 있다.
import { createApp } from 'vue';
import App from './App.vue';
import router from './router';
import './assets/base.css';
import './assets/front.css';
import './assets/layout.css';
import './assets/reset.css';
import './assets/setting-market.css';
createApp(App).use(router).mount('#app');
- 이렇게 CSS를 전역을 import 하면 좋은 점은 있다.
- 한 번의 네트워크 요청으로 모든 스타일을 내려받기에 FOUC(Flash of Unstyled Content)를 막고,
- 프로젝트 규모가 작은 경우, 관리 포인트가 한 곳으로 집중되어 유지보수가 쉽다.
FOUC(Flash of Unstyled Content) 예시 - 화면 로드시 잠깐 동안 스타일이 적용되지 않음
(2) 전역 CSS의 그림자
그러나 전역 CSS는 서비스가 커질수록 처음 화면이 뜨기까지 지연을 발생시킬 수 있다 🫠
- 한 예로 토스(Toss) 앱처럼 100개가 넘는 서비스를 SPA 앱에 담은 경우를 생각해보자
- 토스에는 혜택, 쇼핑, 증권, 자동차 보험, 고양이 키우기 등 수십 개의 메뉴가 있다.
- 그런데 만약 CSS를 모두 전역으로 관리하면, 사용자가 실제로 몇 개 메뉴만 방문하더라도 모든 CSS를 내려받는 구조가 된다.
- 결과적으로 네트워크 대기 시간은 물론 CSSOM 생성 시간이 누적되어, FCP(First Contentful Paint)와 LCP(Largest Contentful Paint)가 지연된다.
(3) 왜 느려질까? — 브라우저 렌더링 파이프라인
- 왜 렌더링 지연이 발생하는 걸까? 그 이유는 브라우저 렌더링 과정을 보면 알 수 있다.
- 브라우저는 페이지를 그리기 위해 다음 과정을 거친다:
- HTML 파싱 → DOM 트리 생성
- CSS 파싱 → CSSOM 트리 생성
- DOM + CSSOM → Render Tree 조합 (단,
display:none
요소나<head>
같이 시각적 출력을 갖지 않는 노드는 이 단계에서 제외됨) - Layout(혹은 Reflow): 각 노드의 정확한 위치·크기 계산
- Paint: 픽셀 단위로 스타일을 채색
- Composite: 여러 레이어를 GPU에서 합성(필요한 경우)
- 렌더링 파이브라인에서 핵심 포인트는 2가지이다.
- HTML 파서는
<link rel="stylesheet">
를 만나면 CSS 다운로드·파싱이 끝날 때까지 멈춘다는 점, - 그리고 CSSOM이 완성돼야 Render Tree를 만들 수 있기에, 전역 CSS가 크면 클수록 FCP/LCP가 늦어진다는 점이다.
잠깐! 그럼 CSS를 전부 분할하면 모든 문제가 해결될까?
- 아니다! 만약 전역으로 적용되어야할 스타일(컬러 변수, layout)이 있다면
- 전역으로 import하지 않으면 SPA 구조에서는 FOUC 현상이 발생한다.
- 그럼 어떻게 구성해야 global CSS, split CSS의 장점을 챙길 수 있을까?
2. Global css, Split css를 잘 쓰는 법!
- 전역 CSS(Global)만 쓰면 초기 로딩·스타일 일관성이 편하지만, 불필요한 코드 전송·사이드이펙트가 쌓인다.
- 반대로 파일·페이지·채널 단위로 분할하면 번들 사이즈·특정성 관리가 쉬워지지만, 라우팅 시 추가 네트워크 비용과 레이어 우선순위 충돌을 신경 써야 한다.
- 결국 “성능 vs 유지보수”를 동시에 잡는 하이브리드 전략이 필요하다.
1) 두 개의 강점을 조합해보자!
전역 CSS 환경의 프로젝트를 전역 & 분할(Split) CSS의 강점을 모두 살리는 방식으로 변경해보자!
(1) 프로젝트 구조
- 실험을 위해 프로젝트를 아래와 같이 구성했다.
src/
├── assets/
│ ├── base.css # pl100, mt10 등 유틸리티 클래스 관리
│ ├── front.css # 색상 변수 및 페이지별 스타일 관리
│ ├── layout.css # 레이아웃 전용 스타일
│ ├── reset.css # 브라우저 기본 스타일 리셋
│ └── setting-market.css # MarketPage 전용 스타일
├── pages/
│ ├── DomainPage.vue
│ ├── HandlePage.vue
│ ├── MarketPage.vue
│ └── StorePage.vue
├── App.vue
├── main.ts
└── router.ts
- 페이지는 총 4개이며, 현재는 이 모든 페이지의 스타일을 front.css 하나에 몰아 관리하고 있다.
(2) 변경 1: 전역으로 꼭 필요한 스타일만 관리
- 기존에는 모든 CSS를 전역에서 로드했다.
- 심지어 front.css와 setting-market.css처럼 특정 페이지에서만 사용하는 스타일조차 전역에 포함되어 있었다.
<html lang="en">
<head>
<link rel="stylesheet" href="./src/assets/base.css" />
<link rel="stylesheet" href="./src/assets/front.css" />
<link rel="stylesheet" href="./src/assets/layout.css" />
<link rel="stylesheet" href="./src/assets/reset.css" />
<link rel="stylesheet" href="./src/assets/setting-market.css" />
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
- 이 구조는 초기 로드 시 불필요한 CSS까지 모두 내려받게 되어 렌더링 성능에 영향을 준다.
그럼 전역 CSS 구조를 어떻게 바꿔야할까?
- 전역에서 꼭 필요한 핵심 CSS만 로드하도록 변경한다. (reset.css, layout.css, base.css)
- 그라고 페이지별로 필요한 스타일은 각 페이지 내부로 옮긴다.
<html lang="en">
<head>
<!-- preload로 우선 다운로드 -->
<link rel="preload" href="./src/assets/base.css" as="style" />
<link rel="preload" href="./src/assets/layout.css" as="style" />
<link rel="preload" href="./src/assets/reset.css" as="style" />
<!-- defer 스타일 적용 -->
<link rel="stylesheet" href="./src/assets/base.css" />
<link rel="stylesheet" href="./src/assets/layout.css" />
<link rel="stylesheet" href="./src/assets/reset.css" />
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
- ✅ 변경 포인트:
- base.css: 유틸리티 클래스와 색상 변수만 남김
- layout.css: 레이아웃 시스템 유지
- reset.css: 기본 스타일 리셋
- front.css: 색상 변수만 base.css로 통합(아직 페이지별 스타일은 남아있음)
- setting-market.css: MarketPage에서만 필요하므로 MarketPage.vue 내부로 이동
(3) 변경 2: 특정 페이지에서만 쓰이는 스타일은 페이지 내부로 이동
- front.css에서 색상 변수만 base.css로 분리했지만, 여전히 특정 페이지에서만 사용하는 스타일들이 front.css에 남아 있다.
- 이 스타일들을 각 페이지 컴포넌트(
.vue
)의 inline<style scoped>
영역으로 이동시켜주자! - 이렇게 하면, 필요한 스타일만 로드되어 초기 리소스 다운로드와 스타일 파싱 비용이 줄일 수 있다.
<!-- StorePage.vue 내부로 스타일을 이동한 예시 -->
<template>
...
</template>
<style scoped>
/* vue의 inline style로 이동 */
.store-page {
display: flex;
flex-direction: column;
align-items: center;
padding: 20px;
}
.store-header {
background-color: #f8f9fa;
padding: 20px;
width: 100%;
text-align: center;
}
.store-title {
font-size: 2em;
color: #343a40;
}
.store-description {
display: flex;
justify-content: space-between;
width: 100%;
margin: 20px 0;
}
</style>
- ✅ 변경 포인트
- StorePage에 필요한 스타일만 별도로 로드 → 초기 렌더링 가벼움
<style scoped>
를 사용해 스타일 충돌 없이 로컬 범위로 적용- 다른 페이지에 불필요한 스타일 로딩/파싱이 일어나지 않음
2) 실험을 해보자!
앞에서 전역 CSS를 정리하고, 페이지별 스타일을 분리했다. (결과 레포지토리)
이 최적화가 실제 성능에 어떤 영향을 미쳤는지 확인하기 위해, puppeteerJS를 사용해 페이지 로딩 성능을 측정해 보았다.
(1) puppeteerJS 사전 설정
- 캐시 비활성화 (
page.setCacheEnabled(false)
) - → 매번 리소스를 새로 요청하여 정확한 초기 로드 성능 측정
- CSS 리소스 타이밍 수집
- →
PerformanceObserver
를 사용해 CSS 파일별 로드/파싱 시간 기록 - DOMContentLoaded 이벤트까지 대기 (
waitUntil: 'domcontentloaded'
) - FCP(First Contentful Paint) 실시간 감지
- →
paint
타입 PerformanceObserver 사용, 5초 타임아웃 + 폴백(DCL) - 50회 반복 측정 → 평균 및 표준편차 산출
✅ 이렇게 설정하여, 데이터 편차를 최소화하고 최적화 효과를 명확히 비교할 수 있도록 했다.
(2) Store 페이지 최적화 사례
Store 페이지 진입 시, 필요한 CSS 약 30줄만 로드하도록 최적화한 경우
지표 | 최적화 전 | 최적화 후 | 변화 |
---|---|---|---|
평균 FCP | 67.92 ms | 62.80 ms | ↓ 5.12 ms (−7.5%) - 초기 페인트 7.5% 이상 빠름 |
평균 CSS Blocking | 2.62 ms | 2.34 ms | ↓ 0.28 ms (−10.7%) - 렌더 차단 시간 감소 |
- 최적화 전에는 모든 CSS를 global로 로드했었다.
- 그러나 필요하지 않은 CSS를 제거하고, 페이지별로 필요한 스타일만 로드하는 방식으로 최적화한 후, 초기 렌더링 속도가 개선되었다.
- 추가로 Chrome DevTools의 Performance 탭에서도, 최적화 전후 렌더링 지연 차이를 확실히 확인할 수 있었다.
3) 고려할 점: Cascade Layers로 스타일 우선순위 고정하기
앞서 전역 CSS를 정리하고 페이지별 스타일을 분리하면서 초기 렌더링 지연을 줄일 수 있었다.
하지만, 이렇게 CSS를 분할하게 되면 스타일 충돌이나 우선순위 꼬임이 발생할 수 있다 😟
이를 해결하기 위해 Cascade Layers 를 함께 도입하면 좋다.
(1) Cascade Layers가 필요한 이유
- CSS에서는 specificity(선택자 복잡성)와 source order(작성 순서)에 따라 스타일 우선순위가 결정했다.
- 하지만 프로젝트가 커질수록 예상치 못한 스타일 충돌이나 우선순위 문제를 관리하기 어려워졌다.
- 이런 문제를 해결하기 위해 등장한 것이 바로 Cascade Layers이다.
- Cascade Layers는 CSS 스타일을 논리적인 그룹(레이어) 으로 나누고,
- 그룹 간의 우선순위를 명시적으로 통제할 수 있는 기능이다.
쉽게 말해, Cascade Layers는 “reset 스타일은 base 스타일보다 먼저 적용되고",
"컴포넌트 스타일은 레이아웃 스타일보다 나중에 적용된다" 같은 규칙을 명확하게 약속하는 방법이다.
- 레이어끼리 우선순위를 먼저 비교하고, 그 다음에야 기존의 specificity나 source order를 비교한다.
- 덕분에 대규모 프로젝트에서도 스타일 충돌 없이 안정적으로 스타일을 관리할 수 있다.
(2) Vue에서 Cascade Layers를 쓰는 방법
- Vue 컴포넌트에서도 이 Cascade Layers를 활용할 수 있다.
- 먼저, 프로젝트 전역 파일(main.ts나 global.css 등)에서 다음과 같이 레이어 순서를 딱 한 번 선언해준다.
@layer reset, base, layout, components, overrides;
- 이후, 각 레이어에 해당하는 스타일들을 다음과 같이 구성할 수 있다.
@layer reset {
/* 브라우저 기본 스타일 초기화 */
*, *::before, *::after {
margin: 0;
padding: 0;
box-sizing: border-box;
}
}
@layer base {
/* 공통 폰트, 색상 변수, 유틸리티 클래스 */
body {
font-family: 'Pretendard', sans-serif;
color: var(--text-color);
background-color: var(--bg-color);
}
.mt-10 {
margin-top: 10px;
}
}
@layer layout {
/* 레이아웃 시스템 (그리드, 플렉스 정렬 등) */
.container {
max-width: 1200px;
margin: 0 auto;
padding: 0 20px;
}
.row {
display: flex;
flex-wrap: wrap;
}
}
- 레이어 순서를 고정했으니 그 다음으로 각 컴포넌트에서는
<style scoped>
안에 자신이 속할 레이어를 지정하여 스타일을 작성한다. - 예를 들어, 일반적인 컴포넌트 스타일은
components
레이어에 묶는다:
<template>
<div class="store-page">
<h1 class="store-title">스토어</h1>
</div>
</template>
<style scoped>
@layer components {
.store-page {
padding: 24px;
background-color: #f8f9fa;
}
.store-title {
font-size: 2rem;
color: var(--primary-color);
}
}
</style>
- 이 방식으로 CSS를 선언하면,
reset → base → layout → components → overrides
순서로 스타일이 적용되어, - 다른 파일이나 컴포넌트와 충돌할 걱정 없이 일관된 스타일 우선순위를 유지할 수 있다.
만약, 특정 페이지에서 기존 스타일을 오버라이드하고 싶다면 어떻게 해야할까?
- 이 경우,
overrides
레이어를 별도로 사용하면 된다.
<!-- DomainPage.vue -->
<template>
<div class="store-title">도메인 페이지</div>
</template>
<style scoped>
@layer overrides {
.store-title {
color: darkred;
}
}
</style>
3. 마치며…
이번 시간에는 전역 CSS의 강점을 살리면서도 약점을 보완하는 방법을 살펴보았다.
그중에서도, 전역 CSS를 정리하고 페이지별로 필요한 스타일만 로드하는 것만으로도
초기 렌더링 성능(FCP, CSS Blocking)이 개선된다는 사실을 직접 확인할 수 있었다.
추가로 스타일 관리의 명확성과 유지보수성까지 함께 고려하는 게 중요하다.
- Cascade Layers를 활용하여 스타일 충돌 없이 안정적이고 일관된 스타일 우선순위를 설계하고
- Split CSS를 통해 각 페이지별로 스타일을 관리하면, 스타일 변경에 따른 사이드 이펙트를 예방할 수 있다.
결국 중요한 것은 '규모가 커질수록 더 단순하게' 관리하는 방향을 택하는 것이다.
스타일이 어디에 속하는지, 어떤 책임을 갖는지를 명확히 구분할수록,
변화에 유연하면서도 품질을 유지하는 시스템을 갖출 수 있다.
'개발 기술 > 개발 이야기' 카테고리의 다른 글
Vue 3 <script setup> 선언 순서를 자동 정렬하는 ESLint 룰 개발기 (0) | 2025.02.28 |
---|---|
테스트 코드를 관리하는 법 2: 커버리지 감소 검사하기 (0) | 2025.01.24 |
테스트 코드를 관리하는 법 1, Pre-Push 단계에서 테스트 자동화하기 (0) | 2025.01.01 |
아이콘 컴포넌트 렌더링 방식, 정말 좋을까? (with. 빌드 시간, FID, TBT 등 비교) (0) | 2024.08.11 |
[JS/CSS] corner smoothing을 구현하는 법(feat. 부드러운 둥근 모서리) (0) | 2024.06.18 |
댓글