Sass(SCSS) 활용: 변수, 중첩, Mixin, 함수, @use 모듈, Partials와 빌드 연동

이 글의 핵심

색상과 간격 값이 여러 파일에 흩어지고 선택자가 길어지는 CSS를 Sass로 정리하는 과정을 따라갑니다. 중첩을 과하게 쓰면 선택자 특이도가 높아지는 함정, @import가 권장되지 않는 이유, 변수·Mixin 파티셜로 디자인 토큰을 나누는 구조를 보고 SCSS와 Sass 문법 차이, PostCSS·Tailwind와의 관계도 정리합니다.

이 글의 핵심

Sass(SCSS)의 변수, 중첩, Mixin, 함수, 모듈 시스템을 예제로 정리하고, 각 기능을 쓸 때 실제로 부딪히는 함정과 요즘 CSS 기능과의 관계까지 함께 다룹니다.

실무에서 마주치는 문제들

같은 색상을 반복해요

#3498db 같은 값이 수십 개 파일에 흩어져 있으면 브랜드 색 하나를 바꾸는 데도 전체 검색·치환이 필요하고, 비슷하지만 미묘하게 다른 색(#3499db)이 섞여 들어갑니다. Sass 변수로 한 곳에서 관리합니다.

중첩 선택자가 복잡해요

.card .card-title, .card .card-body, .card:hover처럼 같은 접두어를 반복해 쓰게 됩니다. Sass 중첩으로 구조를 코드 모양 그대로 표현합니다.

재사용 가능한 스타일이 필요해요

버튼 색상 변형처럼 속성 묶음이 반복되면 복사·붙여넣기가 늘고, 한 곳만 고쳐 불일치가 생깁니다. Mixin으로 묶어 재사용합니다.


Sass가 CSS에 더해 주는 것

Sass는 CSS로 컴파일되는 전처리기입니다. 브라우저는 Sass를 모르므로, 빌드 단계에서 .scss 파일을 일반 .css로 변환해 배포합니다. 따라서 Sass의 변수와 함수는 모두 빌드 시점에 계산되어 사라지고, 결과 CSS에는 값만 남습니다.

주요 장점:

  • Variables: 재사용 가능한 값
  • Nesting: 중첩 선택자
  • Mixins: 재사용 가능한 스타일
  • Functions: 계산 가능
  • Modules: 파일 분리

현재 공식 구현은 Dart Sass(npm 패키지 sass) 하나입니다. 예전에 많이 쓰던 node-sass(LibSass 기반)는 2020년에 지원 중단되어 @use, math.div 같은 새 문법을 지원하지 않고, Node 버전이 올라갈 때마다 네이티브 빌드가 깨지는 문제가 있었습니다. 오래된 프로젝트에서 node-sass 설치 중 gyp ERR!가 난다면 sass로 교체하는 것이 정답입니다.


설치와 CLI 컴파일

설치

npm install -D sass

컴파일

npx sass src/styles.scss dist/styles.css

package.json

{
  "scripts": {
    "css": "sass src/styles.scss dist/styles.css",
    "css:watch": "sass --watch src/styles.scss dist/styles.css"
  }
}

Vite나 Webpack을 쓰는 프로젝트라면 이 CLI를 직접 돌릴 일은 거의 없고, 번들러가 .scss import를 만나면 sass 패키지를 호출해 컴파일합니다. CLI는 번들러 없이 정적 사이트를 만들거나, 배포용 CSS 파일 하나를 뽑을 때 씁니다. 프로덕션 빌드라면 --style=compressed와 --no-source-map 옵션을 함께 고려하십시오.


변수로 색상·간격 관리하기

// _variables.scss
$primary-color: #3498db;
$secondary-color: #2ecc71;
$font-size-base: 16px;
$spacing-unit: 8px;
// styles.scss
@use 'variables' as *;
.button {
  background: $primary-color;
  font-size: $font-size-base;
  padding: $spacing-unit * 2;
}

$spacing-unit * 2는 컴파일 시점에 16px로 계산되어 CSS에 들어갑니다. 여기서 Sass 변수와 CSS 사용자 정의 속성(--primary-color)의 차이가 드러납니다. Sass 변수는 컴파일이 끝나면 사라지므로, 다크 모드 전환처럼 런타임에 값을 바꾸는 일은 할 수 없습니다. 반대로 CSS 변수는 런타임에 바뀌지만 Sass 함수(color.adjust 등)에 넣어 계산할 수 없습니다. color.adjust(var(--primary), $lightness: 10%)를 쓰면 $color: var(--primary) is not a color 에러가 나는 이유가 이것입니다.

