문서 사이트를 관리하고 있다고 가정해 보겠습니다. 글 카드에는 넉넉한 안쪽 여백이 필요하고, 버전 알림은 조금 더 촘촘해야 합니다. 그래도 둘은 같은 간격 단위를 따라야 합니다. calc()를 두 번 작성해 하나는 3배, 다른 하나는 2배로 계산하면 당장의 요구는 해결됩니다.

이후 사이드바에도 카드를 넣게 되었고, 사이드바는 더 작은 간격 단위를 사용합니다. 계산 자체는 여전히 쉽습니다. 추적하기 어려워지는 것은 약속입니다. 어느 부분이 같은 배율 체계를 따르는지, 단위는 어디에서 오는지, 한 규칙을 바꿀 때 어떤 다른 컴포넌트도 함께 바뀌어야 하는지가 문제입니다.

CSS @function은 이 계산에 이름을 붙일 수 있게 해 줍니다. 여러 컴포넌트가 계산 규칙을 공유하면서 입력은 다르게 사용한다면, 규칙과 입력을 분리해 변경의 책임을 명확히 할 수 있습니다. 하지만 한 번 쓰는 곱셈에 이름만 하나 더 붙이면 스타일을 이해하기 위해 한 단계를 더 따라가야 할 수도 있습니다.

일반 CSS로 가능한 것부터 살펴보기

가상의 문서 사이트에서 사용할 HTML입니다. 이후 예제에서도 같은 클래스를 사용합니다.

<article class="site-card">
  <h2>설치 가이드</h2>
  <p>프로젝트의 첫 페이지부터 시작하세요.</p>
</article>
<aside class="site-notice">
  <h2>버전 안내</h2>
  <p>업데이트 전에 변경 기록을 확인하세요.</p>
</aside>

사용자 지정 속성으로 공통 단위를 제공하고 각 컴포넌트에서 계산합니다.

:root {
  --space-unit: 0.5rem;
}
 
.site-card {
  padding: calc(var(--space-unit) * 3);
}
 
.site-notice {
  padding: calc(var(--space-unit) * 2);
}

루트 글자 크기가 16px이면 단위는 8px이고 카드의 안쪽 여백은 24px, 알림은 16px입니다. 단위를 바꾸면 둘 다 갱신되며 배율은 각 컴포넌트의 규칙에 남습니다.

이 CSS만으로도 충분히 명확합니다. 사용자 지정 속성이 값을 제공하고 calc()가 연산합니다. 곱셈이 두 번 등장했다는 이유만으로 리팩터링할 필요는 없습니다. 함수를 고려할 시점은 코드가 비슷해 보일 때가 아니라, 이 계산이 앞으로 함께 관리해야 할 규칙이 되었을 때입니다.

@function으로 계산에 이름 붙이기

다음은 앞의 CSS 전체를 대체하는 예제입니다. 우선 배율과 단위를 명시적으로 전달해 호출 위치에서 의존 관계를 확인할 수 있도록 합니다.

:root {
  --space-unit: 0.5rem;
}
 
@function --space(--steps <number>: 1, --unit <length>: 0.5rem)
  returns <length> {
  result: calc(var(--steps) * var(--unit));
}
 
.site-card {
  padding: --space(3, var(--space-unit));
}
 
.site-notice {
  padding: --space(2, var(--space-unit));
}

함수와 매개변수 이름은 --로 시작합니다. 최상위에 함수를 정의하고 속성값에서 --space(...)를 직접 호출합니다. var(--space(...))로 감싸거나 @apply를 사용할 필요는 없습니다. 함수 내부의 var(--steps)와 var(--unit)은 매개변수를 읽고, result는 결과값을 지정합니다. 이 구성은 함수 정의 규칙에 설명되어 있습니다.

이 인터페이스는 숫자 배율과 길이 단위를 받아 길이를 반환합니다. --space()는 두 기본값을 사용해 0.5rem을, --space(3)은 1.5rem을, --space(3, 6px)는 18px을 만듭니다. <length>에는 백분율이 포함되지 않습니다. 타입은 함수가 실제로 받기로 약속한 입력에 맞춰 선택해야 합니다.

