<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입니다. <플러그인 제작기> 이번 편은 “문제를 찾는 것”에서 “문제를 자동으로 고치는 것”으로 넘어가는 과정입니다. 2편에서는 ESLint Rule이 AST에서 ImportDeclaration을 찾고, import source를 builtin, external, internal, relative 그룹으로 분류한 뒤, 잘못된 순서나 빈 줄 누락을 context.report()으로 보고하는 과정을 다뤘습니다. 이번엔 그 다음 단계인 autofix를 다룹니다.
에러를 보여주고 나서, eslint --fix를 실행했을 때 코드가 원하는 형태로 자동 수정되도록 만든다면, 그거야말로 정말 편하고 멋진 도구 아닐까요? 제가 플러그인을 만들기 시작한 이유도, 이 비중이 80%는 되는 것 같아요. ^^
이번 글에서 다룰 내용은 크게 두 가지입니다.
그룹 사이에 빈 줄이 없을 때 자동으로 빈 줄을 삽입하기
import 그룹 순서가 잘못되었을 때 안전하게 재정렬하기
작업을 진행하고 나서 느낀 점은, 이번 autofix 과정이 이번 플러그인을 만드는 과정 중에서 가장 고려할 게 많았던 것 같습니다. 그중에서도 import 순서를 바꾸는 작업은 코드의 실행 순서와 연관된 문제였기 때문에 autofix의 대상에서 제외되어야 했습니다.
이번 글에서는 fixer API를 사용하는 방법, 그리고 “어떤 코드는 자동으로 고쳐도 되고, 어떤 코드는 고치면 안 되는가”에 대한 기준을 정리한 과정 또한 함께 정리하려고 합니다. 가보시죠!
(0) 들어가기
import React from "react";
import { Button } from "@/shared/ui";
2편에서는 잘못된 import를 찾는 과정까지 구현했습니다. 예를 들어 다음과 같은 코드가 있을 때, React는 external, Button은 internal입니다. 서로 다른 그룹이므로 두 import 사이에는 빈 줄이 있어야 합니다.
import React from "react";
import { Button } from "@/shared/ui";
2편에서는 이런 문제를 context.report()로 보고하는 데까지 구현했습니다.
context.report({
node: currentImport.node,
messageId: "missingBlankLine",
});
하지만 여기서 끝나면 사용자는 에러를 보고 직접 코드를 고쳐야 합니다. ESLint의 장점은 여기서 한 단계 더 나아갈 수 있다는 점입니다. rule이 fix를 제공하면, 사용자는 eslint --fix를 실행해서 일부 문제를 자동으로 수정할 수 있습니다. 이번 글에서는 이 autofix를 구현한 과정을 정리하도록 하겠습니다.
(1) ESLint의 autofix란 무엇인가?
ESLint rule은 문제를 발견하면 context.report()를 호출합니다. 그리고 context.report() 안에 fix 함수를 함께 넘기면, ESLint는 해당 문제를 자동으로 수정할 수 있습니다. 대략적인 형태는 다음과 같습니다.
context.report({
node,
messageId: "missingBlankLine",
fix(fixer) {
return fixer.insertTextBefore(node, "\n");
},
});
여기서 fixer란, ESLint가 제공하는 수정 도구입니다. 특정 위치 앞에 텍스트를 삽입하거나, 특정 범위의 텍스트를 교체하거나, 특정 노드 뒤에 텍스트를 추가하는 식으로 코드를 수정할 수 있습니다. 다만 rule이 fix를 제공하려면 meta.fixable을 선언해주어야 합니다.
meta: {
type: "layout",
fixable: "whitespace",
messages: {
missingBlankLine:
"Expected a blank line between different import groups.",
},
}
fixable: "whitespace"는 이 rule이 공백이나 줄바꿈과 관련된 수정을 제공한다는 뜻입니다.
이번 rule의 첫 번째 autofix는 서로 다른 import 그룹 사이에 빈 줄을 넣는 작업이므로, whitespace로 선언했습니다.
(2) 테스트에서 output을 먼저 작성하기 (with TDD)
autofix를 구현하기 이전, 먼저 테스트를 수정했습니다. 2편에서 작성한 invalid 테스트는 대략 이런 형태였습니다...
{
code: invalidExternalToInternal,
errors: [{ messageId: "missingBlankLine" }],
}
이 테스트는 잘못된 코드에서 missingBlankLine 에러가 발생하는지만 확인합니다. autofix를 검증하려면 여기에 output을 추가해야 합니다.
{
code: invalidExternalToInternal,
output: validExternalToInternal,
errors: [{ messageId: "missingBlankLine" }],
}
여기서 code는 입력 코드이고, output은 --fix가 적용된 뒤 기대하는 결과입니다.
예를 들어 입력 코드는 다음과 같습니다.
const invalidExternalToInternal = [
'import React from "react";',
'import { Button } from "@/shared/ui";',
].join("\n");
이 입력 코드에 대한 기대 output은 다음과 같습니다.
const validExternalToInternal = [
'import React from "react";',
"",
'import { Button } from "@/shared/ui";',
].join("\n");
아직 fixer를 구현하지 않은 상태에서 이 테스트를 추가할 것입니다. 결과는 실패해야 합니다. expected output에는 빈 줄이 있지만, actual output에는 빈 줄이 없기 때문입니다. 테스트가 “현재 rule은 문제를 찾을 수 있지만, 아직 자동으로 수정하지는 못한다”는 사실을 보여주는 것이 목적이기 때문입니다.
(3) 첫 번째 fixer: 빈 줄 삽입하기
가장 먼저 구현한 autofix는 그룹 사이에 빈 줄을 삽입하는 기능입니다. 서로 다른 그룹의 import가 바로 붙어 있다면, 두 번째 import 앞에 줄바꿈 하나를 추가해주면 됩니다.
context.report({
node: currentImport.node,
messageId: "missingBlankLine",
fix(fixer) {
return fixer.insertTextBefore(currentImport.node, "\n");
},
});
여기서 currentImport.node는 문제가 보고되는 두 번째 import 문입니다. 예를 들어 다음 코드에서는요...
import React from "react";
import { Button } from "@/shared/ui";
문제가 되는 위치는 import { Button } ...입니다. 그 import 바로 앞에 "\n"을 삽입하면 결과는 다음과 같습니다.
import React from "react";
import { Button } from "@/shared/ui";
이 단계에서는 import 순서를 바꾸지 않습니다.
오직 서로 다른 그룹 사이에 빈 줄이 없는 경우에만 줄바꿈을 삽입합니다.
(4) 두 번째 fixer: import 순서 재정렬하기
빈 줄 삽입은 그래도 비교적 간단하지만, import 순서 재정렬은 조금 더 큰 범위의 수정이 필요할 거라고 예상할 수 있었습니다. 잘못된 import 하나 앞에 텍스트를 삽입하는 정도가 아니라, import block 전체를 원하는 순서로 다시 구성해야 하기 때문입니다. 예를 들자면...
import helper from "./helper";
import React from "react";
import { Button } from "@/shared/ui";
기본 그룹 순서가 ["builtin", "external", "internal", "relative"] 순서라면, 원하는 결과는 다음과 같습니다.
import React from "react";
import { Button } from "@/shared/ui";
import helper from "./helper";
이 경우에는 import 목록을 group order 기준으로 정렬한 뒤, 기존 import 영역 전체를 새 문자열로 교체해야 합니다. 초기 구현의 흐름은 다음과 같았습니다.
fix(fixer) {
const sortedImports = [...importEntries].sort(
(a, b) => groups.indexOf(a.group) - groups.indexOf(b.group),
);
const fixedText = sortedImports
.map((entry) => sourceCode.getText(entry.node))
.join("\n\n");
return fixer.replaceTextRange(
[
importEntries[0].node.range![0],
importEntries[importEntries.length - 1].node.range![1],
],
fixedText,
);
}
여기서...
- replaceTextRange()는 특정 범위의 원본 텍스트를 통째로 교체합니다.
- node.range는 노드의 시작 위치와 끝 위치를 나타내는 두 칸짜리 좌표입니다.
range = [start, end]는, 첫 번째 import의 시작 위치부터 마지막 import의 끝 위치까지를 잡고, 그 범위를 정렬된 import 문자열로 교체한다는 의미입니다. 이 방식으로 가장 단순한 import reorder 테스트는 통과할 수 있지만, 이후 이 방식만으로는 부족하다는 점을 알 수 있었습니다.
(5) 자동으로 고치면 위험한 import가 있다
import "./setup";
모든 import가 이동했을 때 안전한 건 아닙니다. 대표적인 예가 side-effect import입니다.
(여기서 말하는 side-effect import란 import "./setup";처럼 특정 값을 가져오지 않고, 모듈을 실행하기만 하는 import를 의미합니다. 예를 들어 CSS 파일 import, polyfill 등록, 전역 설정 초기화 같은 경우가 여기에 해당합니다. 출처: MDN)
이 import는 어떤 값을 가져오기 위한 import가 아닌, 파일을 실행하기 위한 import입니다. 따라서 이 import의 위치를 바꾸면 프로그램이 의도한 실행 순서가 달라질 수 있겠죠. AST에서는 side-effect import를 다음과 같이 구분할 수 있습니다.
entry.node.specifiers.length === 0
일반적인 import는 가져오는 대상- 즉, specifiers가 있습니다.
import React from "react";
import { Button } from "@/shared/ui";
하지만 side-effect import는 specifier가 없습니다. import "./setup"; 처럼요.
이런 import가 포함된 block은 자동 재정렬을 제공하지 않는 편이 안전하다고 판단했습니다. 문제는 보고할 수 있지만, fix는 제공하지 않도록 작성하게 되었습니다.
(6) non-import statement를 넘어서 이동하면 안 된다
또 다른 문제는 import가 항상 파일 맨 위에 연속해서 모여 있지 않을 수 있다는 점입니다. 예를 들면 다음과 같은 코드가 있을 수 있습니다.
import helper from "./helper";
const value = 1;
import React from "react";
초기 구현처럼 node.body.filter()로 import만 골라서 정렬하면, 중간에 있는 const value = 1;을 무시하게 됩니다. 이 상태에서 import 영역 전체를 교체하면, import가 아닌 코드까지 영향을 받을 수 있습니다. 역시나 또 한 번 의도하지 않은 동작을 일으킬 수 있게 됩니다...
그렇기 때문에 autofix는 같은 연속 import block 안에서만 제공하도록 작성하였습니다. 그리하여, 다음 코드는 하나의 import block입니다.
import a from "a";
import b from "b";
하지만 다음 코드는 두 개의 import block입니다.
import a from "a";
const value = 1;
import b from "b";
이 경우 a와 b는 같은 block이 아니므로, 둘 사이의 순서를 자동으로 바꾸지 않도록 작성했습니다.
(7) import block 만들기
안전하게 autofix를 제공하기 위해 import block을 따로 만들었습니다. import block은 “연속된 import들의 묶음”입니다. 예를 들어 다음 코드는 block이 두 개입니다.
import a from "a";
import b from "b";
const value = 1;
import c from "c";
개념적으로는 다음과 같습니다!
[
[a, b],
[c],
]
구현은 Program의 node.body를 순서대로 보면서 진행할 수 있도록 작성했습니다.
const entryByNode = new Map(
importEntries.map((entry) => [entry.node, entry]),
);
const importBlocks = [];
let currentBlock = [];
for (const statement of node.body) {
if (statement.type === "ImportDeclaration") {
const entry = entryByNode.get(statement);
if (entry) {
currentBlock.push(entry);
}
continue;
}
if (currentBlock.length > 0) {
importBlocks.push(currentBlock);
currentBlock = [];
}
}
if (currentBlock.length > 0) {
importBlocks.push(currentBlock);
}
흐름을 설명하자면...
import 문을 만나면 현재 block에 추가한다.
import가 아닌 문장을 만나면 지금까지 모은 block을 저장하고 비운다.
파일이 import로 끝날 수도 있으므로, 반복문이 끝난 뒤 마지막 block을 한 번 더 저장한다.
이렇게 만들어둔 import block을 기준으로, 현재 import와 이전 import가 같은 block 안에 있을 때만 reorder fix를 제공하도록 했습니다.
(8) fix를 제공할 수 있는 경우와 없는 경우 구분하기
순서가 잘못되었다고 해서 항상 fix를 붙이지는 않습니다. 위에서 정리했던 대략적인 조건은 다음과 같습니다.
!currentImportBlock ||
currentImportBlock !== previousImportBlock ||
currentImportBlock.some((entry) => entry.node.specifiers.length === 0)
이 조건 중 하나라도 해당하면 fix를 제공하지 않습니다. 의미를 정리하자면 이렇습니다.
현재 import가 속한 block을 찾지 못한 경우
이전 import와 현재 import가 서로 다른 block에 있는 경우
현재 block 안에 side-effect import가 포함된 경우
이런 경우에는 context.report()로 문제는 알리되, fix 함수는 넘기지 않습니다. 자동 수정으로 코드의 실행 순서가 바뀔 가능성이 있다면, 수정하지 않는 쪽이 더 안전하다고 판단했습니다.
(9) 주석도 import와 함께 이동하기
다음 문제는 주석이었습니다. (문제가 끝도 없이 나오지만, 얼마 안 남았습니다!) import 텍스트를 가져올 때 다음 코드를 사용했습니다...
sourceCode.getText(entry.node)
이 코드는 import 문 자체의 텍스트만 가져옵니다. 따라서 다음과 같은 코드가 있을 때,
// React import
import React from "react";
sourceCode.getText(entry.node)는 아래 import 문만 가져옵니다.
import React from "react";
import를 이동하면 주석은 원래 위치에 남게 됩니다. 하지만 import 바로 위에 붙어 있는 주석은 대체로 해당 import에 대한 설명일 가능성이 높습니다. 그래서 import를 이동할 때, 바로 위에 붙어 있는 leading comment도 함께 이동하도록 처리했습니다.
function getImportTextStart(entry: ImportEntry): number {
const commentsBefore = sourceCode.getCommentsBefore(entry.node);
let start = entry.node.range![0];
let expectedEndLine = entry.node.loc!.start.line - 1;
for (let index = commentsBefore.length - 1; index >= 0; index -= 1) {
const comment = commentsBefore[index];
if (!comment.loc || !entry.node.loc) {
break;
}
if (comment.loc.end.line !== expectedEndLine) {
break;
}
start = comment.range![0];
expectedEndLine = comment.loc.start.line - 1;
}
return start;
}
이 과정에서 “import 텍스트를 어디서부터 잘라야 하는가” 또한 명확히 해야합니다. 이 함수는 import 바로 위에 연속해서 붙어 있는 주석을 확인합니다. 그리고 해당 주석까지 포함해서 import 텍스트의 시작 위치를 계산합니다. 실제 텍스트를 가져올 때는 이렇게 사용합니다.
function getImportText(entry: ImportEntry): string {
return sourceText.slice(
getImportTextStart(entry),
entry.node.range![1],
);
}
이제 import가 이동할 때, 그 import에 붙어 있던 주석도 함께 이동할 수 있습니다.
(10) 빈 줄은 group이 바뀔 때만 넣기
초기 구현에서는 정렬된 import를 다음과 같이 합쳤습니다.
const fixedText = sortedImports
.map((entry) => getImportText(entry))
.join("\n\n");
이렇게 하면 모든 import 사이에 빈 줄이 들어갑니다. 하지만 제가 원한 것은 모든 import 사이의 빈 줄이 아닌, 서로 다른 group 사이의 빈 줄입니다. 예를 들어 다음 두 import는 둘 다 relative group입니다.
import helperB from "./b";
import helperA from "./a";
같은 group 안에서는 빈 줄이 들어갈 필요가 없습니다. 그래서 fixed text를 만들 때, 이전 import와 현재 import의 group을 비교하도록 수정했습니다.
function buildImportBlockText(entries: ImportEntry[]): string {
return entries.reduce((text, entry, index) => {
const importText = getImportText(entry);
if (index === 0) {
return importText;
}
const previousEntry = entries[index - 1];
const separator =
previousEntry.group === entry.group ? "\n" : "\n\n";
return `${text}${separator}${importText}`;
}, "");
}
이제 같은 group 내부에서는 줄바꿈 하나만 사용하고, group이 바뀌는 지점에서만 빈 줄을 넣습니다.
(11) 테스트로 안전장치 고정하기
autofix에서는 구현만큼이나 테스트도 중요하다고 느꼈습니다. 자동 수정은 사용자의 코드를 직접 바꾸기 때문에, 의도하지 않은 변경이 생기면 신뢰 문제가 생길 수 있다고 생각했습니다. 그래서 다음과 같은 케이스를 테스트로 추가했습니다.
서로 다른 group 사이에 빈 줄이 없으면 빈 줄을 삽입한다.
group order가 잘못되어 있으면 같은 import block 안에서 순서를 재정렬한다.
side-effect import가 포함된 block은 report만 하고 fix는 제공하지 않는다.
non-import statement를 넘어 import를 이동하지 않는다.
leading comment는 해당 import와 함께 이동한다.
같은 group 내부 순서는 유지한다.
group이 바뀌는 지점에만 빈 줄을 삽입한다.
중요한 것은 “report 가능한 문제”와 “fix 가능한 문제”를 분리하는 것입니다! 모든 문제를 자동으로 고치는 것보단, 자동 수정이 안전하게 작동할 수 있는 경우에만 fix를 제공하는 방향으로 구현하도록 했습니다.
(12) 정리
이렇게, 이번 글에서는 eslint-plugin-layered-imports의 autofix 구현 과정을 정리했습니다. 서로 다른 import 그룹 사이에 빈 줄을 삽입하는 간단한 fixer부터 시작했습니다.
fix(fixer) {
return fixer.insertTextBefore(currentImport.node, "\n");
}
이후 import 그룹 순서가 잘못된 경우, import block 전체를 정렬된 문자열로 다시 구성하고 replaceTextRange()로 교체하는 방식으로 확장했습니다. 하지만 import 순서 재정렬은 고려할 게 더 있었죠. side-effect import는 이동하면 실행 순서가 달라질 수 있고, non-import statement를 넘어 import를 이동하면 코드 구조가 바뀔 수 있습니다. 또한 import 위에 붙어 있는 주석도 함께 이동해야 했습니다. 그래서 최종적으로는 다음 기준을 두었습니다.
빈 줄 삽입은 안전하게 자동 수정한다.
import reorder는 같은 연속 import block 안에서만 자동 수정한다.
side-effect import가 포함된 block은 자동 수정하지 않는다.
leading comment는 import와 함께 이동한다.
같은 group 내부 순서는 유지한다.
group이 바뀌는 지점에만 빈 줄을 둔다.
이번 구현 autofix를 구현하면서 느낀 점은... '판단하는 과정'에 시간이 많이 소요되었다는 점입니다. 구현하는 과정보다도요. 이것이 개발자 도구이기 때문만은 아니고, 개발을 할 때마다, 아직 잘 모르지만, 항상 드는 생각으로는... 기준을 명확히 해야하고, 말하고자 하는 것이 항상 뚜렷해야 한다는 생각이 드는 것 같습니다. 다음 글에서는 이 rule을 프로젝트마다 다르게 사용할 수 있도록 옵션과, 그리고 편한 사용을 돕는 프리셋을 추가한 과정을 정리해보겠습니다. 이 과정에서는 internalAliases, groups, 그리고 FSD 구조를 위한 preset과 같은 이야기를 다뤄보려고 합니다.
그러면, 긴 글 읽어주셔서 감사합니다! 지금까지 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 제작기 - 2 | ESLint Rule 작성하기 (0) | 2026.06.21 |
| eslint-plugin-layered-imports 제작기 - 1 | 구조를 지키는 ESLint 규칙 만들기 (0) | 2026.06.17 |