개발 기술/개발 이야기

[CSS] SPA에서 Global과 Split CSS, 두 장점을 모두 살리는 방법

by GicoMomg 2025. 4. 27.

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) 왜 느려질까? — 브라우저 렌더링 파이프라인

  • 왜 렌더링 지연이 발생하는 걸까? 그 이유는 브라우저 렌더링 과정을 보면 알 수 있다.
  • 브라우저는 페이지를 그리기 위해 다음 과정을 거친다:

  1. HTML 파싱 → DOM 트리 생성
  2. CSS 파싱 → CSSOM 트리 생성
  3. DOM + CSSOM → Render Tree 조합 (단, display:none 요소나 <head> 같이 시각적 출력을 갖지 않는 노드는 이 단계에서 제외됨)
  4. Layout(혹은 Reflow): 각 노드의 정확한 위치·크기 계산
  5. Paint: 픽셀 단위로 스타일을 채색
  6. 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.csssetting-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)이 개선된다는 사실을 직접 확인할 수 있었다.

추가로 스타일 관리의 명확성과 유지보수성까지 함께 고려하는 게 중요하다.

  1. Cascade Layers를 활용하여 스타일 충돌 없이 안정적이고 일관된 스타일 우선순위를 설계하고
  2. Split CSS를 통해 각 페이지별로 스타일을 관리하면, 스타일 변경에 따른 사이드 이펙트를 예방할 수 있다.

결국 중요한 것은 '규모가 커질수록 더 단순하게' 관리하는 방향을 택하는 것이다.
스타일이 어디에 속하는지, 어떤 책임을 갖는지를 명확히 구분할수록,
변화에 유연하면서도 품질을 유지하는 시스템을 갖출 수 있다.



반응형

댓글