타입과 기본값은 생략할 수 있지만, 여기서는 어떤 값을 전달할지 분명하게 보여 주기 위해 지정했습니다. 기본 단위는 함수 자체의 0.5rem입니다. --space-unit을 따르게 하려면 카드 예제처럼 명시적으로 전달해야 합니다. 루트 토큰을 0.75rem으로 바꿔도 함수 정의에 적힌 기본값이 자동으로 바뀌지는 않습니다.

사용자 지정 속성이 평가되는 위치가 중요하다

계산을 루트의 사용자 지정 속성에 넣는다고 해서 모든 자손에서 토큰을 다시 읽는 함수를 저장하는 것은 아닙니다.

다음 CSS를 앞의 HTML에 독립적으로 적용하고 카드를 살펴보세요.

:root {
  --space-unit: 0.5rem;
  --card-space: calc(var(--space-unit) * 3);
}
 
.site-card {
  --space-unit: 0.25rem;
  padding: var(--card-space);
}

루트 글자 크기가 16px이라면 카드의 안쪽 여백은 여전히 24px입니다. --card-space 안의 var(--space-unit)은 루트 요소에서 해석되고 그 결과가 자손에게 상속됩니다. 카드의 0.25rem은 루트에서 이미 해석한 변수 참조를 다시 계산하게 만들지 않습니다. 이는 사용자 지정 속성의 평가와 상속 규칙에 따른 것으로, 변수가 변화에 반응하지 않는다는 뜻은 아닙니다.

함수 없이도 고칠 수 있습니다. 카드 자체에 calc(var(--space-unit) * 3)를 쓰면 됩니다. 여러 곳에서 같은 규칙을 쓰면서 정의는 한곳에 유지하고 싶을 때 함수가 유용합니다.

다음은 다른 인터페이스입니다. 배율은 명시적으로 전달하고, 단위는 호출하는 요소에서 읽습니다. 두 가지 의존 방식을 혼동하지 않도록 앞의 --space()와 이름을 구분했습니다.

:root {
  --space-unit: 0.5rem;
}
 
@function --local-space(--steps <number>: 1) returns <length> {
  result: calc(var(--space-unit, 0.5rem) * var(--steps));
}
 
.site-card {
  --space-unit: 0.25rem;
  padding: --local-space(3);
}
 
.site-notice {
  padding: --local-space(2);
}

이제 카드의 안쪽 여백은 12px, 알림은 여전히 16px입니다. 함수 안에 --space-unit이라는 매개변수나 지역 변수가 없으므로 호출 요소의 사용자 지정 속성을 읽습니다. 이 관계는 초안의 매개변수와 변수 범위 규칙에 정의되어 있습니다.

방식카드 단위를 읽는 위치이 예제의 카드 여백
루트의 --card-space 상속루트에서 변수 참조를 해석할 때의 0.5rem24px
카드 단위를 0.25rem으로 설정하고 --space(3, var(--space-unit)) 호출호출 시 명시적으로 전달한 인수12px
--local-space(3)함수가 읽는 카드의 토큰12px

명시적인 인수는 추적과 테스트가 쉽습니다. 외부 토큰을 읽으면 호출은 짧아지지만 암묵적인 의존성이 늘어납니다. 디자인 시스템에서 --local-space()를 제공한다면 --space-unit을 읽는다는 사실도 사용 계약에 포함해야 합니다. 같은 이름의 매개변수나 지역 변수는 외부 값을 가리므로 의도치 않은 이름 충돌도 피해야 합니다.

result는 선언이며 조기 반환이 아니다

가상의 요구 사항을 하나 더 추가해 보겠습니다. 뷰포트 너비가 40rem 이하일 때 두 컴포넌트의 간격을 원래의 4분의 3으로 줄입니다. 이는 이 문서 사이트의 디자인 결정이며 모든 휴대폰을 구분하는 보편적인 기준은 아닙니다.

다음 전체 CSS는 지역 변수에 밀도 배율을 저장하고 조건이 맞으면 덮어쓴 뒤 같은 식으로 결과를 만듭니다.

:root {
  --space-unit: 0.5rem;
}
 