그래서 요즘 많이 쓰는 방식은 “원천 토큰은 Sass 변수로 두고, 테마로 바뀌어야 하는 값은 CSS 변수로 내보내기”입니다. 예를 들어 :root { --color-primary: #{$primary-color}; }처럼 보간(#{})으로 Sass 값을 CSS 변수에 넣고, 컴포넌트에서는 var(--color-primary)를 쓰면 빌드 타임 계산과 런타임 테마를 모두 얻을 수 있습니다.


선택자 중첩과 & 참조

.card {
  padding: 1rem;
  border: 1px solid #ccc;
  .title {
    font-size: 1.5rem;
    font-weight: bold;
  }
  .content {
    margin-top: 0.5rem;
  }
  &:hover {
    box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
  }
  &.featured {
    border-color: #3498db;
  }
}

이 코드는 .card .title, .card .content, .card:hover, .card.featured 네 개의 선택자로 컴파일됩니다. &는 부모 선택자 자체를 가리키므로, &:hover는 공백 없이 붙고 .title은 공백(자손 결합자)으로 이어집니다. &를 빼고 :hover만 쓰면 .card :hover(자손 중 hover된 모든 요소)가 되어 의도와 전혀 다르게 동작하는데, 처음 Sass를 쓸 때 가장 흔히 하는 실수입니다.

중첩의 진짜 비용은 선택자 특이도(specificity)입니다. 중첩 한 단계마다 클래스가 하나씩 붙으므로, 4단계로 중첩한 .page .sidebar .card .title은 클래스 4개의 특이도를 가집니다. 나중에 이 제목 스타일을 다른 곳에서 덮어쓰려면 그보다 높은 특이도가 필요하고, 결국 !important나 더 깊은 중첩으로 맞서는 악순환이 생깁니다. 저도 레거시 SCSS를 정리하면서 가장 많이 본 문제가 HTML 구조를 그대로 따라 56단계로 중첩한 코드였고, 마크업이 조금만 바뀌어도 스타일이 적용되지 않는 원인이 되었습니다. 중첩은 &:hover, &.is-active, &__element(BEM)처럼 “같은 컴포넌트의 상태와 변형”을 묶는 데만 쓰고, 깊이는 23단계로 제한하는 것이 안전합니다.

참고로 현재 주요 브라우저는 네이티브 CSS 중첩도 지원합니다. 다만 Sass의 &-secondary 같은 문자열 이어붙이기는 네이티브 CSS 중첩에서 지원되지 않으므로, 이 문법에 기대는 코드는 당분간 Sass가 필요합니다.


@mixin과 @include로 스타일 묶음 재사용

// _mixins.scss
@mixin flex-center {
  display: flex;
  justify-content: center;
  align-items: center;
}
@mixin button($bg-color, $text-color) {
  background: $bg-color;
  color: $text-color;
  padding: 0.5rem 1rem;
  border: none;
  border-radius: 4px;
  cursor: pointer;
  &:hover {
    opacity: 0.8;
  }
}
// styles.scss
@use 'mixins' as *;
.container {
  @include flex-center;
}
.btn-primary {
  @include button(#3498db, #fff);
}
.btn-secondary {
  @include button(#2ecc71, #fff);
}

Mixin은 @include하는 자리마다 속성을 복사해 넣습니다. 위 예제에서 .btn-primary와 .btn-secondary는 padding, border, border-radius, cursor를 각각 한 벌씩 가지게 됩니다. 버튼 변형이 두세 개라면 괜찮지만, Mixin을 수십 곳에 include하면 CSS 파일이 눈에 띄게 커집니다. 공통 부분은 .btn 기본 클래스에 한 번만 쓰고 Mixin에는 색상처럼 달라지는 속성만 남기는 것이 출력 크기 면에서 유리합니다(뒤의 디자인 시스템 예제 참고).

Mixin과 비슷한 기능으로 @extend가 있습니다. @extend는 속성을 복사하지 않고 선택자를 합쳐서(.a, .b { ... }) 출력이 작다는 장점이 있지만, 확장된 선택자가 예상하지 못한 곳까지 퍼져 결과 CSS를 읽기 어렵게 만들고, 미디어 쿼리 안에서 바깥 선택자를 extend할 수 없다는 제약이 있습니다. 그래서 대부분의 스타일 가이드는 Mixin을 기본으로 권장합니다.


@function으로 값 계산하기

// _functions.scss
@use 'sass:math';
@use 'sass:color';
@function rem($px) {
  @return math.div($px, 16) * 1rem;
}
@function lighten-color($color, $amount) {
  @return color.adjust($color, $lightness: $amount);
}
// styles.scss
@use 'functions' as *;
.title {
  font-size: rem(24);
  color: lighten-color(#3498db, 10%);
}

Mixin이 “속성 묶음”을 반환한다면 함수는 “값 하나”를 반환합니다. rem(24)는 1.5rem으로 계산됩니다.

예전 예제에서 흔히 보이는 #{$px / 16}rem 형태는 두 가지 이유로 피해야 합니다. 첫째, Dart Sass에서 /로 나누기는 폐기 예정(deprecated)이라 Deprecation Warning: Using / for division outside of calc() is deprecated가 출력되고, 향후 버전에서는 /가 CSS의 구분자(font: 16px/1.5)로만 해석됩니다. sass:math 모듈의 math.div()를 써야 합니다. 둘째, #{} 보간으로 단위를 붙이면 결과가 숫자가 아니라 문자열이 되어, 이후 rem(24) * 2 같은 계산이 불가능해집니다. * 1rem처럼 단위를 곱하면 숫자로 남습니다.

lighten(), darken() 같은 전역 색상 함수도 Dart Sass 1.79부터 폐기 예정으로 경고가 나옵니다. sass:color 모듈의 color.adjust()(고정량 변경)나 color.scale()(남은 범위 대비 비율로 변경)을 씁니다. 두 함수는 결과가 다릅니다. 이미 밝은 색에 color.adjust($lightness: 30%)를 하면 흰색으로 잘려 버리지만, color.scale($lightness: 30%)는 “남은 밝기의 30%만큼” 올리므로 팔레트를 자동 생성할 때 더 고르게 나옵니다.


@import 대신 @use 모듈 시스템

// _variables.scss
$primary-color: #3498db;
// _mixins.scss
@mixin button {
  padding: 0.5rem 1rem;
  border: none;
  border-radius: 4px;
}
// styles.scss
@use 'variables' as vars;
@use 'mixins';
.button {
  @include mixins.button;
  background: vars.$primary-color;
}

@use는 기존 @import를 대체하는 모듈 시스템입니다. 차이는 세 가지입니다.

  1. 네임스페이스: @use 'mixins'로 불러온 멤버는 mixins.button처럼 파일 이름을 접두어로 씁니다. as vars로 이름을 바꾸거나 as *로 접두어 없이 쓸 수도 있지만, as *를 여러 파일에 쓰면 이름 충돌 시 This module and the new module both define a variable named "$primary" 같은 에러가 납니다.
  2. 한 번만 로드: 같은 파일을 여러 곳에서 @use해도 CSS는 한 번만 출력됩니다. @import는 import할 때마다 내용을 다시 넣어서, 리셋 CSS가 번들에 여러 번 중복되는 문제가 흔했습니다.
  3. 전역 스코프 없음: @import로 불러온 변수는 전역이라 어디서 정의됐는지 추적하기 어려웠지만, @use는 명시적으로 불러온 파일의 멤버만 보입니다. 이름이 -나 _로 시작하는 멤버($-private)는 모듈 밖에서 접근할 수 없는 비공개 멤버가 됩니다.

@import는 Dart Sass 1.80부터 폐기 예정 경고가 나오고 Dart Sass 3.0에서 제거될 예정이므로, 새 코드에는 @use와 여러 파티셜을 하나로 묶어 다시 내보내는 @forward를 씁니다. 기존 코드는 공식 마이그레이터(npx sass-migrator module --migrate-deps styles.scss)로 상당 부분 자동 변환할 수 있습니다.


_로 시작하는 partial 파일 나누기

// _reset.scss
* {
  margin: 0;
  padding: 0;
  box-sizing: border-box;
}
// _typography.scss
body {
  font-family: Arial, sans-serif;
  line-height: 1.6;
}
// styles.scss
@use 'reset';
@use 'typography';
@use 'components/button';

파일 이름이 _로 시작하는 파일을 파티셜이라고 하며, Sass CLI로 폴더를 통째로 컴파일할 때 독립 CSS 파일로 출력되지 않습니다. @use 'reset'처럼 밑줄과 확장자를 빼고 불러옵니다. @use의 순서는 CSS 출력 순서가 되므로, 리셋 → 타이포그래피 → 컴포넌트 순으로 두어야 캐스케이드에서 뒤의 스타일이 앞의 스타일을 덮어쓸 수 있습니다.


webpack의 sass-loader 설정

npm install -D sass-loader sass
// webpack.config.js
module.exports = {
  module: {
    rules: [
      {
        test: /\.scss$/,
        use: ['style-loader', 'css-loader', 'sass-loader'],
      },
    ],
  },
};

로더는 배열의 오른쪽에서 왼쪽 순서로 실행됩니다. sass-loader가 SCSS를 CSS로 바꾸고, css-loader가 url()과 @import를 해석하며, style-loader가 결과를 <style> 태그로 페이지에 넣습니다. 프로덕션에서는 style-loader 대신 MiniCssExtractPlugin.loader를 써서 별도 CSS 파일로 뽑아야 JS가 로드되기 전에 스타일이 적용됩니다. 순서를 거꾸로 적으면 Module parse failed: Unexpected token 같은 에러가 납니다.


Vite에서 Sass 쓰기

npm install -D sass
// vite.config.ts
import { defineConfig } from 'vite';
export default defineConfig({
  css: {
    preprocessorOptions: {
      scss: {
        additionalData: `@use "@/styles/variables" as *;`,
      },
    },
  },
});

Vite는 sass 패키지만 설치되어 있으면 별도 로더 없이 .scss를 처리합니다. additionalData는 모든 SCSS 파일의 맨 앞에 문자열을 붙여 주므로, 컴포넌트마다 변수 파일을 @use하지 않아도 됩니다. 여기에는 변수·Mixin·함수처럼 CSS를 출력하지 않는 파일만 넣어야 합니다. 리셋 CSS처럼 실제 규칙이 있는 파일을 넣으면 SCSS 파일 수만큼 같은 CSS가 반복 출력됩니다. 또 _variables.scss 자신도 이 문자열을 받으므로, 그 파일이 다시 자기를 @use하게 되면 Module loop: this module is already being loaded 에러가 날 수 있습니다.


예제: 변수·믹스인·컴포넌트로 나눈 디자인 시스템

구조

styles/
├── _variables.scss
├── _mixins.scss
├── _functions.scss
├── base/
│   ├── _reset.scss
│   └── _typography.scss
├── components/
│   ├── _button.scss
│   ├── _card.scss
│   └── _input.scss
└── main.scss

토큰(_variables)과 도구(_mixins, _functions)는 CSS를 출력하지 않고, base/와 components/만 실제 규칙을 출력합니다. main.scss는 base와 components를 순서대로 @use만 하는 진입점입니다. 이렇게 “출력하는 파일”과 “출력하지 않는 파일”을 나눠 두면 위 Vite additionalData에 무엇을 넣어도 안전한지 명확해집니다.

_variables.scss

// Colors
$primary: #3498db;
$secondary: #2ecc71;
$danger: #e74c3c;
$gray-100: #f8f9fa;
$gray-900: #212529;
// Spacing
$spacing-unit: 8px;
$spacing-xs: $spacing-unit;
$spacing-sm: $spacing-unit * 2;
$spacing-md: $spacing-unit * 3;
$spacing-lg: $spacing-unit * 4;
// Typography
$font-family-base: 'Arial', sans-serif;
$font-size-base: 16px;
$line-height-base: 1.6;

_mixins.scss

@use 'variables' as *;
@mixin button-variant($bg, $color) {
  background: $bg;
  color: $color;
  padding: $spacing-sm $spacing-md;
  border: none;
  border-radius: 4px;
  cursor: pointer;
  &:hover {
    opacity: 0.8;
  }
  &:disabled {
    opacity: 0.5;
    cursor: not-allowed;
  }
}
@mixin responsive($breakpoint) {
  @if $breakpoint == sm {
    @media (min-width: 640px) { @content; }
  } @else if $breakpoint == md {
    @media (min-width: 768px) { @content; }
  } @else if $breakpoint == lg {
    @media (min-width: 1024px) { @content; }
  }
}

responsive Mixin의 @content는 include하는 쪽에서 넘긴 블록이 들어갈 자리입니다. .sidebar { @include responsive(md) { display: block; } }처럼 쓰면 @media (min-width: 768px) { .sidebar { display: block; } }가 출력됩니다. 이 Mixin에는 약점이 하나 있는데, responsive(xl)처럼 정의되지 않은 값을 넘기면 아무것도 출력하지 않고 조용히 넘어간다는 점입니다. 마지막에 @else { @error "Unknown breakpoint: #{$breakpoint}"; }를 추가하면 오타를 컴파일 에러로 잡을 수 있습니다. 브레이크포인트를 맵($breakpoints: (sm: 640px, ...))으로 두고 map.get으로 꺼내면 @if 분기를 늘리지 않아도 됩니다.

components/_button.scss

@use '../variables' as *;
@use '../mixins' as *;
.btn {
  @include button-variant($primary, #fff);
  &-secondary {
    @include button-variant($secondary, #fff);
  }
  &-danger {
    @include button-variant($danger, #fff);
  }
  &-sm {
    padding: $spacing-xs $spacing-sm;
    font-size: 0.875rem;
  }
  &-lg {
    padding: $spacing-md $spacing-lg;
    font-size: 1.125rem;
  }
}

&-secondary는 중첩 안에 있지만 .btn .btn-secondary가 아니라 .btn-secondary로 컴파일됩니다. & 뒤에 문자열을 이어 붙이면 선택자 이름 자체가 바뀌기 때문입니다. 덕분에 특이도는 클래스 하나로 유지되고 <button class="btn btn-secondary btn-sm">처럼 조합할 수 있습니다.

다만 이 예제에서는 button-variant가 padding·border·cursor까지 포함하고 있어 .btn, .btn-secondary, .btn-danger가 같은 속성을 세 번 출력합니다. 또 색상 변형도 padding을 선언하기 때문에, btn-secondary btn-sm 조합이 작은 버튼이 되는 것은 순전히 &-sm이 &-secondary보다 뒤에 적혀 있어서입니다. 특이도가 같은 규칙끼리는 나중에 나온 쪽이 이기므로, 누군가 크기 변형을 파일 위쪽으로 옮기면 작은 버튼이 조용히 기본 크기로 돌아갑니다. 이런 취약성은 Mixin을 “색상만 바꾸는 것”으로 좁혀 크기 관련 속성이 한 곳에서만 선언되게 하면 사라집니다. 제가 Sass로 컴포넌트 스타일을 짤 때 가장 자주 확인하는 것도 이 부분인데, 컴파일된 CSS를 한 번 열어 출력 순서와 중복을 눈으로 보는 습관이 Mixin 남용을 막는 가장 확실한 방법이었습니다.


Sass 도입 요점

  • Sass 변수와 함수는 빌드 시점에 계산됩니다. 런타임 테마가 필요하면 CSS 변수와 함께 씁니다.
  • 중첩은 상태·변형을 묶는 데만 쓰고 2~3단계로 제한해 특이도를 낮게 유지합니다.
  • Mixin은 속성을 복사하므로, 공통 부분은 기본 클래스에 두고 달라지는 속성만 Mixin에 남깁니다.
  • 나누기는 math.div(), 색상 조정은 sass:color, 파일 불러오기는 @use/@forward를 씁니다. /, lighten(), @import는 모두 폐기 예정입니다.

같이 보면 좋은 글


자주 묻는 질문 (FAQ)

Q. SCSS와 Sass의 차이는 뭔가요?

A. 둘 다 같은 Sass 언어의 두 가지 문법입니다. SCSS(.scss)는 중괄호와 세미콜론을 쓰는 CSS 상위 호환 문법이라 기존 CSS를 그대로 붙여 넣어도 동작하고, 들여쓰기 문법(.sass)은 중괄호와 세미콜론을 생략합니다. 기존 CSS와의 호환성 때문에 대부분의 프로젝트가 SCSS를 씁니다.

Q. PostCSS와 비교하면 어떤가요?

A. 역할이 다릅니다. Sass는 자체 언어(변수, Mixin, 제어문)를 CSS로 컴파일하는 전처리기이고, PostCSS는 이미 CSS인 코드를 플러그인으로 변환하는 도구(autoprefixer, 압축 등)입니다. Sass로 컴파일한 결과를 PostCSS로 후처리하는 조합이 흔합니다.

Q. Tailwind CSS와 함께 사용할 수 있나요?

A. 가능하지만 권장되는 조합은 아닙니다. Tailwind 공식 문서도 Sass 같은 전처리기와의 병용을 권하지 않으며, Tailwind v4는 전처리기 없이 쓰도록 설계되었습니다. Tailwind를 쓴다면 변수와 중첩은 CSS 변수와 네이티브 중첩으로 대체하는 편이 설정이 단순합니다.

Q. Sass 메이저 업그레이드 때 빌드가 깨지지 않게 하려면?

A. node-sass가 아닌 sass(Dart Sass)를 쓰고, 폐기 예정 경고를 무시하지 말고 정리해 두어야 메이저 업그레이드 때 빌드가 깨지지 않습니다.