회사에서 디자인시스템을 구축하고 유지보수한 지 2년이 되어 갑니다. 시스템이 어느 정도 안정화가 되었지만, 운영하면서 계속 새로운 문제가 생기곤 합니다. 시스템을 개발하는 사람과 사용하는 사람이 다르기 때문에 의도한 방식대로 사용하지 않는 경우, 구현된 개발 코드와 피그마의 옵션이 다른 경우, 피그마에 개발자가 생각한 의도가 제대로 담기지 않는 경우 등등 여러 이유들로 시스템을 유지하는 것이 쉽지만은 않습니다.
비개발적인 부분(팀 내 업데이트 공지, 회의 방식, 컴포넌트가 추가되는 과정, 자유도 허용치 정하기 등)도 개선하려고 노력하고 있지만 올해는 개발자로서 코드를 통해 시스템을 안정화하고자 합니다.
그중에서도 eslint custom plugin 을 만들어서 문제를 해결한 사례를 소개하도록 하겠습니다.
현재 회사의 가장 대표적인 프로덕트는 vue2 + tailwind 로 이루어져 있습니다. (마이그레이션 리소스 부족으로 EOL 이 된 이후에도 아직까지 사용하고 있습니다..) vue도 react 와 동일한 SPA 프레임워크이나 다르게 동작하는 부분들이 꽤 존재합니다.
그 중에서도 vue 는 컴포넌트의 attribute 를 상위 컴포넌트에서 받아서 직접 할당해주고, 겹치면 통합을 해주는 Fallthrough Attributes 기능을 제공하고 있습니다.
잠깐 Fallthrough Attributes(속성 자동 상속)에 대해 설명을 하자면,
const ParentComponent = () => {
return (
<>
<ChildButton1 className={'bg-red-20'} />;
<ChildButton2 className={'bg-red-20'} />;
</>
)
};
// props 로 전달한 값이 button 에 적용됨.
const ChildButton1 = ({ className }) => {
return <button className={`${className} text-blue-100`} />;
};
// props 로 전달한 값이 button 에 적용되지 않음.
const ChildButton2 = () => {
return <button className={'text-b-100'} />;
};
react 에서는 attribute 를 자동으로 상속하지 않고 무조건 props 로 받은 값을 연결 시켜주어야만 해당 값이 하위 컴포넌트(ChildComponent)로 적용이 됩니다.
vue 는 props 와 emit 으로 정의되어 있지 않는 어떤 속성을 부모에 할당한 경우, 하위 자식에 무조건 할당시키는 기능을 제공하고 있습니다. class 나 style, id 값 같은 경우 상위에서 해당 값을 할당한 경우 react 는 하위 커스텀 컴포넌트에 연결해주어야 하는데 vue 에서는 명시하지 않아도 이를 자동으로 할당(연결)해주는 것입니다.
A "fallthrough attribute" is an attribute or
v-on
event listener that is passed to a component, but is not explicitly declared in the receiving component's props or emits.
<!-- template of <ParentComponent> -->
<ChildComponent class="large" />
<!-- template of <ChildButton> -->
<button>Click Me</button>
<!-- 적용된 결과물 > 상위에서 할당한 class 값이 하위 button 의 속성으로 들어감 -->
<button class="large">Click Me</button>
자동으로 연결해주는 기능이 대부분의 경우에는 편리하지만, 문제는 이 기능을 제어할 수 없다는 점이었습니다. vue2 에서는 상위 컴포넌트에서 class 를 할당하면 하위에서 이를 $attr 로 접근하거나 조작하는 것이 불가능합니다.
<script lang="ts">
import { defineComponent } from 'vue';
export default defineComponent({
inheritAttrs: false,
});
</script>
<script lang="ts" setup>
</script>
정확하게는 모든 attr 는 아니고 class 와 style 만 제어가 불가능하고 나머지 attr 는 inheritAttrs 옵션으로 할당을 막는 것은 가능합니다. 그러나 여전히 vue2에는 attrs 에 접근이 안 되는 것과 상속 받은 class 와 style 을 선택적으로 적용하거나 가공하는 것은 불가능하다는 문제가 있습니다. (vue3 부터는 모든 속성 제어 가능)
tailwind 는 class 에 미리 정해진 스타일명을 작성해서 스타일링을 하는 스타일링 라이브러리입니다. 여러 class 가 같은 속성을 정의하는 경우(스타일이 겹치는 경우) 우선순위는 내부의 정의 순서에 따라 적용이 되는 특징이 있습니다.
즉, 사용자가 정의한 순서가 아니라 라이브러리 내부적으로 정의한 순서에 따르기 때문에 동일한 속성을 스타일링했을 때 최종적으로 어떤 스타일이 적용될 지 예측이 어렵습니다. 상위 컴포넌트에서 정의한 class 와 하위 컴포넌트에서 정의한 class 를 합쳐서 충돌이 나면 사용자는 어떤 것이 적용될 지는 알 수 없고 원하는 대로 스타일링이 되지 않을 수 있는 문제가 생기게 됩니다.
tailwind 의 우선순위 문제를 해결하기 위해서 보통 twMerge 라이브러리를 사용하지만, vue2 에서는 fallthrough attribute 기능의 우선순위가 높아서 상속된 class 는 twMerge 를 적용할 수 없었습니다.
twMerge 가 적용된다면 text 관련 속성이 하나만 남아야 하지만 text-blue-500, text-red-500 두 가지 클래스가 모두 적용된 것을 알 수 있습니다.
시스템에서 정의해놓은 스타일이 있어서 변경하면 안 되는 상황에 개발자의 코드를 제한하지 않으면 문제가 발생할 수 있다고 생각했고, 위의 기술적인 이유로 tailwind + vue2 를 사용하고 있는 현재의 상태에서 디자인시스템 컴포넌트를 버그없이 사용할 수 있게 하는 방법을 고민하게 되었습니다.
라고 말하면 기억하는 사람이 얼마나 될까요?
위클리 회의 시간에 시스템 업데이트 사항을 공유드리기도 하지만 사람의 기억력에는 한계가 있기 때문에 개발할 때 놓칠 수 있다는 문제는 여전히 남아 있게 되었습니다. 또한 시스템 개발하는 사람도 놓치지 않고 공유를 해야 하며, 공유를 위한 준비 공수가 들어간다는 단점도 생기게 됩니다.
시스템에서 업데이트된 사항은 (이전부터) 문서화가 되고 있었습니다. 디자인팀과 개발팀의 논의 사항을 문서로 만들고 개발 완료된 부분을 문서 체크하는 형태로 운영을 하고 있으나, 근본적으로 시스템을 개발하는 사람과 시스템을 사용하는 사람이 다르고 프로덕트를 개발하면서는 문서를 챙겨보는 것도 병목의 원인이 되면서 굳이 찾아보기보다는 시스템 담당자에게 계속 물어보는 일이 생겼습니다.
“~ 하게 쓰고 싶은데 기능이 개발되어 있나요?”
”커스텀으로 ~ 하게 바꾸고 싶은데 속성에 추가해서 써도 되나요?”
시스템 담당자들이 프로덕트 개발에도 함께 참여하고 있는 실정이라 시스템 외적으로 프로덕트 전반을 신경쓰기도 어려운 상황이고 요청이 들어올 때마다 팔로업하고 추가 개발하는 것도 상당한 리소스 낭비라는 생각이 들었습니다.
반복적으로 일어나는 문제들을 해결하기 위해서는 컴포넌트의 자유도를 높일 필요가 있다고 생각했습니다. 다만 컴포넌트의 자유도를 높이는 경우 마찬가지로 어떻게 쓸 수 있는지 알려줘야 한다는 문제가 여전히 존재합니다. storybook 도 만들어서 배포하고 있으나 문서랑 마찬가지로 개발자가 직접 찾아야 하고 읽어야한다는 번거로움이 존재했고, 개발창(IDE)를 벗어나 문서를 읽는 것이 생산성의 저하를 일으킨다고 생각했습니다.
결국 자동화가 필요하다는 결론을 내리게 되었습니다. 다양한 자료들을 찾아보게 되었고 toss 에서 framer plugin(hand-off)을 직접 만들어 사용하시는 것을 보고 우리팀에도 적용하고자 했지만 토스와 다른 점은 저희 시스템은 자유도가 굉장히 작은 편이고 컴포넌트 방법이 다양하지 않기 때문에 당장 피그마 플러그인까지 만드는 것은 오버 스펙이라고 생각이 들었습니다.
1차로는 eslint plugin 으로 코드 품질을 높이고, 앞으로는 radix ui 를 사용해서 시스템을 업데이트할 예정이기 때문에 이 때 피그마 플러그인도 함께 개발하는 것이 저의 장기적 목표입니다.
디자인시스템의 Button 을 예시로 보면, 다음과 같이 최상단 컴포넌트에 class 로 스타일링이 적용되어 있습니다.
<!-- ssm-button.vue -->
<template>
<!-- 기본적으로 패딩( py-[15px] px-[20px] 이 들어있음) -->
<button
ref="buttonRef"
:class="[
'group',
'box-border disabled:scale-100 disabled:cursor-not-allowed',
'relative transition-transform active:scale-[0.95]',
'py-[15px] px-[20px]'
]"
v-bind="$attrs"
v-on="$listeners">
...
</button>
</template>
디자인시스템 컴포넌트 사용하면서 개발할 때 상위에서 스타일을 변경하려고 시도하는 경우 lint 에러를 발생시켜 버그를 방지하고 싶었습니다.
<!-- 에러 발생! -->
<template>
<ssm-button
style-key="btnFilledLarge01"
class="p-0">
버튼 예시
</ssm-button>
</template>
<!-- 에러 발생! -->
<template>
<ssm-button
style-key="btnFilledLarge02"
class="px-[10px]">
버튼 예시
</ssm-button>
</template>
https://astexplorer.net/ 를 사용해서 vue-eslint-parser 로 파싱했을 때 어떻게 AST 가 구성되는지를 파악할 수 있었습니다.
AST 기반으로 원하는 조건을 강제하거나 메시지를 노출시킬 수 있습니다.
/**
* rules/ssm-buttom-no-overwrite-style.js
*
* 기본 SsmButton 스타일을 덮어쓰지 못하도록 하는 규칙
*/
// SsmButton class 강제 규칙
const RULE = [
{
messageId: 'noTextClass',
includes: 'text-',
message:
'["{{ foundClass }}"] text-* 를 사용하지 마세요. SsmButton 스타일을 덮어쓸 수 있습니다.',
},
{
messageId: 'noBgClass',
includes: 'bg-',
message:
'["{{ foundClass }}"] bg-* 를 사용하지 마세요. SsmButton 스타일을 덮어쓸 수 있습니다.',
},
{
messageId: 'noBorderClass',
includes: 'border-',
message:
'["{{ foundClass }}"] border-* 를 사용하지 마세요. SsmButton 스타일을 덮어쓸 수 있습니다.',
},
];
module.exports = {
meta: {
type: 'problem',
docs: {
description: '<ssm-button> 컴포넌트의 스타일 덮어쓰기를 방지합니다.',
recommended: false,
},
fixable: null, // auto-fix가 가능하다면 'code'로 설정하고, fix 함수 추가
schema: [],
messages: RULE.reduce((acc, { messageId, message }) => {
acc[messageId] = message;
return acc;
}, {}),
},
create(context) {
// vue-eslint-parser가 제공하는 helper
// templateBodyVisitor: <template> 안의 AST를 순회할 수 있게 해줌
// vue-eslint-parser 를 사용하지 않았을 때 예외처리
const parserServices = context.parserServices;
const defineTemplateBodyVisitor = parserServices.defineTemplateBodyVisitor;
if (!defineTemplateBodyVisitor) {
return {};
}
return defineTemplateBodyVisitor({
// 모든 HTML 속성(VAttribute)을 방문
VAttribute(node) {
// 1) 이 속성이 <ssm-button> 태그에 있는지 확인
const parentElement = node.parent && node.parent.parent;
if (!parentElement || parentElement.type !== 'VElement') return;
if (parentElement.name !== 'ssm-button') return;
// 2) 속성 이름이 "class" 인지 확인 (ex: class="..." 형태)
if (!node.directive && node.key?.name === 'class') {
// 3) 정적 class 문자열에서 "includes"가 있는지 검사
if (node.value && node.value.type === 'VLiteral') {
const classValue = node.value.value;
RULE.forEach(({ includes, messageId }) => {
if (classValue.includes(includes)) {
context.report({
node,
messageId,
data: {
foundClass: classValue,
},
});
}
});
}
}
// 4) v-bind:class="..." 속성에서 "includes"가 있는지 검사
if (
node.directive &&
node.key?.name.name === 'bind' &&
node.key.argument?.name === 'class'
) {
const expr = node.value.expression;
if (
expr &&
expr.type === 'Literal' &&
typeof expr.value === 'string'
) {
const classValue = expr.value;
RULE.forEach(({ includes, messageId }) => {
if (classValue.includes(includes)) {
context.report({
node,
messageId,
data: {
foundClass: classValue,
},
});
}
});
}
}
},
});
},
};
실제 룰을 적용하고 lint 검사를 통해 custom rule 을 위반한 코드들을 손쉽게 확인할 수 있었고, 향후에 일어날 버그를 방지할 수 있었습니다.
vue2 와 talilwindcss 를 사용하고 있는 저의 특수한 상황에서는 꼭 필요한 해결책이었지만 react 를 사용하신다거나 혹은 다른 스타일링 라이브러리를 사용한다면 일어나지 않을 문제일 수 있습니다..ㅎㅎ 한 가지 방법 정도로 알아두시고 필요할 때 팀 내의 코드 퀄리티와 통일성을 높이기 위해 사용하시면 좋겠습니다.