Eslint Plugin 만들기: 요구사항에 맞는 패키지가 없다면

May 18, 2025

개인 프로젝트에서 다국어 지원을 위해 LinguiJS를 사용하고 있다. macro 기반 빌드타임 다국어 지원을 제공하여, 마크업과 코드에 한글을 그대로 사용하면서도 동적으로 에셋을 가져와 사용할 수 있다.

이전에 사용한 react-i18next와 비교해면 코드의 가독성이 훨씬 좋다. 이전에는 한글 에셋을 코드와 함께 확인하기 위해 vscode extension을 사용해야 했다.

Lingui에서는 Trans 컴포넌트 와 t, msg 템플릿 리터럴로 감싸면 자동으로 에셋으로 처리된다. 그렇지 않은 하드코딩된 문자열은 에셋으로 처리되지 않는다.

대부분의 하드코딩된 문자열은 개발자의 의도와 달리 누락된 부분일 것이다.

lingui 에서는 이를 감지하기 위해 eslint-plugin-lingui를 제공한다. 이 플러그인에서 제공하는 no-unlocalized-strings 룰을 사용하면 하드코딩된 문자열을 감지할 수 있다.

잘 사용하던 중 아래와 같은 버그를 발견했다.

<button onClick={() => alert("not linted")}>...</button>

위와 같이 jsx attribute 내부에서 사용된 문자열은 검사를 하지 않는 버그였다. 간단한 이벤트 핸들러는 인라인으로 작성하는 경우가 많은 나에게는 꽤나 중요한 이슈였다. 처음에는 기쁜 마음으로 pr을 올리려고 했으나, rule 코드를 확인해보니 관련 로직이 예상 이상으로 복잡했다. 그래서 내 요구사항에 맞는 간단한 플러그인을 만들기로 했다.

요구사항 정의하기

  1. 배포 및 사용 가능한 eslint-plugin을 만든다.
  2. 하드코딩된 문자열을 감지한다. 0. 하드코딩된 문자열을 감지한다.
    1. 하드코딩된 문자열을 감지할 때, t, msgTrans로 감싸진 문자열은 제외한다.
    2. import 문에서 사용한 문자열은 제외한다.
    3. 하드코딩된 문자열을 허용하는 jsx attribute 를 옵션으로 제공할 수 있도록 한다.
    4. 하드코딩된 문자열을 허용하는 regex를 옵션으로 제공할 수 있도록 한다.
    5. as const 로 감싸진 문자열은 제외한다.

구현하기

1. eslint-plugin 만들기

typescript를 파싱하여 린팅할 것 이므로, eslint plugin을 만들기 위한 가이드는 typescript-eslint에서 확인할 수 있다. 위 페이지에서 설명하는 RuleCreator API 와 ESTree에 대해 알고 있으면 구현이 수월하다.

나는 eslint-plugin-lingui를 보일러플레이트삼아 프로젝트의 전체적인 구조를 잡았다. 번들러로는 tsup을 사용했다.

RuleCreator 의 핵심 기능을 요약하자면, context.report()를 호출하면 eslint에서 제공하는 경고를 발생시킬 수 있다. create 메서드의 반환 객체의 key를 selector로 사용하여 ESTree의 각 노드를 순회할 수 있다.

2.0 하드코딩된 문자열을 감지한다.

하드코딩된 문자열을 감지하기 위해, Literal, TemplateLiteral, JSXText 노드를 검사한다. 이전 페이즈(selector)에서 제외할 수 있으므로, 경고는 최종 :exit 페이즈에서 발생시키도록 했다.

"TemplateLiteral:exit"(node) {
  context.report({ messageId: "default", node });
},
"Literal:exit"(node) {
  if (typeof node.value != "string") { // 문자열만 검사한다.
    return;
  }
  context.report({ messageId: "default", node });
},
"JSXText:exit"(node) {
  if (node.value.trim() === "") { // 개행이나 공백은 검사하지 않는다.
    return;
  }
  context.report({ messageId: "forJsxText", node });
},

2.1 하드코딩된 문자열을 감지할 때, t, msgTrans로 감싸진 문자열은 제외한다.

Trans 컴포넌트 하위의 모든 문자열을 제외하기 위해 selector 를 사용한다.

const skip = new WeakSet();

// ... 

'JSXElement[openingElement.name.name="Trans"] JSXText'(node: TSESTree.JSXText) {
  skip.add(node);
}

template literal 중, 부모가 t 또는 msg인 경우를 제외하기 위해, isTemplateLiteralExpression 함수를 사용했다.

