안녕하세요 개발하는 토마토입니다 🍅 저번 블로그 글을 두달 전에 쓰고,, 다시 저의 프로젝트로 돌아와서 글을 쓰게 되었습니다 ㅎㅎ 주제는 저번에 1편을 썼던 것에 이어, 저의 designLint가 어떻게 하드코딩된 스타일 된 값을 막는지에 대해서 조금 더 자세히 살펴보려고 해요.
이 규칙이 해결하려고 한 문제
예를 들어 아래와 같은 코드는 언뜻 보면 큰 문제가 없어 보입니다.
const Button = styled.button`
background-color: #8052E1;
color: #FFFFFF;
`;
하지만 이런 코드가 쌓이기 시작하면 디자인 시스템에서 정의한 토큰과 실제 UI 코드가 점점 멀어지게 됩니다. 색상 변경이 필요할 때도 토큰만 수정해서 끝나지 않고, 곳곳에 흩어진 하드코딩 값을 다시 찾아야 했습니다.
이 문제를 줄이기 위해 “색상은 직접 쓰지 말고 반드시 토큰을 사용하도록” lint 단계에서 검사하는 방식을 선택했습니다.
규칙은 어떻게 동작하도록 만들었을까?
이번에 만든 no-hardcoded-color 룰은 크게 아래 순서로 동작하도록 구성했습니다.
- Stylelint rule이 CSS declaration을 순회했습니다.
- 그중 색상 관련 속성만 골라냈습니다.
- 선언 값에서 hex 색상을 추출했습니다.
- 추출한 값을 비교 가능한 형태로 정규화했습니다.
- theme 파일에서 만든 색상 맵과 비교했습니다.
- 일치하면 해당 토큰을 추천했고, 일치하지 않으면 하드코딩 색상으로 경고했습니다.
실제 rule의 핵심 코드는 아래와 같습니다 !
root.walkDecls((decl) => {
const prop = decl.prop.toLowerCase();
const isColorProp =
prop === "color" ||
prop === "background" ||
prop === "background-color" ||
prop === "border-color";
if (!isColorProp) return;
const match = decl.value.match(HEX_COLOR_REX);
if (!match) return;
const rawHex = match[0];
const norm = normalizeHex(rawHex);
const tokens = valueToTokens.get(norm);
if (tokens?.length) {
stylelint.utils.report({
ruleName,
result,
node: decl,
message: messages.suggested(rawHex, tokens[0]),
});
return;
}
stylelint.utils.report({
ruleName,
result,
node: decl,
message: messages.rejected(rawHex),
});
});
이 로직의 핵심은 “색상값을 발견한 뒤, 그 값을 기준으로 토큰을 역으로 찾는다”는 점이었습니다.
1. 색상 관련 속성만 먼저 좁혀서 검사했습니다
처음부터 모든 스타일 속성을 다 검사하지는 않았습니다. 우선은 실제로 하드코딩이 자주 등장하는 속성만 대상으로 잡았습니다.
const isColorProp =
prop === "color" ||
prop === "background" ||
prop === "background-color" ||
prop === "border-color";
이렇게 범위를 좁힌 이유는 처음부터 너무 넓게 잡으면 오탐 가능성이 커지고, 규칙의 신뢰도가 떨어질 수 있기 때문이었습니다. 일단 가장 자주 문제가 생기는 속성부터 안정적으로 잡는 것이 우선이라고 판단했습니다.
2. theme 파일은 import하지 않고 정적으로 읽었습니다
이 규칙을 만들면서 가장 먼저 고민했던 것은 “theme 정보를 어떻게 가져올 것인가”였습니다.
가장 단순한 방법은 theme 파일을 직접 import해서 객체를 읽는 것이었습니다. 하지만 lint 규칙은 가능하면 런타임 실행에 덜 의존하는 편이 더 안정적이라고 생각했습니다. 그래서 theme 파일을 실행하는 대신, 소스 코드를 문자열로 읽어서 필요한 부분만 분석하는 방식을 사용했습니다.
먼저 theme 파일 원본을 읽었습니다.
export function readThemeSource(themePath: string) {
const abs = path.isAbsolute(themePath)
? themePath
: path.resolve(process.cwd(), themePath);
return fs.readFileSync(abs, "utf8");
}
그 다음에는 colors 블록을 추출하고, 실제 비교에 쓸 색상 맵을 만들었습니다.
export function buildThemeColorMap(themePath: string) {
const src = readThemeSource(themePath);
const block = extractObjectBlock(src, "colors");
if (!block) throw new Error(`colors block not found in ${themePath}`);
const colors = parseColorsHexOnly(block);
const valueToTokens = buildColorReverseMap(colors);
return { colors, valueToTokens };
}\
이 방식의 장점은 theme 객체 전체를 실행하지 않아도 되었고, lint 환경에서 필요한 정보만 정적으로 추출할 수 있었습니다
3. hex 값은 비교 전에 정규화했습니다
색상 비교에서 의외로 중요한 부분은 “같은 색이라도 문자열 형태가 다를 수 있다”는 점이었습니다.
예를 들어 아래 두 값은 실제로는 같은 색입니다.
#FFF
#ffffff
하지만 문자열 그대로 비교하면 다른 값으로 처리되기 때문에 비교 전에 값을 정규화하는 함수를 만들었습니다.
export function normalizeHex(hex: string) {
const s = hex.trim().toLowerCase();
const m3 = s.match(/^#([0-9a-f]{3})$/i);
if (m3) {
const t = m3[1];
return `#${t[0]}${t[0]}${t[1]}${t[1]}${t[2]}${t[2]}`;
}
const m6 = s.match(/^#([0-9a-f]{6})$/i);
if (m6) return `#${m6[1]}`;
return s;
}
이 함수는 모든 문자를 소문자로 변환하고, 3자리 hex 를 6자리 hex 로 확장하였습니다.
예를 들면 아래처럼 통일되었습니다.
- #FFF -> #ffffff
- #8052E1 -> #8052e1
이 과정을 넣으니 theme 값과 실제 코드 값을 훨씬 안정적으로 비교할 수 있었습니다.
4. 토큰 맵은 정방향이 아니라 역방향으로
처음에는 primary -> #8052E1 같은 형태의 맵을 떠올렸습니다. 그런데 rule 입장에서는 하드 코딩된 색상값을 통해 실제 디자인 토큰으로 지정되어 있는 이름을 찾아내야 했습니다.
export function buildColorReverseMap(
colors: Record<string, string>,
tokenPrefix = "theme.colors."
) {
const valueToTokens = new Map<string, string[]>();
for (const [key, value] of Object.entries(colors)) {
const norm = normalizeHex(value);
const token = `${tokenPrefix}${key}`;
const arr = valueToTokens.get(norm) ?? [];
arr.push(token);
valueToTokens.set(norm, arr);
}
return valueToTokens;
}
이렇게 reverse map을 만들어 두면, lint 중에 #8052E1을 발견했을 때 정규화만 거쳐 바로 대응되는 토큰을 찾을 수 있었습니다. 구현도 단순해지고, rule 본체의 책임도 분명해졌습니다.
5. 토큰과 일치할 때는 더 구체적인 메시지를 제공했습니다
이번 룰은 단순히 “하드코딩 금지”만 말하지 않도록 만들었습니다. 하드코딩된 색상이 theme에 있는 값과 정확히 일치한다면, 어떤 토큰을 써야 하는지까지 구체적으로 안내하고 싶었습니다.
const messages = stylelint.utils.ruleMessages(ruleName, {
rejected: (value: string) =>
`Hardcoded color "${value}" detected. Use theme.colors.* token instead.`,
suggested: (value: string, token: string) =>
`Hardcoded color "${value}" detected. Use ${token}.`,
});
예를 들어 #8052E1이 theme.colors.primary와 일치한다면 아래처럼 더 구체적인 메시지를 보여주도록 했습니다.
Hardcoded color "#8052E1" detected. Use theme.colors.primary.
반대로 theme 토큰에서 찾을 수 없는 값이라면 아래처럼 일반 경고 메시지를 보여주도록 했습니다.
Hardcoded color "#123456" detected. Use theme.colors.* token instead.
실제 토큰에 있는데도 사용하지 않은 값에 대해서는 명확하게 토큰을 사용할 수 있도록 하였고, 만약 토큰에 명시되어 있지 않은 색의 경우
디자인 토큰 형태로 추가할 수 있도록 하였습니다.
6. 같은 theme는 한 번만 분석하도록 캐시를 넣었습니다
Stylelint rule은 여러 파일을 검사하면서 반복 실행됩니다. 이때 매번 theme 파일을 다시 읽고, 다시 파싱하면 비효율적이라고 판단했습니다. 그래서 themePath 기준의 간단한 캐시를 추가했습니다.
let cached: {
themePath: string;
valueToTokens: Map<string, string[]>;
} | null = null;
function getValueToTokens(themePath: string) {
if (cached?.themePath === themePath) return cached.valueToTokens;
const { valueToTokens } = buildThemeColorMap(themePath);
cached = { themePath, valueToTokens };
return valueToTokens;
}
큰 최적화는 아니지만, 같은 theme 파일을 기준으로 여러 컴포넌트를 검사하는 상황에서는 불필요한 반복 작업을 줄일 수 있었습니다.
간단히 동작 방식 살펴보기
theme에 아래와 같은 값이 정의되어 있다고 가정해보겠습니다.
export const LIGHT_THEME = {
colors: {
primary: "#8052E1",
text: "#000000",
},
};
만약 아래와 같은 버튼이 만들어져있다면 :
const Button = styled.button`
background-color: #8052E1;
color: #000;
border-color: #123456;
`;
lint 를 실행 시켰을 때, 아래와 같이 메시지가 나오게 됩니다.
Hardcoded color "#8052E1" detected. Use theme.colors.primary.
Hardcoded color "#000" detected. Use theme.colors.text.
Hardcoded color "#123456" detected. Use theme.colors.* token instead.
결과적으로 이 룰을 통해, “디자인 토큰이 존재한다”는 상태에서 한 걸음 더 나아가 “개발 과정에서 자연스럽게 토큰을 사용하게 만드는 구조”를 만들 수 있었습니다. 디자인 시스템은 문서로 정리하는 것도 중요하지만, 결국 개발자가 실제로 사용하지 않으면 아무 의미가 없습니다.
그런 점에서 현재 라이브러리 개발하는 과정은 디자인 시스템을 ‘읽는 것’에서 ‘지켜지게 만드는 것’으로 바꾸는 작은 전환이었다고 느꼈습니다. 앞으로도 font, spacing 등 프로젝트 내에서 사용되고 있는 토큰에 대한 규칙을 추가하고, 가능하다면 AI를 통해 새로운 색상 값이 들어왔을 때 어떻게 디자인 시스템에 넣으면 좋을지 추천해주는 것 까지도 나아가보려고 합니다 🙇♀️
'궁금증 해결소' 카테고리의 다른 글
| [라이브러리 개발 일기] 디자인 시스템 컨벤션 좀 지켜 !! - 1 (1) | 2026.01.06 |
|---|---|
| ‘깨져도 괜찮은 웹’을 만드는 법 – 우아한 낮춤 (0) | 2025.10.20 |
| 프론트 개발자가 바라보는 멱등성🔥 (0) | 2025.10.17 |
| TroubleShoot Ep.1 험난했던 빌드 오류 해결의 과정.. (2) | 2025.01.22 |
| [자바스크립트] 정적 메서드, 인스턴스 메서드 뭐가 달라? (3) | 2024.11.30 |