
이 글에서는 컴포넌트나 라이브러리 사용자에게 훌륭한 DX를 제공하는 데 도움이 되는 CSS @function 고급 패턴들을 다룹니다. 내용을 제대로 따라오려면 CSS 함수의 기초와 한계에 대한 이해가 필요합니다.
커스텀 CSS @function에서의 변수 범위는 정말 흥미롭습니다. 일반적인 CSS에서는 --var 변수들이 부모 요소에서 자식 요소로 상속됩니다. 그리고 자식 요소는 이렇게 상속받은 --var 변수들을 자유롭게 사용할 수 있습니다.
html {
--theme: light;
--size-1: 1rem;
}
html div {
--palette-1: if(
style(--theme: dark): black;
else: white;
);
font-size: var(--size-1);
}
마찬가지로 JavaScript에서도 특정 범위 안에서 정의된 함수는, 자신이 정의된 바깥 범위에 존재하는 모든 변수를 상속받아 함수 내부에서 자유롭게 사용할 수 있습니다. 일반적으로 JavaScript에서는 이렇게 함수가 외부에서 변수들을 가져올 수 있는 범위를 클로저 스코프라고 부릅니다.
let html, div
html = () => {
const theme = "light"
const size1 = "1rem"
div = () => {
const palette1 = theme === "dark" ? "black" : "white"
const fontSize = size1
return { palette1, fontSize }
}
return { theme, size1 }
}
기존 CSS 의 var() 사용 방식은 DOM 구조에 대한 패턴과 규칙에 크게 의존합니다.
즉, DOM 구조 자체가 자식 요소가 어떤 부모 구조 아래에 존재할지를 결정하고, 그에 따라 부모로부터 어떤 변수들이 상속될 것인지, 그리고 자식 요소에서 어떤 변수를 안전하게 사용할 수 있는지가 결정됩니다.
함수는 호출되는 위치를 기준으로, 마치 상속된 클로저처럼 동작하는 범위를 갖게 됩니다.
즉, 함수 내부 로직은 자신이 호출된 위치에 존재하는 모든 변수들에 접근할 수 있으며, 마치 DOM 안의 자식 요소처럼 그 변수들을 상속받아 사용하는 것처럼 동작합니다.
JavaScript로 비유하자면, 마치 자식 함수의 정의 자체를 문자열 형태로 저장해두었다가, 함수가 사용되는 위치마다 eval() 을 통해 실행하는 것과 비슷합니다.
말하자면, 이것은 일종의 평가 스코프(evaluation scope)라고 볼 수 있습니다.
let html, div
const fn = `() => {
const palette1 = theme === "dark" ? "black" : "white"
const fontSize = size1
return { palette1, fontSize }
}`
html = () => {
const theme = "light"
const size1 = "1rem"
div = eval(fn)
return { theme, size1 }
}
html().size1 === div().fontSize
// true
CSS에서는 이렇게 됩니다:
@function --palette-1() {
result: if(
style(--theme: dark): black;
else: white;
);
}
@function --font-size() {
result: var(--size-1);
}
html {
--theme: light;
--size-1: 1rem;
}
html div {
--palette-1: --palette-1();
/* white */
font-size: --font-size();
/* 1rem */
}
.evaluation-scope {
--theme: dark;
--size-1: 20px;
--palette-1: --palette-1();
/* black */
font-size: --font-size();
/* 20px */
}
.evaluation-scope div {
--palette-1: --palette-1();
/* black */
font-size: --font-size();
/* 20px */
}
어떤 컴포넌트를 만들더라도, 그 컴포넌트와 연결된 CSS 함수를 함께 정의할 수 있습니다.
그리고 이러한 함수들은 오직 해당 컴포넌트 내부에서만 호출되기 때문에, 함수 구현 시 컴포넌트 내부에서 보장되는 변수 범위를 기반으로 안정적으로 동작하도록 설계할 수 있습니다. 🤌
개인적으로 이 부분이 정말 마음에 듭니다.
아직 이 개념이 완전히 이해되지 않았다면, 잠시 멈춰서 한 번 곱씹어보세요.
왜냐하면 이제부터는 이 개념을 기반으로, 훨씬 더 흥미로운 방식으로 이야기를 확장해볼 예정이기 때문입니다. 🚀
만약 어떤 CSS 함수를 오직 다른 함수 내부에서만 호출되도록 설계한다고 가정해봅시다.
그렇게 되면 부모 함수 내부에 존재하는 다소 복잡한 로직과 변수들은, 자식 함수에게 거의 영구적으로 유지되는 것처럼 동작하는 실행 범위(evaluation scope)가 됩니다.
즉, 부모 함수 내부의 컨텍스트가 자식 함수가 언제든 참조할 수 있는 실행 환경처럼 동작하게 되는 것입니다.
하나의 부모 함수는 if(style()) 와 같은 간단한 조건 분기를 통해, 서로 연관된 여러 개의 내부 함수를 필요에 따라 얼마든지 호출할 수 있습니다.
@function --parent(--whichFn, --arg1, --arg2) {
/* 이 이식 가능한 평가 스코프를 위해 */
/* 공통 연산을 수행하고 */
/* 여러 공유 변수를 계산합니다 */
--wow: calc(var(--arg1) * 2);
--pow: pow(var(--wow), var(--arg2));
/* 그런 다음 자식 함수로 분기합니다 */
result: if(
style(--whichFn: childA): --childA();
style(--whichFn: childB): --childB();
style(--whichFn: childC): --childC();
style(--whichFn: child64): --child64();
else: "Unknown Function";
);
}
@function --childA() {
/* 이식 가능한 평가 스코프에서 가져온 */
/* 복잡한 변수들을 활용합니다 */
result: calc(var(--wow) + var(--pow));
}
@function --childB() {
/* 동일한 이식 가능한 평가 스코프이지만 */
/* --childB() 함수 고유의 */
/* 다른 일련의 연산을 수행합니다 */
--many-more-steps: 9;
result: calc(
var(--pow) -
var(--wow) +
var(--many-more-steps)
);
}
이런 형태의 분기 함수인 parent 는, 만약 이를 라이브러리나 컴포넌트 문서에서 직접 API처럼 외부에 공개하려고 한다면, 개발자 경험 측면에서 다소 무겁고 복잡하게 느껴질 수 있습니다.
그 이유는 각 인자들이 자식 함수에 따라 서로 다른 역할을 가질 수 있기 때문입니다.
비록 내부적으로는 여러 공통 로직을 공유하더라도, 사용하는 입장에서는 함수 구조가 지나치게 복잡해 보일 수 있습니다.
하지만 여기서 멈추지만 않는다면, 이런 문제는 크게 걱정할 필요가 없습니다.
조금만 더 확장해서 생각해보면 됩니다.
예를 들어 outerA 와 outerB 같은 외부 함수를 만들어, 부분 적용(Partial Application) 형태로 활용할 수 있습니다(필요하다면 완전한 커링까지도 가능합니다).
@function --outerA(--arg1, --arg2: 10) {
result: --parent(childA, --arg1, --arg2);
}
@function --outerB(--arg1, --arg2: 1) {
result: --parent(childB, --arg1, --arg2);
}
이렇게 만든 외부 함수들은 이제 코드베이스 전체에서 사용하는 공식 API 역할을 하게 되며, 더 이상 분기 함수인 switch function 을 직접 사용할 필요가 없어집니다.
덕분에 내부적으로 복잡한 분기 처리용 API 구조는 감춰둘 수 있고,
그 구조에 의존하는 자식 함수들 역시 외부에 노출되지 않은 채 내부 구현으로만 유지할 수 있습니다.
왜냐하면 이 자식 함수들은 이동 가능한 실행 범위(portable evaluation scope) 에 의존하기 때문에, 다른 곳에서는 직접 호출할 수 없기 때문입니다.결국 사용자나 다른 개발자가 경험하게 되는 것은 복잡한 내부 구조가 아닌, 각각의 단순한 외부 함수들뿐입니다.
겉으로 보기에는 매우 단순하지만, 내부적으로는 상당히 복잡한 로직을 담아낼 수 있다는 점에서 정말 인상적입니다. 🤌
한마디로, 내부 사용을 위한 포터블 평가 스코프(Portable Evaluation Scope)를 만들고, 이것을 사용자의 DX로부터 완전히 숨기는 것입니다. 👽
@propjockey/doubledash.css에는 16비트 정수에 대한 비트 연산이 있습니다.
내부적으로는 단일 "bitwise" 스위치 함수가 존재합니다:
@function --dd-bitwise(--dd-int16-a, --dd-int16-op, --dd-int16-b: 0, --dd-int16-set-bit-to: 0) {
--_dd-a-sign: sign(var(--dd-int16-a));
--_dd-a-down-to-int: clamp(
0,
round(down, abs(var(--dd-int16-a)), 1),
pow(2, 16) - 1
);
result: if(
style(--dd-int16-op: right): calc(
var(--_dd-a-sign) * clamp(
0,
round(down, var(--_dd-a-down-to-int) / pow(2, var(--dd-int16-b)), 1),
pow(2, 16) - 1
)
); else: --_dd-bw-split-a(
var(--dd-int16-a),
var(--dd-int16-op),
var(--dd-int16-b),
var(--dd-int16-set-bit-to)
);
);
}
연산이 right shift라면 나눗셈과 거듭제곱으로 결과를 반환하고, 그 외 연산에 대해서는 내부 함수 --_dd-bw-split-a()를 호출하여 포터블 노출 스코프 레이어를 추가합니다. 이 함수는 int16-a 인수를 16개의 개별 비트로 분리합니다.
비트 "NOT"연산은 각 비트를 반전시켜 calc()로 새 정수로 만들어 반환합니다.
비트 "XOR" 같은 연산은 세 번째 내부 함수를 호출해 또 하나의 포터블 노출 스코프 레이어를 추가하고, int16-b 인수도 16개의 비트로 분리합니다. 그런 다음 두 평가 스코프의 각 비트를 XOR 연산으로 조합해 최종 결과를 냅니다.
이 패턴은 기능의 캡슐화와 분리를 가능하게 합니다. split-b 함수는 오직 인수를 비트로 분리하는 역할만 하고, 이후 또 다른 함수를 호출하여 원래의 내부 bitwise 호출로부터 실제 스위치보드 처리를 맡깁니다.
doubledash 문서의 노출 API는 최소한의 구현만 가지고 있으며, 라이브러리 사용자가 보게 되는 것은 오직 그것뿐입니다.
경우에 따라서는 개발자 사용자들이 직접 기능을 확장해서 활용할 수 있도록, 복잡한 Portable Evaluation Scope 자체를 하나의 기능으로 제공하고 싶을 수도 있습니다.
이 경우에는 내부에서 사용하던 switch function 을 개발자 사용자에게도 노출해야 하기 때문에, 해당 분기 함수에서 사용하는 공통 인자 구조를 표준화하는 것이 훨씬 중요해집니다.
JavaScript 관점에서 비유하자면, 개발자가 만든 사용자 정의 함수를 문자열 형태로 라이브러리 함수에 전달하고, 라이브러리 내부에서 eval(yourFnStr) 을 실행하는 것과 비슷합니다.
물론 JavaScript에서는 보안 문제 때문에 이런 방식을 거의 사용하지 않습니다.
하지만 CSS에서는 evaluation scope 자체가 언어 구조에 내장되어 있고 별도의 보안 문제가 없기 때문에, 이런 방식이 가진 가능성을 훨씬 자유롭게 활용할 수 있습니다.
다만 현재 CSS에서는 아직 함수를 참조 형태로 전달하는 기능은 지원되지 않습니다.
변수와 마찬가지로 동적인 참조나 보간된 참조 방식이 아직 허용되지 않기 때문에, 아래와 같은 방식은 현재 동작하지 않습니다.
@function --dev-user-fn() {
result: var(--use-portable-exposure-scope) " world!";
}
@function --library-fn(--fn) {
--use-portable-exposure-scope: "Hello";
result: --fn();
}
body::before {
content: --library-fn(--dev-user-fn);
}
이 역시 굉장히 유용할 수 있는 기능이기 때문에, 또 하나의 아쉬운 제한 사항이라고 할 수 있습니다.
그래서 우리는 “안 된다”는 것을 그대로 받아들이지 않는다는 철학을 가지고 있으니, 이 기능이 지원되지 않더라도 다른 방식으로 구현할 수 있습니다. 라이브러리나 컴포넌트를 만드는 개발자라면, 단순히 아직 정의되지 않은 함수들을 호출하도록 switch board 함수를 구성하면 됩니다.
그리고 그 함수들은 라이브러리를 사용하는 개발자가 직접 정의하면 됩니다. 여러분은 Portable Evaluation Scope가 어떤 값들과 변수들을 제공하는지 문서화해주기만 하면 되고, 그러면 양쪽 모두 원하는 방식으로 활용할 수 있게 됩니다. 🎉
주의할 점: switch board 내부에서 함수에 직접 인자를 전달하는 방식은 사용하지 않는 것이 좋습니다. 이전 글에서 설명했듯이, 만약 호출한 함수가 전달받은 모든 인자들을 정의하고 있지 않다면 함수 호출은 실패하게 됩니다. 그리고 왜 실패했는지 알 수 있는 디버깅 방법도 없고, 단지 “그게 원인이었구나” 하고 스스로 알아내는 수밖에 없습니다. 이것은 좋지 않은 개발자 경험입니다.
반면 Portable Evaluation Scope를 사용하면, 라이브러리를 사용하는 개발자는 매개변수 없이 최소한의 문법으로 함수를 빠르게 정의할 수 있고, 그 안에서 필요한 변수만 골라서 사용할 수 있습니다.
정리하면, 라이브러리를 사용하는 개발자는 Portable Evaluation Scope가 포함된 switch board를 호출하고 식별자를 전달합니다. 그러면 해당 switch board는 그 식별자에 대응하는, 개발자가 직접 정의한 함수를 실행하게 됩니다.
doubledash의 세계 최초 100% CSS 루프가 바로 이렇게 동작합니다.
이 라이브러리는 개발자 사용자를 위해 --dd-loop() 함수를 제공합니다. 이 함수는 인자 중 하나로 --dd-loop-id-7 과 같은 라이브러리에서 정의한 switch key 를 전달받습니다.
내부적으로는 정적인 캐스케이딩 언어인 CSS에서 반복문을 구현할 수 있도록 필요한 모든 복잡한 로직을 처리한 뒤, 최종적으로 전달된 switch key 를 확인하고, 그에 대응하는 개발자가 직접 정의한 함수를 실행합니다. 이 함수는 곧 반복문의 본문 역할을 하게 됩니다.
현재는 함수를 동적으로 전달하는 방법이 없기 때문에, 라이브러리는 switch 내부에 64개의 비어 있는 함수 슬롯을 미리 제공합니다. 이를 통해 개발자는 반복문이 필요한 서로 다른 상황에 맞춰 최대 64개의 전역 함수 정의를 직접 구현할 수 있습니다.
그리고 loop가 제공하는 Portable Evaluation Scope 에는 현재 반복 중인 인덱스 값과 현재 x 값 등, 반복문 실행에 필요한 여러 정보들이 함께 제공됩니다.
CSS는 실제로 사용되지 않는 값은 계산하지 않습니다.
Pen 예제에서 볼 수 있듯이, switch board API 함수에는 추가적인 장점이 하나 있습니다.
라이브러리를 사용하는 개발자는 다음과 같이 변수를 설정할 수 있습니다.
--alias: --dd-loop-id-0;
이렇게 설정해두면, Portable Evaluation Scope를 사용하기 위해 switch board를 호출할 때 키 값으로 var(--alias) 만 전달하면 됩니다. 그러면 switch board는 해당 값이 무엇을 의미하는지 자동으로 판단하고 그에 맞는 동작을 수행합니다.
이 방식은 개발자가 두 개 이상, 혹은 여러 개의 loop를 사용하려고 할 때 코드를 보다 체계적으로 관리하는 데 도움이 됩니다.
DX 관점에서의 제한 사항:
개발자가 alias를 직접 식별자로 전달할 수 있도록, alias 값을 덮어쓸 수 있는 변수를 제공하는 방식은 현재 사용할 수 없습니다.그 이유는
if(style())가--arg === var(--alias-1)와 같은 비교를 수행할 수 없기 때문입니다.현재
if(style())는 변수 값을 비교할 수 없고, 오직 하드코딩된 값만 비교할 수 있습니다.개발자 경험 측면에서는 아쉬운 부분이지만, 적어도
var(--alias)를 활용할 수 있다는 점은 올바른 방향으로 나아가는 한 걸음이라고 볼 수 있습니다.
라이브러리를 사용하는 개발자는 자신이 원하는 방식으로 매개변수를 정의한 별도의 커스텀 함수로 switch board 호출을 감쌀 수 있습니다. 그리고 이렇게 정의한 매개변수들은, 이후 개발자가 정의한 자식 함수의 evaluation scope 일부로 함께 전달됩니다.
이 100% CSS 기반 Pen 예제는 원래 Ana Tudor 가 만든 PostCSS 아트워크인데, 여기서 개발자가 만든 --particles() 함수는 먼저 인자들을 정의한 뒤 doubledash 라이브러리의 loop 함수를 호출합니다. 그러면 그 인자들은 Portable Evaluation Scope의 일부가 되어 --dd-loop-id-0() 함수 본문 내부에서도 그대로 사용할 수 있게 됩니다.
이런 개발자 경험은 C programming language 에서 헤더 파일을 구현부와 분리해서 정의하는 방식과 비슷하게 느껴집니다.
마치 앞서 비트 연산 함수 예제에서 OuterA, OuterB 같은 개별 래퍼 함수를 제공했던 것처럼, switch board 기반 Portable Evaluation Scope 역시 개발자가 여러 방식으로 감싸서 활용할 수 있습니다. 예를 들어 --dot-particles() 와 --star-particles() 는 서로 다른 기본 인자를 사용하도록 설정하면서도, 내부적으로는 동일한 라이브러리 함수를 공유하도록 구성할 수 있습니다.
거의 함수 오버로딩(function overloading) 과 비슷한 방식이라고 볼 수 있지만, 최종적으로 만들어지는 함수들은 반드시 각각 다른 이름을 가져야 합니다.
만약 --arg1, --arg2 처럼 범용적인 변수 인자를 정의하고, 기본값을 지정해서 선택적으로 사용할 수 있게 만든다면, 문서에서는 서로 다른 시그니처마다 인자 이름이 다른 것처럼 안내할 수 있습니다.
예를 들어 doubledash.css 의 단순한 --dd-repeat() 함수는 이런 방식을 사용합니다. 문서상으로는 가운데 매개변수가 선택 사항인 것처럼 보이지만, 실제 구현상으로는 세 번째 인자가 선택적으로 처리되는 구조입니다.
내부적으로는 전달된 인자들을 이후 함수 내부 깊숙한 로직에서 사용하기 쉽도록, 보다 의미 있는 변수 이름으로 다시 정리해서 처리합니다.
@function --dd-repeat(--dd-total, --_dd-arg2, --_dd-arg3: initial) {
--dd-data: var(--_dd-arg3, var(--_dd-arg2));
--dd-joiner: if(style(--_dd-arg3): if(
style(--_dd-arg2: none): ;
style(--_dd-arg2: comma): ,;
else: var(--_dd-arg2, );
); else: ;);
...
result: ...;
}
이전 글에서 이야기했듯이, 기본 인자 값으로 initial 을 사용하는 방식은 이런 경우를 비교적 쉽게 처리할 수 있게 해줍니다.
예를 들어 data 는 기본적으로 세 번째 인자(argument 3) 를 사용합니다.
하지만 만약 세 번째 인자가 정의되어 있지 않다면, 그때는 두 번째 인자(argument 2) 를 data 값으로 사용하게 됩니다.
마찬가지로 joiner 역시 if(style()) 를 사용해서 세 번째 인자가 정의되어 있는지 확인합니다.
그리고 만약 해당 값이 존재하지 않는다면, 기본값으로 아무 동작도 하지 않는 빈 공백값(<empty> whitespace) 을 사용하도록 처리합니다.
evaluation scope는 커스텀 CSS 함수에서 매우 강력한 도구입니다.
비록 현재 커스텀 CSS 함수의 기본적인 개발자 경험(DX)은 수많은 까다로운 제약들로 가득 차 있지만, 이 evaluation scope 자체는 충분히 깊이 있게 탐구해볼 가치가 있는 “스트라이크” 같은 기능입니다.
결국, 여러분이 다루고 있는 기반 기술보다 더 나은 개발자 경험(DX)을 만들어서 그것을 감싸 제공할 수 있다면, 그것은 충분히 만족스러운 성공이라고 할 수 있습니다.
문서는 항상 표면적인 사용법만을 전달하면 됩니다. 그리고 사용자에게는 이렇게 말할 수 있습니다:
“제가 할 수 있는 모든 것은 이제 여러분도 똑같이 할 수 있습니다.
더 이상 제가 직접 복잡하게 처리했던 과정을 신경 쓸 필요 없이,
이 시스템이 그 모든 것을 대신 처리해줍니다.
그러니 우리 모두 그 복잡함에 대해 걱정하지 않아도 됩니다.” 😁
다시 말해, 저는 사람들이 무언가를 만들 수 있도록 돕는 도구를 만드는 사람입니다.
이 글이 도움이 됐거나, 질문이 있거나, 여러분이 만든 것을 보여주고 싶다면 언제든지 연락주세요: I invite Open Contact 💚👽