문서 사이트를 관리한다고 가정해 봅시다. 홈페이지의 글 카드에는 얇은 테두리, 둥근 모서리, 여백이 있고, 옆의 버전 알림에도 거의 같은 외곽 스타일이 있습니다. CSS 몇 줄을 복사해 테두리 색을 바꾸면 두 영역이 각각 완성됩니다.

몇 주 뒤 사이트 전체의 컨테이너 간격을 조정하게 됩니다. 카드는 고쳤지만 알림 영역 한 곳을 놓칩니다. 이때 “처음부터 mixin으로 만들었으면 잊지 않았을 텐데”라는 생각이 들기 쉽습니다.

하지만 다른 상황도 생길 수 있습니다. 알림은 더 촘촘해야 하고, 카드에는 읽기 편한 여백이 남아 있어야 합니다. 둘이 하나의 추상화에 묶여 있다면 한 번의 수정이 바꾸지 말아야 할 부분까지 건드립니다.

중복 코드는 단서를 줍니다. 그다음에는 판단이 필요합니다. 이 스타일들은 같은 규칙을 나타내며, 앞으로도 함께 바뀌어야 할까요? 네이티브 CSS @mixin에 주목할 이유는 스타일 규칙 묶음에 이름을 붙이고, 사용하는 곳에서 차이를 전달할 수 있는 가능성에 있습니다.

함께 바뀌어야 할 규칙부터 찾기

가상의 문서 사이트에서 요구를 좀 더 구체적으로 정해 봅시다. 글 카드와 버전 알림에는 눈에 거슬리지 않는 외곽선이 필요하고 모서리 형태는 같습니다. 테두리와 제목은 같은 강조색을 쓰며, 알림은 여백이 더 좁아도 됩니다.

두 컨테이너는 담는 내용이 다르지만 시각 규칙의 일부를 공유합니다. 문제는 중복된 몇 줄을 없애는 방법만이 아닙니다. 어떤 결정은 공통 출처를 가져야 하고, 어떤 결정은 각 컴포넌트 안에 남아야 하는지가 중요합니다.

예를 들어 모서리 반경은 사이트 전체의 시각적 약속이므로 중앙에서 관리할 수 있지만, 안쪽 여백은 정보 밀도와 관련될 수 있습니다. 카드에 요약을 표시할지, 알림에 동작 링크가 필요한지는 각각의 책임입니다. 외곽을 공유한다는 이유로 함께 묶을 필요는 없습니다.

이 구분이 추상화의 크기를 결정합니다. “무엇을 공유하는가”조차 설명하기 어렵다면 몇 줄의 중복을 남기는 편이 조정하기 쉬울 수 있습니다. 사용 맥락이 드러나야 정말 안정적인 부분을 묶을 근거가 생깁니다.

사용자 지정 속성과 공통 class로 어디까지 할 수 있을까?

우선 mixin 없이 시작합니다. 아래 HTML은 이후 예제에서도 사용하는 공통 구조입니다.

<article class="site-card">
  <h2 class="site-panel-title">Installation guide</h2>
  <p>Start with your project's first page.</p>
</article>
 
<aside class="site-notice">
  <h2 class="site-panel-title">Release notice</h2>
  <p>Read the changelog before upgrading.</p>
</aside>

선택자 그룹과 사용자 지정 속성만으로도 외곽 스타일을 한곳에 모을 수 있습니다. 다음은 기존 CSS를 사용한 예제입니다.

:root {
  --panel-radius: 0.5rem;
}
 