@function --responsive-space(--steps <number>: 1) returns <length> {
  --density: 1;
 
  @media (width <= 40rem) {
    --density: 0.75;
  }
 
  result: calc(var(--space-unit, 0.5rem) * var(--steps) * var(--density));
}
 
.site-card {
  padding: --responsive-space(3);
}
 
.site-notice {
  padding: --responsive-space(2);
}

루트와 브라우저 기본 글자 크기가 모두 16px일 때, 뷰포트가 800px이면 결과는 24px과 16px입니다. 600px에서는 18px과 12px이 됩니다. --density는 함수 안에만 존재하며 다른 CSS가 요소에서 읽을 수 있는 공개 토큰이 되지 않습니다.

여기서도 선언형 모델이 적용됩니다. result는 JavaScript의 return과 다릅니다. 다음 함수는 좁은 화면 조건을 만족해도 항상 1rem을 만듭니다.

@function --always-one() returns <length> {
  @media (width <= 40rem) {
    result: 0.75rem;
  }
 
  result: 1rem;
}

조건을 만족하는 선언이 결과 선택에 참여하지만, 뒤에 있는 result가 앞의 값을 덮어씁니다. 첫 결과에서 실행이 멈추지 않습니다. 조건과 실행 모델에 이 차이가 설명되어 있습니다. 기준값을 먼저 두고 조건에 따른 덮어쓰기를 뒤에 배치하거나, 이전 예제처럼 결과를 한곳에 모으면 읽기 쉽습니다.

카드와 알림이 서로 다른 화면 조건에 반응해야 한다면 미디어 쿼리를 각 컴포넌트 규칙에 남겨야 합니다. 조건을 공유하는 것 역시 앞으로 함께 바꾸겠다는 약속을 만드는 일입니다.

타입 검사가 앞선 선언으로 되돌려 주지는 않는다

먼저 배율에 기본값이 없는 함수로 실패를 살펴봅니다.

@function --strict-space(--steps <number>) returns <length> {
  result: calc(var(--steps) * 0.5rem);
}
 
.site-card {
  padding: 24px;
  padding: --strict-space(red);
}

사용자 지정 함수를 지원하는 브라우저에서 red는 <number>에 맞지 않으므로 계산값 단계에서 호출이 무효해집니다. 이때 padding은 앞의 24px로 돌아가지 않고 초기값 0이 됩니다. 필수 인수를 생략한 --strict-space() 역시 사용할 배율이 없습니다. 다른 속성은 상속 여부에 따라 결과가 달라질 수 있으므로 모두 0이 된다고 일반화하면 안 됩니다. 계산값 단계에서 무효한 값 규칙은 캐스케이드를 다시 실행하지 않는 이유를 설명합니다.

기본값이 있는 경우는 구분해야 합니다. 앞의 --space()는 배율 기본값으로 1을 제공합니다. 인수 평가 규칙에 따라 타입에 맞지 않는 입력에는 기본값을 시도하므로 --space(red)는 0.5rem이 됩니다. 호출 전체가 반드시 무효해지는 것은 아닙니다. 기본값이 입력 실수를 숨길 수 있다는 뜻이기도 하므로 개발할 때 실제 결과를 확인해야 합니다.

타입은 값의 종류를 제한하지만 디자인상의 모든 제약을 보장하지는 않습니다. 음수도 <number>입니다. 함수가 반환한 음수 길이는 margin에는 쓸 수 있지만 padding에는 적합하지 않습니다. 유효한 입력 범위와 결과를 사용하는 속성을 함께 고려해야 합니다.

함께 관리할 대상을 기준으로 선택하기

이 기능들은 함께 사용할 수 있습니다. 이 사례라면 저는 토큰과 직접 계산부터 유지하고, 더 많은 컴포넌트에서 규칙을 함께 조정할 필요가 생기면 함수로 분리하겠습니다.

함께 관리하려는 내용먼저 고려할 방법확인할 점
간격이나 색처럼 덮어쓸 수 있는 값사용자 지정 속성선언 위치, 상속, 의존 관계
한 번의 계산이나 범위 제한calc(), min(), max(), clamp()직접 쓰는 편이 가장 명확할 때가 많음
입력을 달리하는 공통 계산네이티브 @function매개변수, 호출 요소, 브라우저 지원
빌드 시점의 데이터 처리와 CSS 생성Sass function컴파일러와 입력 데이터
동일한 선언 묶음공통 클래스나 선택자 그룹HTML과 스타일 구성
매개변수가 있는 스타일 블록네이티브 @mixin 초안초안의 성숙도와 블록의 책임

