<eslint-plugin-layered-imports 제작기 시리즈> 이어서 보기
- <GitHub에서 플러그인 구경하기: https://github.com/electrohyun/eslint-plugin-layered-imports >
- eslint-plugin-layered-imports 제작기 - 5 | 배포와 회고
- eslint-plugin-layered-imports 제작기 - 4 | 옵션과 프리셋 구현하기
- eslint-plugin-layered-imports 제작기 - 3 | Autofix 구현하기
- eslint-plugin-layered-imports 제작기 - 2 | ESLint Rule 작성하기 (현재)
- eslint-plugin-layered-imports 제작기 - 1 | 구조를 지키는 ESLint 규칙 만들기

안녕하세요! electrohyun입니다.
지난 글에서는 제가 왜 eslint-plugin-layered-imports를 만들게 되었는지, 그리고 import를 어떤 기준으로 나누고 싶었는지 정리했었습니다. 요약하자면요...
import path from "node:path";
import React from "react";
import { clsx } from "clsx";
import { userApi } from "@/entities/user";
import { Button } from "@/shared/ui";
import styles from "./style.module.css";
import { helper } from "../lib/helper";
이렇게 import를 builtin, external, internal, relative 네 가지 그룹으로 나누고, 그룹 사이에 빈 줄을 두어 파일의 의존 관계가 한눈에 보이도록 만들고자 플러그인 제작을 시작했습니다. 이번 글에서는 이 규칙을 실제 ESLint Rule로 옮기는 과정을 다뤄보려고 합니다. 그냥 "문자열을 읽어서 import로 시작하는 줄만 검사하면 되는 것 아닌가?"라는 생각도 했는데요, ESLint는 코드를 단순 문자열로 보는게 아니라 AST라는 구조로 바꾼 뒤 그 구조를 탐색하더라고요. 그래서 이번에 제가 할 일은, "import 문을 문자열로 찾는 것이 아니라, AST에서 ImportDeclaration을 찾기"가 되겠습니다. 그럼 시작해보겠습니다.
(1) ESLint Rule은 어디에서 시작할까?
ESLint 플러그인을 만들면, 플러그인의 실제 규칙은 보통 rules 폴더 안에 들어갑니다. 이번 플러그인에서는 import-spacing이라는 rule을 만들 예정이므로, 대략 이런 파일을 만들게 됩니다.
src/rules/import-spacing.ts
그리고 rule의 기본 구조는 이런 형태입니다.
const rule = {
meta: {
type: "layout",
docs: {
description: "Require blank lines between different import groups",
recommended: false,
},
schema: [],
messages: {
missingBlankLine:
"Expected a blank line between different import groups.",
},
},
create(context) {
return {
Program(node) {
// 검사 로직
},
};
},
};
export default rule;
처음 봤을 때는 meta, create, context, Program 같은 단어가 한 번에 나와서 조금 어지러웠습니다 ㅎㅎ
그런데 찾아보면 그렇게 복잡하지도 않고, 바로 기억할 수 있게 되더라고요.
1. 먼저 meta는 이 rule에 대한 설명입니다.
type: "layout";
1-1. type은 rule의 성격을 나타냅니다.
ESLint rule의 type은 보통 세 가지로 나눌 수 있습니다.
- problem: 버그 가능성을 잡는 규칙
- suggestion: 더 나은 코드 패턴을 제안하는 규칙
- layout: 공백, 줄바꿈, 세미콜론 같은 코드 모양 관련 규칙
이번에 제가 만들 import-spacing rule은 import 그룹 사이에 빈 줄이 있는지를 검사합니다.
즉, 코드의 동작을 바꾸는 문제가 아니라 코드의 배치를 다루는 규칙입니다. 그래서 layout이 맞다고 판단했네요.
messages: {
missingBlankLine:
"Expected a blank line between different import groups.",
}
1-2. messages는 나중에 ESLint가 사용자에게 보여줄 에러 메시지 목록입니다. 이 메시지는 뒤에서 context.report()를 호출할 때 사용하게 됩니다.
(2) create(context)는 무엇인가?
가장 중요한 부분입니다! create(context)입니다.
create(context) {
return {
Program(node) {
// 검사 로직
},
};
}
create는 ESLint가 rule을 실행할 때 호출하는 함수입니다. context는 ESLint가 rule에게 넘겨주는 도구 상자 같은 객체로, 문제가 있는 코드를 발견했을 때는 이렇게 사용됩니다.
context.report({
node,
messageId: "missingBlankLine",
});
이렇게 하면 ESLint가 해당 node 위치에 lint error를 표시합니다. 즉, rule은 대략 이런 흐름으로 작동합니다.
ESLint가 파일을 읽음 ->
코드를 AST로 변환함 ->
우리 rule의 create(context)를 호출함 ->
우리가 등록한 AST 노드를 만나면 해당 함수 실행 ->
문제가 있으면 context.report() 호출.
여기서 중요한 점은, ESLint Rule은 “어떤 AST(Abstract Syntax Tree) 노드를 만났을 때 어떤 코드를 실행할지”를 찾는 식으로 동작한다는 것입니다. 예를 들면 이렇게 작성할 수 있습니다...
create(context) {
return {
Program(node) {
// 파일 전체를 볼 때 실행
},
ImportDeclaration(node) {
// import 문 하나를 볼 때마다 실행
},
};
}
ESLint는 AST를 쭉 걷습니다.
-> Program을 만나면 Program(node)를 실행하고,
-> ImportDeclaration을 만나면 ImportDeclaration(node)를 실행합니다.
이런 방식을 Visitor Pattern이라고 부릅니다.
간단합니다. AST를 돌아다니다가 내가 관심 있는 노드를 만나면, 등록해둔 함수를 실행한다.
이번 플러그인에서 우리가 관심 있는 노드는 import 문입니다. 그리고 import 문은 AST에서 ImportDeclaration으로 표현됩니다.
(3) AST에서 import 문은 어떻게 보일까?
import React from "react";
const count = 1;
우리가 보는 코드는 이런 텍스트 느낌이라면,
Program
├─ ImportDeclaration
│ ├─ source: "react"
│ └─ specifiers: React
└─ VariableDeclaration
└─ declarations
└─ id: count
하지만 ESLint가 보는 구조는 대략 이런 느낌이 됩니다. (시각화는 GPT 도움을 받았습니다!)
우리가 보는 import React from "react";는 ImportDeclaration이 되는 셈입니다.
여기서 가장 중요한 값은, "node.source.value"입니다.
가령, 예를 들어 다음 코드가 있다고 해보겠습니다.
import React from "react";
import { Button } from "@/shared/ui";
import helper from "../lib/helper";
각 import의 source.value는 다음과 같습니다.
"react", "@/shared/ui", "../lib/helper"
이제 감이 오는데요, import를 분류하려면 전체 import 문장을 전부 직접 파싱할 필요가 없습니다. node.source.value만 읽어도 이 import가 어디에서 가져오는지 알 수 있습니다.
- "react" → 외부 패키지
- "@/shared/ui" → 내부 alias
- "../lib/helper" → 상대 경로
즉, import 정리 rule의 출발점은 ImportDeclaration이고, 그 안에서도 핵심은 node.source.value였습니다.
(4) 왜 ImportDeclaration을 바로 검사하지 않았나?
create(context) {
return {
ImportDeclaration(node) {
// import 하나를 만날 때마다 검사
},
};
}
처음 만들 때는 그렇게 했고, 언뜻 보면 들법한 생각입니다. 그러나 이번 rule은 import 하나만으로 판단할 수 없습니다.
import React from "react";
import { Button } from "@/shared/ui";
예를 들어 이런 코드가 있다고 하면... 첫 번째 import는 external이고, 두 번째 import는 internal입니다.
이 두 import 사이에는, 제가 의도한 규칙대로 작성하려면 빈 줄이 있어야 합니다.
import React from "react";
import { Button } from "@/shared/ui";
이 예시 속에서는, 이걸 검사하려면 현재 import 하나만 보는 방법도 있고, 모든 import를 보는 방법도 있습니다. 그래서 어떤 방법이든 상관 없지 않을까- 하는 생각이 들 수 있습니다. 그러나 앞으로, 더 나아가 external, internal, ... 등의 그룹 순서까지 검사하려던 계획을 생각해보면, 파일 안에 있는 import 목록 전체를 봐야만 이것을 고려할 수 있다는 것을 느낄 수 있습니다.
그래서 저는 ImportDeclaration을 바로 처리하는 대신,
Program에서 파일 전체를 보고 그 안의 import 문을 모으는 방식으로 작성했습니다.
Program(node) {
const imports = node.body.filter(
(statement) => statement.type === "ImportDeclaration",
);
}
Program은 파일 전체를 나타내는 AST 노드입니다. 그리고 node.body에는 파일의 최상위 statement들이 들어 있습니다.
여기서 statement.type === "ImportDeclaration"인 것만 골라내면, 파일 상단의 import 문들을 모을 수 있습니다. 이제 이 import들을 하나씩 분류하면 됩니다.
(5) import source를 그룹으로 분류하기
1편에서 정한 그룹은 네 가지였습니다. type ImportGroup = "builtin" | "external" | "internal" | "relative"; 그리고 import source를 읽어서 각 그룹으로 나눕니다. 대략적인 흐름은 이렇습니다.
function getImportGroup(source: string): ImportGroup {
if (isBuiltin(source)) {
return "builtin";
}
if (source.startsWith(".") || source.startsWith("..")) {
return "relative";
}
if (source.startsWith("@/")) {
return "internal";
}
return "external";
}
물론 실제 구현에서는 Node.js builtin 모듈 판단이나 internal alias 옵션 같은 부분을 더 고려해야 합니다. 하지만 처음 rule을 만들 때 든 생각은 이 정도였습니다.
node:path → builtin
react → external
@/shared/ui → internal
./style.module.css → relative
이렇게 source를 그룹으로 분류하면, import 문은 “이 파일이 어디에 의존하는지”를 나타내는 정보가 됩니다. 추가적으로, import 목록을 이런 형태로 바꿔주었습니다.
const importEntries = imports.flatMap((importNode) => {
const source = importNode.source.value;
if (typeof source !== "string") {
return [];
}
return [
{
node: importNode,
source,
group: getImportGroup(source),
},
];
});
string 관련 코드가 보입니다. 아래처럼 as string을 사용해서 처리했었는데요...
const importEntries = imports.map((importNode) => ({
node: importNode,
source: importNode.source.value,
group: getImportGroup(importNode.source.value as string),
}));
그런데 이 방식은 조금 아쉬웠습니다. node.source.value가 일반적인 import 구문에서는 문자열로 들어온다고 기대할 수는 있겠지만, 타입 단언으로 as string을 붙여버리면 그 판단을 코드가 아니라 제가 대신 보장하는 셈이 된다고 생각했습니다.
물론 지금 다루는 ImportDeclaration에서는 문제가 되지 않을 수 있습니다. 하지만 ESLint Rule은 사용자의 코드를 입력으로 받는 도구이고, AST 노드의 값도 타입상 항상 제가 원하는 형태라고 단정하기보다는 실제 값이 문자열인지 한 번 확인하는 편이 더 안전하다고 생각했습니다.
TypeScript를 사용하는 이유도 결국 이런 애매한 지점을 그냥 넘기지 않기 위해서라고 느꼈습니다. 그래서 as string으로 밀어붙이기보다, typeof source !== "string"인 경우를 먼저 걸러내고, 문자열인 경우에만 import entry로 변환하는 방식으로 바꾸었습니다.
if (typeof source !== "string") {
return [];
}
string이 아니면 빈 배열을 반환하고, string이면 import entry 하나를 반환합니다.
이렇게 하면 최종 결과는 ImportEntry[] 형태로 깔끔하게 남습니다.
const importEntries = imports.flatMap((importNode) => {
const source = importNode.source.value;
// if문...
return [
{
node: importNode,
source,
group: getImportGroup(source),
},
];
});
이후, flatMap을 사용했습니다. map으로 작성하여 중간에 null이나 undefined가 섞일 수 있는 문제를 방지했습니다.
(6) 그룹 사이 빈 줄 검사하기
이제 import 목록을 얻었으니, 서로 인접한 import끼리 비교할 수 있습니다. 이번 rule의 첫 번째 목표는 그룹이 다른 import 사이에 빈 줄이 있는지 확인하는 것이었습니다. 예를 들어 다음 코드같은 상황이 문제겠네요.
import React from "react";
import { Button } from "@/shared/ui";
React는 external이고, Button은 internal입니다. 서로 다른 그룹이므로 빈 줄이 있어야 합니다.
import React from "react";
import { Button } from "@/shared/ui";
그럼 빈 줄은 어떻게 확인할 수 있을까요? 각 AST 노드에는 사실!! 위치 정보가 있습니다.
previousImport.node.loc.end.line
currentImport.node.loc.start.line
바로 이것입니다. 이것을 활용하여, 이전 import가 끝난 줄과 현재 import가 시작하는 줄을 비교하면 됩니다. 대략 이런 식으로요.
const hasBlankLine =
currentImport.node.loc.start.line -
previousImport.node.loc.end.line >
1;
두 import가 바로 붙어 있다면 줄 차이가 1입니다. 그 사이에 빈 줄이 있다면 줄 차이가 2 이상이 됩니다. 그래서 조건은 이렇게 정리할 수 있습니다.
if (
previousImport.group !== currentImport.group &&
!hasBlankLine
) {
context.report({
node: currentImport.node,
messageId: "missingBlankLine",
});
}
뜻은 이렇습니다.
이전 import와 현재 import의 그룹이 다르다. ->
그런데 둘 사이에 빈 줄이 없다. ->
그러면 ESLint error를 보고한다.
여기까지 구현하면, 드디어 첫 rule이 동작합니다.🥳
아직 자동으로 고쳐주지는 않지만요! ESLint가 문제를 찾아서 알려줄 수 있도록 만들었네요.
(7) 그룹 순서도 검사하기
빈 줄만 검사하면 조금 부족합니다. 다음과 같은 코드를 생각해보겠습니다.
import { Button } from "@/shared/ui";
import React from "react";
두 import 사이에 빈 줄은 있지만... 순서가 이상하지 않나요? 제가 정한 기본 순서는 다음과 같기 때문입니다.
const DEFAULT_GROUPS = [
"builtin",
"external",
"internal",
"relative",
];
external이 internal보다 먼저 와야 하는데, 위 코드는 internal이 먼저 나오고 external이 나중에 나옵니다. 그래서 그룹 순서도 검사해야 했습니다. 방법은 단순합니다. 각 group이 DEFAULT_GROUPS에서 몇 번째인지 index로 바꿉니다.
const currentGroupIndex = groups.indexOf(currentImport.group);
그리고 지금까지 본 group index 중 가장 큰 값을 기억해둡니다.
let highestSeenGroupIndex = groups.indexOf(importEntries[0].group);
이후 import를 어떤 방법으로든 순회하는 동안(for를 사용할 거라고 예상하실 수 있겠죠!)
현재의 group index가 이미 지나온 group index보다 작다면, 순서가 거꾸로 된 것이라고 볼 수 있겠네요.
if (currentGroupIndex < highestSeenGroupIndex) {
context.report({
node: currentImport.node,
messageId: "unexpectedGroupOrder",
});
}
highestSeenGroupIndex = Math.max(
highestSeenGroupIndex,
currentGroupIndex,
);
“왜 굳이 이 코드의 범위- 즉 순회 for문 안에서 이전 인덱스와 현재 인덱스를 비교하는 게 아니라, let 변수를 하나 만들어서 비교하시나요? 낭비 아닌가요? -_-”라고 생각하실 수 있습니다. (위 코드가 for문 안에 들어가는 내용이라고 보시면 됩니다.)
저도 바로 이전 import와 현재 import만 비교하도록 작성했었는데요... 바꾼 이유가 있습니다. 이 rule에서 확인하려는 것은 “바로 앞 import보다 현재 import가 앞선 그룹인가?”가 아니라, “지금까지 등장한 그룹의 흐름이 다시 앞쪽 그룹으로 되돌아가고 있는가?”를 확인하는 것입니다.
예를 들어 기본 그룹 순서가 다음과 같다고 해보겠습니다. ["builtin", "external", "internal", "relative"]
이때 relative import가 이미 등장했다면, 그 뒤에는 더 이상 builtin, external, internal이 나오면 안 됩니다. relative는 기본 순서상 가장 뒤쪽 그룹이기 때문입니다. 그런데 바로 이전 import와 현재 import만 비교하면, 순서가 한 번 깨진 뒤에 기준이 다시 낮은 index로 내려갈 수 있습니다. 그러면 “이미 relative까지 봤다”는 사실을 잊어버리는 셈이 됩니다. highestSeenGroupIndex라는 변수를 따로 둔 이유는 바로 이 이유입니다. 주석을 달지 말지 고민해야 하는 포인트가 아닌, 애초에 다뤄야하는 부분이 조금 달랐던 문제였다고 봐주시면 될 것 같습니다.
if (currentGroupIndex < highestSeenGroupIndex) {
context.report({
node: currentImport.node,
messageId: "unexpectedGroupOrder",
});
}
highestSeenGroupIndex = Math.max(
highestSeenGroupIndex,
currentGroupIndex,
);
Math.max()를 사용하는 이유도 같습니다. 더 뒤쪽 그룹을 만나면 기준은 뒤로 이동해야 하지만, 앞쪽 그룹이 다시 등장했다고 해서 기준이 앞으로 돌아가면 안 됩니다. 한 번 relative까지 봤다면, 이후 기준은 계속 relative에 머물러야 합니다.
정리하면 highestSeenGroupIndex는 이전 값을 편하게 let 할당해서 기억하자는 의미가 아니라, import 그룹 순서를 검사하는 동안 “현재까지 어디까지 진행했는지”를 기억하는 상태값으로써 활용하고자 했습니다. let 변수 하나가 추가되긴 하지만, 이 덕분에 인접한 두 import만 보는 것보다 전체 import 흐름을 더 안정적으로 검사할 수 있을 것이라 생각하였습니다.
(8) context.report()로 문제 알리기
ESLint Rule의 검사 결과는 결국 context.report()로 이어집니다.
context.report({
node: currentImport.node,
messageId: "missingBlankLine",
});
또는 그룹 순서가 잘못된 경우에는 이런 식으로 사용할 수 있습니다.
context.report({
node: currentImport.node,
messageId: "unexpectedGroupOrder",
});
여기서 node는 문제가 발생한 위치입니다. 그리고 messageId는 meta.messages에 정의해둔 메시지의 key입니다. 예를 들어 meta.messages가 이렇게 되어 있다면...
messages: {
missingBlankLine:
"Expected a blank line between different import groups.",
unexpectedGroupOrder:
"Expected import groups to be ordered.",
}
messageId: "missingBlankLine"을 사용했을 때 ESLint는 해당 메시지를 출력합니다. 즉, rule의 흐름은 이렇게 정리할 수 있습니다.
import 목록을 만든다. ->
각 import의 source를 읽는다. ->
source를 group으로 분류한다. ->
이전 import와 현재 import를 비교한다. ->
빈 줄이 없거나 순서가 이상하면 context.report()를 호출한다.
이 과정이 끝나면 ESLint는 사용자의 코드에서 문제가 있는 위치를 알려줄 수 있습니다.
(9) RuleTester로 확인하기
rule을 만들었으니, 테스트를 해봅시다. 테스트를 하는 이유는, ESLint rule을 직접 눈으로 확인하기보다, “이 코드는 valid여야 하고, 이 코드는 invalid여야 한다”는 테스트 케이스를 만들어두는 편이 훨씬 안전하다고 생각했기 때문입니다. ESLint에서는 RuleTester라는 테스터를 사용합니다.
const ruleTester = new RuleTester({
languageOptions: {
ecmaVersion: 2022,
sourceType: "module",
},
});
여기서...
- ecmaVersion은 테스트 코드가 어떤 ECMAScript 문법을 사용할 수 있는지 알려주는 옵션입니다.
- sourceType: "module"은 이 코드가 ES Module 문법을 사용한다는 뜻입니다.
이번 rule은 import 문을 검사하므로 module로 설정해야 합니다. 테스트는 이런 식으로 작성했습니다.
const validExternalToInternal = [
'import React from "react";',
"",
'import { Button } from "@/shared/ui";',
].join("\n");
const invalidExternalToInternal = [
'import React from "react";',
'import { Button } from "@/shared/ui";',
].join("\n");
const validSameExternalGroup = [
'import React from "react";',
'import { clsx } from "clsx";',
].join("\n");
그리고 RuleTester에 valid와 invalid 케이스를 넘겨줍니다.
ruleTester.run("import-spacing", rule, {
valid: [
{
code: validExternalToInternal,
},
{
code: validSameExternalGroup,
},
],
invalid: [
{
code: invalidExternalToInternal,
errors: [{ messageId: "missingBlankLine" }],
},
],
});
의미는 간단합니다.
1. valid에 있는 코드는 에러가 나면 안 된다.
2. invalid에 있는 코드는 지정한 에러가 나야 한다.
예를 들어 같은 external 그룹끼리는 붙어 있어도 괜찮습니다.
import React from "react";
import { clsx } from "clsx";
하지만 external 다음 internal이 바로 붙어 있으면 에러가 나야 합니다.
import React from "react";
import { Button } from "@/shared/ui";
이렇게 테스트를 작성해두면, 나중에 rule을 수정하더라도 기존 동작이 깨졌는지 빠르게 확인할 수 있습니다. (TDD 좋아요)
(10) 전체 흐름 정리
이번 rule의 전체 동작 흐름을 정리해보겠습니다!
ESLint가 파일을 읽음 ->
코드를 AST로 변환함 ->
우리 rule의 create(context)를 호출함 ->
AST에서 Program 노드를 만나면 Program(node) 실행 ->
node.body에서 ImportDeclaration만 모음 ->
각 import의 source.value를 읽음 ->
source를 builtin/external/internal/relative 그룹으로 분류 ->
그룹 사이에 빈 줄이 있는지 검사 ->
그룹 순서가 올바른지 검사 ->
문제가 있으면 context.report()로 경고 표시
import를 정리하는 용도의 플러그인이니, 사실 이 글의 절반 되는 지점 쯤에 전부 끝날 줄 알았습니다. 그런데 실제론 AST에서 import 문을 찾고, source를 읽고, 그 source를 그룹으로 분류한 뒤, import 문끼리의 관계를 비교하는 작업도 포함되어 있었으니 많이 길어지더라고요😇 하지만 재밌었습니다.
이 과정을 거치면서 ESLint Rule에 대해 알게 된 것들도 정리해보자면...
1. meta에는 rule의 설명과 메시지를 적는다.
2. create(context)에서 검사 로직을 등록한다.
3. Program에서 파일 전체를 본다.
4. ImportDeclaration을 모아 import 정보를 만든다.
5. 문제가 있으면 context.report()를 호출한다.
6. RuleTester로 valid / invalid 케이스를 검증한다.
이번 글에서는 문제를 “찾는 과정”까지만 다뤘습니다. 아직 자동으로 고치지는 않았고, 아직 갈 길이 멉니다. 하지만 하나씩 해보다보면 금방 해나갈 수 있습니다. 다음 글에서는 이 autofix를 다뤄보려고 합니다. 그룹 사이에 빈 줄이 없다면 --fix로 자동 삽입하고, import 순서가 잘못되었을 때 안전하게 재정렬하는 방법을 정리해보겠습니다.
긴 글 읽어주셔서 감사합니다! 지금까지 electrohyun이었습니다. 😎
'공부 > Project' 카테고리의 다른 글
| [AI Skill] 내 작업 흐름에 맞는 에이전트 스킬 만들기 (1) | 2026.06.23 |
|---|---|
| eslint-plugin-layered-imports 제작기 - 5 | 배포와 회고 (0) | 2026.06.21 |
| eslint-plugin-layered-imports 제작기 - 4 | 옵션, 프리셋 구현하기 (0) | 2026.06.21 |
| eslint-plugin-layered-imports 제작기 - 3 | Autofix 구현하기 (0) | 2026.06.21 |
| eslint-plugin-layered-imports 제작기 - 1 | 구조를 지키는 ESLint 규칙 만들기 (0) | 2026.06.17 |