.site-card,
.site-notice {
  border: 1px solid var(--panel-accent, #777777);
  border-radius: var(--panel-radius);
  padding: var(--panel-space, 1.5rem);
}
 
.site-card > .site-panel-title,
.site-notice > .site-panel-title {
  margin-block: 0 0.75rem;
  color: var(--panel-accent, #777777);
}
 
.site-notice {
  --panel-accent: #45687d;
  --panel-space: 1rem;
}

여기에는 두 종류의 재사용이 있습니다. 선택자 그룹은 서로 다른 요소가 선언을 공유하게 하고, 사용자 지정 속성은 바꿀 수 있는 값을 제공합니다. var()는 속성값 안에서 변수를 대입합니다. 그 자체가 border, padding, 중첩 규칙 묶음을 다른 규칙에 삽입하는 것은 아닙니다. 이 대입 방식은 사용자 지정 속성 명세에 정의되어 있습니다.

HTML을 바꿀 수 있다면 공통 규칙을 .site-panel에 두고, class="site-panel site-card"와 class="site-panel site-notice"를 사용할 수도 있습니다. 공통 class는 외곽을 나타내고, 다른 class는 각 컴포넌트의 역할을 나타냅니다. 색과 안쪽 여백은 계속 사용자 지정 속성으로 제어합니다.

컨테이너가 두 종류뿐인 이 작은 사이트라면 저는 먼저 이 방식을 선택하겠습니다. 선언이 모여 있고 차이가 보이며, 추가적인 호출 관계를 따라갈 필요가 없습니다. 나중에 세 번째 컨테이너가 생겨도 같은 외곽을 공유한다면 동일한 class를 추가하는 방식이 명확합니다.

공통 규칙을 여러 컴포넌트 스타일에서 조합해야 하거나, 각 위치에서 서로 다른 설정을 명시적으로 전달해야 하거나, 여러 출처의 HTML에 공통 class를 붙이기 어렵다면 mixin은 다른 정리 방식을 제공합니다. 적용 위치를 CSS 안에 두면서 어떤 규칙 묶음을 선택했는지 이름으로 표현할 수 있습니다.

재사용하려는 대상먼저 고려할 방법관리해야 할 관계
색, 여백, 모서리 반경 같은 값사용자 지정 속성변수의 출처, 상속, 덮어쓰기
완전히 같은 선언 묶음공통 class 또는 선택자 그룹어떤 요소가 함께 수정되어야 하는가
매개변수를 받고 다른 규칙에서 조합하는 스타일 블록네이티브 mixin 초안호출 위치, 매개변수, 블록의 책임

세 가지는 함께 사용할 수 있습니다. mixin 안에서 디자인 토큰을 참조할 수 있고, 공통 class에 mixin을 적용할 수도 있습니다. 선택의 핵심은 변경의 책임을 더 분명하게 만드는 데 있습니다.

@mixin으로 정의하고 @apply 위치에 적용하기

외곽 스타일을 mixin으로 바꾸면 최소 형태는 다음과 같습니다. 이 예제와 이후의 mixin 예제는 모두 초안 문법입니다.

/* Draft syntax: definition and application */
@mixin --panel-frame() {
  border: 1px solid #777777;
  border-radius: 0.5rem;
  padding: 1.5rem;
}
 
.site-card {
  @apply --panel-frame();
}
 
.site-notice {
  @apply --panel-frame();
}

@mixin은 재사용할 블록을 정의하며 이름은 --로 시작합니다. @apply는 적용 위치를 지정합니다. 초안의 정의와 적용 규칙에 따르면 mixin 정의는 일반 스타일 규칙 안에 둘 수 없습니다. @apply는 스타일 규칙이나 그 안의 중첩 그룹 규칙에서 사용합니다. 그래서 위 예제는 정의를 최상위에 두고 각 선택자 안에서 호출합니다.

우선 호출 위치에 선언과 규칙 묶음을 가져온다고 이해할 수 있습니다. 다만 이는 읽기를 돕는 모델일 뿐입니다. 매개변수, 변수, 범위가 관련될 때 임의의 텍스트를 복사해 붙이는 것으로 생각해서는 안 됩니다.

매개변수가 없다면 현재 초안은 @apply --panel-frame;처럼 괄호를 생략할 수 있게 합니다. 이 글에서는 호출을 알아보기 쉽고 이후 매개변수를 추가할 때 일관성을 유지하도록 괄호를 남깁니다. @apply에 class 이름을 전달하는 것이 아니라, 정의된 mixin 이름을 참조하는 것입니다.

이제 강조색과 안쪽 여백을 조절 가능한 부분으로 만듭니다. 아래는 앞의 정의를 대체하는 전체 CSS이며, 앞서 제시한 HTML과 함께 사용합니다.

/* Draft syntax: parameters, defaults, and nested rules */
@mixin --panel-frame(
  --accent <color>: #777777,
  --space <length>: 1.5rem
) {
  border: 1px solid var(--accent);
  border-radius: 0.5rem;
  padding: var(--space);
 
  & > .site-panel-title {
    margin-block: 0 0.75rem;
    color: var(--accent);
  }
}
 
.site-card {
  @apply --panel-frame();
}
 
.site-notice {
  @apply --panel-frame(#45687d, 1rem);
}

--accent는 색을, --space는 길이를 받습니다. 콜론 뒤는 인수를 생략했을 때 쓰는 기본값입니다. 카드는 기본 설정을 사용하고, 알림에는 청회색과 더 작은 여백을 전달합니다. 타입과 기본값의 표기는 초안의 매개변수 문법을 따릅니다. 예를 들어 1.5rem은 <length>에 맞지만 백분율은 이 타입에 포함되지 않습니다.

두 매개변수만 둔 것은 앞에서 확인한 차이에 대응하기 위해서입니다. 모서리 반경은 공통 규칙으로 남고, 제목은 외곽의 강조색을 따릅니다. 호출 위치를 읽으면 이 컴포넌트가 어떤 변형을 선택했는지 알 수 있습니다.

매개변수를 var()로 읽더라도 요소에 일반적인 공개 사용자 지정 속성을 추가하는 것과 같지는 않습니다. 현재 매개변수 모델에서 같은 이름의 매개변수는 외부 값을 가리며, 비공개 범위가 어떤 요소에서 값을 얻을 수 있는지도 제한합니다. 위 예제는 컨테이너와 자식 요소만 다룹니다. 형제 요소를 선택하도록 바꿨을 때도 매개변수를 사용할 수 있다고 바로 가정해서는 안 됩니다.

중첩된 & > .site-panel-title은 “외곽과 제목이 강조색을 공유한다”는 내용을 하나의 규칙으로 만듭니다. 동시에 제목이 컨테이너의 직접 자식이어야 한다는 구조적 약속도 생깁니다. 이 구조가 없는 컴포넌트가 있다면 모든 경우를 포용하도록 선택자를 늘리기보다 제목과 외곽 규칙을 분리해야 합니다.

@contents로 조건을 공유하고 내용은 호출자에게 남기기

새 요구사항을 추가해 봅시다. 화면이 좁아지면 카드와 알림 모두 바뀌어야 하지만 바뀌는 내용은 다릅니다. 카드는 더 조밀하게 배치하고, 알림은 안쪽 여백을 줄이면서 옆 테두리를 강조합니다.

페이지 전체에서 사용하는 공통의 좁은 화면 조건이라면, 조건에 이름을 붙이고 @contents로 스타일을 넣을 자리를 만들 수 있습니다. 다음 초안 예제는 이전 절의 CSS 뒤에 이어집니다.

/* Draft syntax: shared condition, caller-supplied content */
@mixin --compact-layout() {
  @media (width <= 40rem) {
    @contents;
  }
}
 
.site-card {
  @apply --compact-layout() {
    display: grid;
    gap: 0.75rem;
    padding: 1rem;
  }
}
 
.site-notice {
  @apply --compact-layout() {
    padding: 0.75rem;
    border-inline-start-width: 3px;
  }
}

@apply 뒤의 블록은 호출자가 전달하는 내용이고, @contents는 그 내용을 넣는 위치입니다. 초안의 @contents 정의는 mixin이 이런 블록을 받도록 합니다. 따라서 공유하는 것은 “언제 좁은 레이아웃으로 바뀌는가”이고, 각각 어떻게 바뀌는지는 카드와 알림의 스타일에 남습니다.

40rem은 이 가상의 사례에서 선택한 레이아웃 조건이지, 보편적인 기기 분류가 아닙니다. 카드와 알림이 서로 다른 컨테이너의 가용 공간에 따라 변해야 한다면 같은 뷰포트 기준을 억지로 공유하는 의미가 없어집니다. 이때는 mixin이 있다는 이유로 계속 쓰기보다 조건을 다시 검토해야 합니다.

@contents에는 기본 블록을 제공할 수도 있습니다. 호출자가 내용을 전혀 전달하지 않았을 때만 사용하며, 빈 블록을 전달한 것은 내용이 비어 있어도 전달 자체는 한 것입니다. 이 글의 --compact-layout()에는 기본 내용이 없으므로 블록 없이 호출하기만 하면 어떤 선언도 추가되지 않습니다.

현재 두 컴포넌트라면 하나의 @media 안에 두 규칙을 직접 적어도 이해하기 쉽습니다. 조건 mixin의 가치는 여러 곳이 실제로 같은 레이아웃 판단을 공유하면서 스타일은 각 컴포넌트 가까이에 두고 싶을 때 생깁니다. 이름이 하나 늘어나는 만큼 의도가 더 명확해져야 합니다.

추상화의 비용은 수정하는 날 드러난다

이후 프로모션 카드, 오류 알림, 사이드바 요약이 추가되었다고 가정해 봅시다. --panel-frame()에 테두리 두께, 배경, 아이콘 위치, 제목 크기를 넣고 특별한 경우를 제어하는 스위치까지 더하고 싶어질 수 있습니다.

매개변수가 늘어나면 그 관계를 다시 살펴봐야 합니다. 대부분의 호출에서 설정의 절반을 덮어쓰거나 특정 컴포넌트 하나만 특별한 매개변수 묶음을 사용한다면, 원래의 “공통 외곽”이 여러 요구의 집합으로 변했을 수 있습니다. 규칙을 더 작게 나누거나 일부 컴포넌트에 자체 선언을 남기는 편이 관리하기 쉬울 수 있습니다.

저는 세 질문으로 이 경계를 확인합니다.

  • 수정이 함께 일어나야 하는가? 모서리 반경을 바꾸면 모든 컨테이너에 적용되어야 할까요, 특정 종류의 내용에만 적용되어야 할까요?
  • 호출 위치가 의도를 설명하는가? 색과 여백은 이해하기 쉽지만 위치 인수가 길게 늘어서면 정의를 반복해서 확인해야 합니다.
  • 예외를 어디에 두면 가장 명확한가? 알림만의 옆 테두리는 사이트 전체의 공통 옵션으로 만들지 않고 알림 규칙에 남길 수 있습니다.

mixin으로 추출하면 관리자는 적어도 호출 위치, 정의, 전달된 값을 연결해서 읽어야 합니다. mixin 안에서 다른 mixin을 호출하면 추적 경로가 더 길어집니다. 수정 지점을 모으는 편리함은 이 읽기 비용과 함께 고려해야 합니다.

섞여 들어온 선언 역시 다른 스타일과 함께 동작해야 합니다. mixin을 적용해도 기존의 캐스케이드 문제가 사라지는 것은 아닙니다. 이 간단한 사례에서는 @apply 뒤에 컴포넌트 자체의 모서리 반경을 선언해 국소적인 차이를 표현할 수 있습니다. 다른 출처에서 같은 속성을 설정한다면 여전히 출처, 레이어, 명시도, 순서를 확인해야 합니다. 추상화 때문에 실제로 적용된 값을 설명하기 더 어려워져서는 안 됩니다.

더 중요한 점은 class와 mixin 모두 함께 수정하는 관계를 만든다는 것입니다. 오늘 두 부분이 비슷해 보인다는 사실만으로 앞으로도 늘 동기화해야 한다고 볼 수는 없습니다. 먼저 규칙에 합리적인 이름을 붙이고 구현 도구를 고르는 편이, 줄 수부터 최소화하려는 것보다 믿을 만합니다.

지금 배울 가치가 있는 부분은 무엇일까?

Sass에 익숙하다면 개념 자체는 낯설지 않습니다. 하지만 Sass의 mixin은 @include로 적용하고 $로 매개변수 이름을 지정하며, Sass 처리 후 CSS를 출력합니다. 이 글의 네이티브 제안은 @apply, -- 이름, CSS 값 체계를 사용합니다. 용도가 비슷하다고 해서 문법, 범위, 마이그레이션 방식까지 같다고 추론할 수는 없습니다.

같은 CSS 초안의 @function은 값을 다룹니다. 예를 들어 입력에 따라 길이를 계산해 padding 같은 속성에 사용할 수 있습니다. @mixin은 여러 선언과 중첩 규칙을 포함할 수 있는 스타일 블록을 다룹니다. 재사용하려는 대상이 어느 계층에 있는지가 어떤 기능을 선택할지에 영향을 줍니다.

초안 자체도 정리 중입니다. 이 글의 예제는 5.1~5.4절의 정의를 기준으로 작성했습니다. 5.5절에는 새로운 비공개 속성 모델에 맞춰 갱신해야 한다는 주석이 남아 있고, 이전의 @result 표기도 남아 있습니다. 따라서 이 글은 내부 대입 절차를 확정된 동작으로 취급하지 않으며, 문법 설명을 호환성 보장으로 제시하지도 않습니다.

처음의 문서 사이트로 돌아가면, 저는 여전히 공통 class나 선택자 그룹부터 사용하고 명확한 값의 차이는 사용자 지정 속성에 맡기겠습니다. 규칙을 실제로 컴포넌트 사이에서 조합해야 하고 명확한 매개변수 인터페이스가 필요해졌을 때, mixin의 표현 방식이 그만한 가치가 있는지 판단하겠습니다.

네이티브 @mixin 제안은 CSS의 재사용을 단일 값에서 이름을 가진 조절 가능한 규칙 묶음으로 넓히는 방향을 보여 줍니다. 이 기능을 유익하게 쓰려면 여전히 처음의 질문에 답해야 합니다. 다음에 수정할 때, 어떤 스타일이 함께 바뀌어야 할까요?