const isTemplateLiteralExpression = (
  node: TSESTree.TemplateLiteral,
  name: string
) => {
  return (
    node.parent.type === AST_NODE_TYPES.TaggedTemplateExpression &&
    node.parent.tag.type == AST_NODE_TYPES.Identifier &&
    node.parent.tag.name === name
  );
};
// ...
"TemplateLiteral:exit"(node) {
  // 조건문 추가
  if (
    isTemplateLiteralExpression(node, "t") ||
    isTemplateLiteralExpression(node, "msg")
  ) {
    return;
  }
  context.report({ messageId: "default", node });
},

2.2 import 문에서 사용한 문자열은 제외한다.

import 문에서 사용한 문자열을 제외하기 위해, ImportDeclaration 노드를 검사한다.

"Literal:exit"(node) {
  // 조건문 추가
  if (node.parent.type === AST_NODE_TYPES.ImportDeclaration) {
    return;
  }
  if (typeof node.value != "string") {
    return;
  }
  context.report({ messageId: "default", node });
},

2.3, 2.4 하드코딩된 문자열을 허용하는 옵션을 제공한다. (ignoreAttributes, ignore)

옵션을 받기 위해, createRule의 schema를 아래와 같이 정의했다.

{
  schema: [
    {
      type: "object",
      properties: {
        ignoreAttributes: {
          type: "array",
          items: {
            type: "string",
          },
        },
        ignore: {
          type: "array",
          items: {
            type: "string",
          },
        },
      },
      additionalProperties: false,
    },
  ]
}

이제 create 메서드에서 context.options를 통해 옵션을 가져올 수 있다.

const { ignore = [], ignoreAttributes = [] } = context.options[0] ?? {};
const isIgnoredAttribute = (attributeName: string) => {
  return ignoreAttributes.some((regex) =>
    new RegExp(regex).test(attributeName)
  );
};
const isIgnoredLiteral = (literal: string) => {
  return ignore.some((regex) => new RegExp(regex, "u").test(literal));
};

그리고 각 페이즈에서 context.report를 호출하기 전에, 위에서 정의한 isIgnoredAttributeisIgnoredLiteral 함수를 사용하여 검사한다.

2.5 as const 로 감싸진 문자열은 제외한다.

as const로 감싸진 문자열을 제외하기 위해, TSAsExpression 노드를 검사한다.

const isAsConstExpression = (
  node: TSESTree.Literal | TSESTree.TemplateLiteral
) => {
  return (
    node.parent.type === AST_NODE_TYPES.TSAsExpression &&
    node.parent.typeAnnotation.type === AST_NODE_TYPES.TSTypeReference &&
    node.parent.typeAnnotation.typeName.type === AST_NODE_TYPES.Identifier &&
    node.parent.typeAnnotation.typeName.name === "const"
  );
};
//  ...

"Literal:exit"(node) {
  // 조건문 추가
  if (isAsConstExpression(node)) {
    return;
  }
  if (typeof node.value != "string") {
    return;
  }
  context.report({ messageId: "default", node });
}

결과물

실제로 개인 프로젝트에서 eslint-plugin-lingui와 함께 정적 텍스트를 검사하는데 사용하고 있다.

// eslint.config.js
import { defineConfig } from "eslint-config-react-app-essentials";
import pluginLingui from "eslint-plugin-lingui";
import PluginLingui2 from "@lee-donghyun/eslint-plugin-lingui";

export default defineConfig({
  tsconfigRootDir: "./tsconfig.json",
  scope: ["src/**/*.{ts,tsx}"],
  extends: [
    pluginLingui.configs["flat/recommended"],
    PluginLingui2.configs.recommended,
    {
      rules: {
        "@lee-donghyun/lingui/no-unlocalized-strings": [
          "error",
          {
            ignoreAttributes: ["className", "src", "data-testid"],
            ignore: ["^[a-zA-Z0-9\\s\\p{P}\\p{S}]*$"],
          },
        ],
      },
    },
  ],
});

마치며

프로젝트가 성숙해질수록, 정확한 요구사항에 부합하는 패키지를 찾기란 점점 더 어려워진다. 어렵게 찾은 패키지라 하더라도 유지 관리가 미흡하거나, 추가적인 요구사항을 충족하지 못하는 경우도 적지 않다. 이처럼 요구사항이 명확히 정의되어 있다면, 이를 기반으로 새로운 구현체를 직접 개발하는 것도 충분히 합리적인 선택이 될 수 있다. 예를 들어, 토스가 Deus라는 디자인 편집기를 직접 개발한 것도 이와 같은 맥락일 것이다. Framer가 노코드 툴로 피벗하면서, 더 이상 토스의 요구사항을 만족시키지 못했기 때문일 것이다. 개발자로서 팀의 일원이라면, 팀의 구체적인 요구와 가용한 리소스를 면밀히 파악하고, 그에 맞는 현명한 결정을 내려야 한다. 결국 모든 선택에는 트레이드오프가 존재한다!