Sass function은 컴파일러에서 실행되고 네이티브 CSS 함수는 브라우저의 스타일 평가에 참여합니다. Sass도 var()나 calc()를 포함한 CSS를 출력해 이후 처리를 브라우저에 맡길 수 있습니다. 따라서 Sass는 모두 고정값이고 네이티브만 동적으로 변한다고 단순화할 수는 없습니다.

@function의 결과는 값입니다. border, padding, 중첩 선택자를 포함한 스타일 묶음을 삽입하지는 않습니다. 블록 단위의 재사용은 관련 글인 네이티브 CSS @mixin에서 살펴볼 수 있습니다. 같은 명세에 포함되어 있다고 구현의 성숙도까지 같은 것은 아닙니다.

지금 도입한다면 동작하는 기본 스타일을 유지하기

확인 날짜 기준 호환성 원본 데이터는 다음과 같습니다. 기능 지원 상태이며 점유율이나 이 사이트의 방문자 분포를 나타내지 않습니다.

브라우저@function 지원
Chrome / Chrome Android139부터
Edge139부터
Firefox아직 지원하지 않음
Safari / iOS Safaripreview로 기록됨. 안정 버전에서 널리 사용할 수 있다는 뜻은 아님

여러 브라우저에서 읽을 수 있어야 하는 사이트라면 동작하는 calc()를 유지하고 @supports at-rule(@function)으로 향상 버전을 분리할 수 있습니다. 다음은 처음 HTML에 그대로 적용할 수 있는 전체 CSS입니다.

:root {
  --space-unit: 0.5rem;
}
 
.site-card {
  padding: calc(var(--space-unit) * 3);
}
 
.site-notice {
  padding: calc(var(--space-unit) * 2);
}
 
@supports at-rule(@function) {
  @function --space(--steps <number>: 1, --unit <length>: 0.5rem)
    returns <length> {
    result: calc(var(--steps) * var(--unit));
  }
 
  .site-card {
    padding: --space(3, var(--space-unit));
  }
 
  .site-notice {
    padding: --space(2, var(--space-unit));
  }
}

이것은 보수적인 점진적 향상입니다. at-rule() 쿼리 자체도 지원되어야 하므로, 함수를 실행할 수 있어도 이 쿼리를 모르면 기본 버전을 사용합니다. 여기서는 두 경로의 모습이 같지만 잠시 두 계산을 관리해야 한다는 비용이 있습니다. 도입의 이익과 함께 판단해야 합니다.

@supports (padding: --space(3))를 함수의 존재와 정상 실행을 증명하는 용도로 쓰지 마세요. 선언을 파싱할 수 있다고 특정 호출이 성공하는 것은 아닙니다. 이번 테스트에서는 정의되지 않은 함수 이름을 넣어도 지원 엔진이 true를 반환했습니다. at-rule() 역시 규칙 지원 여부를 확인할 뿐 개별 인수를 검증하지 않습니다.

예제의 계산 결과는 로컬 Chromium 153.0.8010.12와 Playwright WebKit 26.6에서 확인했고, Chromium의 CSSFunctions를 끈 상태에서도 기본 스타일을 검증했습니다. Safari 안정 버전이나 Firefox의 실제 실행 검증은 아닙니다. 로컬 Firefox 테스트 프로세스가 시작되지 않아 지원 표는 앞서 인용한 데이터에 근거합니다.

카드와 알림으로 돌아가 보면, 한곳에 모을 가치가 있는 것은 함께 유지하기로 합의한 계산 규칙입니다. 함수 이름은 그 합의를 드러내고 매개변수는 사용 위치의 차이를 표현해야 합니다. 하나의 padding을 이해하려고 이전보다 더 많은 숨은 토큰을 추적해야 한다면, 원래의 calc()를 직접 쓰는 편이 여전히 더 나을 수 있습니다.