<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입니다. 벌써 이번 플러그인 제작기의 80%나 도달했네요. 이번 편의 핵심은 rule을 “내 프로젝트에서만 돌아가는 코드”에서 “다른 프로젝트에서도 설정해서 사용할 수 있는 rule”로 확장하는 과정입니다. 약간은 욕심이기도 하지만, 조금의 유연함을 추가하는 것이기도 하고, 또 이왕 만든 김에 추가해보면 어떨까 하는 생각이 들어 작업을 시작하게 되었네요.
1편에서는 import 정리 규칙의 필요성을 정리했습니다.
2편에서는 ESLint Rule이 AST에서 ImportDeclaration을 읽고, import source를 그룹으로 분류하는 과정을 다뤘습니다.
3편에서는 autofix를 구현하면서 빈 줄을 삽입하고, import 순서를 안전하게 재정렬하는 과정을 정리했습니다.
이제, 4편에서는 이 rule을 프로젝트마다 다르게 사용할 수 있도록 옵션을 추가하고, FSD 구조에 맞는 preset을 제공한 과정을 다룹니다. 이번 글에서 다룰 내용은 크게 네 가지입니다.
- internalAliases 옵션 추가하기
- groups 옵션으로 import group 순서 바꾸기
- internalLayerOrder 옵션으로 internal import 안의 layer 순서 다루기
- configs.fsd preset으로 설정을 묶어서 제공하기
(0) 들어가기
저번에는 import를 네 가지 그룹으로 나누었습니다. -> ["builtin", "external", "internal", "relative"].
import { Button } from "@/shared/ui";
그 중, 내부 import- 즉 internal은 "@/"로 시작하는 경로로 간주하여 플러그인을 작성해왔습니다. 제가 진행하던 '꼬깃' 프로젝트에서는 @/ alias를 사용하고 있었기 때문에 이 기준으로 충분했는데요, 하지만 조금 더 욕심을 내어 유연하게 제작하고자 한다면, 어떤 프로젝트는 @/를 사용하지만, 다른 프로젝트는 ~/를 사용한다는 점을 고려해 옵션을 제공할 수 있을 거란 생각이 들었습니다.
import { Button } from "~/shared/ui";
import { Button } from "@app/shared/ui";
이렇게 말이죠! 다양한 alias가 존재할 수 있습니다.
import { useQuery } from "@tanstack/react-query";
이외에도 고려할 점은요, @로 시작한다고 해서 모두 internal import인 것이 아니란 겁니다. (@tanstack/react-query는 scoped package이지만, 프로젝트 내부 alias는 아닙니다.)
그러니까 단순히 @로 시작하는지를 기준으로 internal을 판단하면 안 됩니다. 이 시점이 바로 rule에 옵션이 필요해지는 시점입니다.
(1) 옵션이 왜 필요한가?
처음 구현은 대략 이런 형태였습니다.
function getImportGroup(source: string): ImportGroup {
if (source.startsWith("@/")) {
return "internal";
}
// ...
}
제 프로젝트에서는 동작하는 방식입니다. 하지만 사용자의 프로젝트 alias가 ~/라면? internal import를 external로 잘못 판단하게 됩니다. 심각한 문제라고 봐도 과언이 아니죠.
import { Button } from "~/shared/ui";
이 코드는 프로젝트 내부 코드, 즉 internal일 수 있지만, rule 입장에서는 ~/가 internal alias라는 사실을 알 방법이 없습니다. 그러니 사용자가 직접 internal alias prefix를 넘길 수 있도록 한다면...? 좋은 아이디어가 될 것 같다는 생각이 들었습니다.
{
internalAliases: ["@/", "~/", "@app/"]
}
이제 rule은 고정된 @/ 하나만 보는 것이 아니라, 사용자가 넘긴 alias 목록을 기준으로 internal import를 판단합니다.
function getImportGroup(
source: string,
internalAliases: string[],
): ImportGroup {
if (internalAliases.some((alias) => source.startsWith(alias))) {
return "internal";
}
// ...
}
이렇게 하면 다음 import들은 internal로 판단됩니다.
import { Button } from "@/shared/ui";
import { Header } from "~/widgets/header";
import { appConfig } from "@app/config";
하지만 아까 말씀드렸던 이러한 import는 여전히 external로 남습니다.
import { useQuery } from "@tanstack/react-query";
중요한 점은 “@로 시작하면 internal”이 아니라, “사용자가 internal alias라고 선언한 prefix로 시작하면 internal”이라는 점입니다😊
(2) schema는 왜 필요한가
옵션을 받기로 했으니, rule은 사용자가 넘긴 옵션의 형태를 검증해야 합니다. 처음 meta.schema는 빈 배열이었습니다.
schema: [],
이건 이 rule이 옵션을 받지 않는다는 뜻입니다. 그런데 이제 사용자는 다음과 같이 옵션을 넣을 수 있어야 합니다.
"layered-imports/import-spacing": [
"error",
{
internalAliases: ["@/", "~/"]
}
]
그러려면 schema도 옵션 객체를 허용하도록 바꿔야 합니다. 여기서 schema는 rule 옵션의 런타임 검증표라고 보시면 될 것 같습니다.
schema: [
{
type: "object",
properties: {
internalAliases: {
type: "array",
items: {
type: "string",
},
},
},
additionalProperties: false,
},
],
TypeScript의 타입이 플러그인 개발 중에는 저에게 도움을 주지만, 이 플러그인의 사용자가 작성하는 ESLint의 config는 JavaScript일 수 있습니다. 그러므로 사용자는 다음과 같은 설정을 넣을 수도 있습니다...
{
internalAliases: "~/"
}
// 혹은...
{
internalAliases: [123]
}
그렇지만, 제가 기대한 형태는 문자열 배열입니다.
{
internalAliases: ["@/", "~/"]
}
TypeScript는 사용자의 config 파일에 들어간 값을 항상 막아주지 못합니다. 그래서 ESLint rule을 작성할 때는 schema를 통해 옵션의 형태를 직접 선언해야 합니다. 정리하면 이렇습니다.
- schema: 사용자가 넣을 수 있는 옵션의 모양을 정의한다.
- context.options: 실제 사용자가 넣은 옵션 값을 읽는다.
(3) context.options에서 옵션 읽기
schema로 옵션의 형태를 허용했다면, 이제 rule 내부에서 실제 옵션 값을 읽어야 합니다. ESLint rule에서 사용자가 넘긴 옵션은 context.options에 들어옵니다.
const options = context.options[0];
그리고 옵션이 없을 경우를 대비해 기본값을 둡니다.
const DEFAULT_INTERNAL_ALIASES = ["@/"];
const internalAliases =
options?.internalAliases ?? DEFAULT_INTERNAL_ALIASES;
이렇게 하면 사용자가 옵션을 넣지 않아도 rule은 기본적으로 @/를 internal alias로 판단합니다.
"layered-imports/import-spacing": [
"error",
{
internalAliases: ["~/"]
}
]
반대로 사용자가 값을 넣으면, 그 값을 internal alias로 판단합니다. 이 경우에는 ~/로 시작하는 import를 internal로 판단합니다.
이 부분을 작성하며 든 생각은, "역시 기본 동작도 제시하는 것이 좋겠다."라는 생각이었습니다. 기존 사용자가 옵션을 아무것도 넣지 않으면, rule은 사용자에게 미리 정해진 기본 옵션을 제공함으로서 편의성을 제공해주는 방향이 좋다고 판단, 이렇게 코드를 작성할 수 있었습니다.
(4) groups 옵션으로 import group 순서 바꾸기
다음으로 추가한 옵션은 groups입니다. 처음 기본 순서는 다음과 같았습니다...
const DEFAULT_GROUPS = [
"builtin",
"external",
"internal",
"relative",
];
하지만 팀, 그리고 프로젝트마다 원하는 import 순서가 다를 수 있습니다. 어떤 팀은 builtin과 external을 같은 외부 의존성처럼 보고 싶을 수도 있고, 어떤 팀은 relative를 internal보다 먼저 두고 싶을 수도 있습니다. 그래서 group 순서도 옵션으로 받을 수 있게 했습니다.
{
groups: ["builtin", "external", "internal", "relative"]
}
rule 내부에서는 다음과 같이 읽습니다.
const groups = options?.groups ?? DEFAULT_GROUPS;
이후 group order 검사는 이 groups 배열을 기준으로 동작합니다.
const currentGroupIndex = groups.indexOf(currentImport.group);
즉, rule이 고정된 순서를 강제하는 것은 아니며, 사용자가 넘긴 순서를 기준으로 import 흐름을 검사합니다. 여기도 마찬가지로 기준이 있어야 하니, schema를 확장해주는 것이 좋겠습니다.
groups: {
type: "array",
items: {
enum: ["builtin", "external", "internal", "relative"],
},
uniqueItems: true,
},
groups 옵션에는 정해진 네 가지 group만 들어갈 수 있어야 합니다. builtin, external, internal, relative 이외의 값이 들어오면 rule이 import 순서를 판단할 수 없기 때문입니다. 또한 같은 group이 중복되어도 문제가 생깁니다. 예를 들어, external이 두 번 들어간다면 어떤 external을 기준으로 순서를 판단해야 하는지 애매해집니다. 그래서 uniqueItems: true를 사용해 같은 group이 중복되지 않도록 막았습니다.
옵션을 열어두면 rule은 더 유연해지지만, 동시에 사용자가 잘못된 값을 넣을 가능성도 생깁니다. 이때 schema는 rule 옵션의 문지기 역할을 합니다. 허용된 group만 받을 수 있게 하고, 중복된 group이나 알 수 없는 값을 미리 막아줌으로써 rule이 더 예측 가능한 상태에서 동작하도록 도와줍니다.
(5) internalLayerOrder 옵션
다음 고민은 internal import 안에서의 순서였습니다. 기존 rule은 import를 큰 그룹으로 나눕니다. builtin -> external -> internal -> relative.
그런데 FSD 구조를 사용하면 internal 안에서도 layer 순서가 중요해집니다. 예를 들어 다음 코드를 보겠습니다.
import { Button } from "@/shared/ui/button";
import { UserCard } from "@/entities/user";
import { LoginForm } from "@/features/auth";
이 import들은 모두 internal입니다. 기존 rule 기준으로는 같은 group이므로, 여기서 더 이상 순서를 판단하지 않아도 됩니다. 하지만 FSD 관점에서는 다음과 같은 순서를 기대할 수 있습니다.
import { LoginForm } from "@/features/auth";
import { UserCard } from "@/entities/user";
import { Button } from "@/shared/ui/button";
그래서 internal import 안에서도 layer 순서를 지정할 수 있는 옵션을 추가했습니다. 이러한 옵션의 이름을 fsdLayerOrder로 할까 고민했는데요, 굳이 FSD가 아니더라도 프로젝트 내부 alias 안에서 자신만의 layer 구조를 사용할 수 있으므로, 최종 이름은 internalLayerOrder로 작성했습니다.
{
internalLayerOrder: [
"app",
"pages",
"widgets",
"features",
"entities",
"shared",
]
}
이 옵션은 optional로 두었습니다. 옵션이 없으면 internal group 내부 순서는 건드리지 않습니다. 옵션이 있을 때만 internal import 안에서 layer 순서를 검사하고 정렬합니다.
(6) internal layer는 어떻게 뽑는가
internalLayerOrder를 적용하려면 먼저 import source에서 layer 이름을 뽑아야 합니다. 예를 들어 다음 source가 있습니다.
"@/features/auth"
"@/entities/user"
"@/shared/ui/button"
여기서 alias인 @/를 제거하면 다음과 같이 남습니다.
"features/auth"
"entities/user"
"shared/ui/button"
그리고 첫 번째 segment를 layer로 봅니다.
features
entities
shared
구현은 다음과 같은 함수로 처리했습니다.
function getInternalLayer(
source: string,
internalAliases: string[],
): string | undefined {
const alias = internalAliases.find((internalAlias) =>
source.startsWith(internalAlias),
);
if (!alias) {
return undefined;
}
return source.slice(alias.length).split("/")[0];
}
예를 들어 @/shared/ui/button은 다음 과정을 거칩니다.
source = "@/shared/ui/button" ->
alias = "@/" ->
source.slice(alias.length) = "shared/ui/button" ->
"shared/ui/button".split("/")[0] = "shared" 까지.
이렇게 작성하여, internal import의 layer를 구할 수 있었습니다.
(7) unknown internal path는 어떻게 처리할까
internalLayerOrder를 추가하면서 애매한 상황도 생겼습니다. 예를 들어 다음 import를 보겠습니다.
import config from "@/lib/config";
lib는 internal alias 아래에 있으므로 internal import입니다. 하지만 FSD layer 목록에는 없을 수 있습니다.
[
"app",
"pages",
"widgets",
"features",
"entities",
"shared",
]
그렇다면 lib는 어디에 둬야 할까요? 최종 정책은 다음과 같이 정했습니다.
- known layer는 internalLayerOrder 순서대로 둔다.
- unknown internal은 known layer 뒤에 둔다.
- unknown끼리는 기존 순서를 유지한다.
예를 들어 다음 코드가 있다고 해보겠습니다.
import config from "@/lib/config";
import { api } from "@/shared/api";
import { LoginForm } from "@/features/auth";
import logger from "@/lib/logger";
fix 후에는 다음과 같은 형태를 기대합니다.
import { LoginForm } from "@/features/auth";
import { api } from "@/shared/api";
import config from "@/lib/config";
import logger from "@/lib/logger";
features, shared는 known layer이므로 지정한 순서대로 정렬됩니다. 반면 lib는 unknown internal이므로 known layer 뒤에 남습니다. 그리고 config, logger는 둘 다 unknown이기 때문에 서로의 기존 순서를 유지합니다. 이 정책을 둔 이유는 unknown path까지 임의로 정렬하면 사용자의 의도와 다르게 움직일 수 있기 때문입니다. rule이 확실히 알고 있는 layer만 적극적으로 정렬하고, 모르는 구조는 최대한 건드리지 않는 쪽을 선택했습니다.
(8) 같은 layer 안에서는 source path 기준으로 정렬하기
같은 known layer 안에서도 순서가 섞일 수 있습니다. 예를 들어 다음 코드는 둘 다 shared layer입니다.
import { Input } from "@/shared/ui/input";
import { Button } from "@/shared/ui/button";
이 경우에는 source path 기준으로 정렬했습니다.
import { Button } from "@/shared/ui/button";
import { Input } from "@/shared/ui/input";
여기서 named import 이름을 기준으로 정렬하지는 않았습니다.
import { Button } from "@/shared/ui/button";
이 코드에서 Button을 기준으로 볼 수도 있지만, rule의 관심사는 import source입니다.
@/shared/ui/button이라는 source path를 기준으로 정렬하는 편이 이번 rule의 책임 범위와 더 맞다고 판단하여 작성하게 되었습니다.
(9) compareImportEntries로 기준 모으기
옵션이 늘어나면서, 정렬 기준도 하나의 함수로 모으는 편이 자연스러워졌습니다. 큰 흐름은 다음과 같습니다.
function compareImportEntries(
firstEntry: ImportEntry,
secondEntry: ImportEntry,
): number {
const firstGroupIndex = groups.indexOf(firstEntry.group);
const secondGroupIndex = groups.indexOf(secondEntry.group);
if (firstGroupIndex !== secondGroupIndex) {
return firstGroupIndex - secondGroupIndex;
}
if (
firstEntry.group !== "internal" ||
secondEntry.group !== "internal" ||
!internalLayerOrder
) {
return 0;
}
// internalLayerOrder 비교
}
먼저 큰 group을 비교합니다. external과 internal처럼 group이 다르면 groups 옵션에 지정된 순서를 따릅니다.
group이 같고, 둘 다 internal이고, internalLayerOrder 옵션이 있을 때만 layer 비교를 진행합니다.
!internalLayerOrder
이 조건이 중요합니다!! internalLayerOrder가 없으면 기존 동작을 바꾸면 안 됩니다. 옵션이 없는 경우에는 internal group 내부 순서를 그대로 유지하도록 했습니다. 정렬 기준을 확장하더라도, 사용자가 명시적으로 옵션을 켜지 않았다면 기존 코드에 영향을 주지 않는 것을 중요시 생각했네요.
(10) FSD preset을 만들기
이제 옵션은 어느 정도 준비되었습니다. 하지만 사용자가 매번 이런 설정을 직접 적어야 한다면 조금 번거로울 수 있겠죠.
"layered-imports/import-spacing": [
"error",
{
internalAliases: ["@/"],
groups: ["builtin", "external", "internal", "relative"],
internalLayerOrder: [
"app",
"pages",
"widgets",
"features",
"entities",
"shared",
],
},
]
그래서 FSD 구조에 맞는 preset을 제공하기로 했습니다.
목표는 사용자가 다음처럼 가져다 쓸 수 있게 하는 것이었습니다.
import layeredImports from "eslint-plugin-layered-imports";
export default [
layeredImports.configs.fsd,
];
이를 위해 plugin export 객체에 configs.fsd를 추가했습니다.
대략적인 형태는 다음과 같습니다.
plugin.configs.fsd = {
plugins: {
"layered-imports": plugin,
},
rules: {
"layered-imports/import-spacing": [
"error",
{
internalAliases: ["@/"],
groups: ["builtin", "external", "internal", "relative"],
internalLayerOrder: [
"app",
"pages",
"widgets",
"features",
"entities",
"shared",
],
},
],
},
};
여기서 한 가지 주의할 점이 있었습니다. configs.fsd 안에서 plugin 자기 자신을 참조해야 한다는 점입니다.
plugins: {
"layered-imports": plugin,
}
그런데 const plugin = { ... } 객체를 만드는 중에는 아직 plugin 변수가 초기화되지 않았습니다. IDE에서도 위와 같은 코드를 작성하면 노란 줄을 띄워줍니다.
그렇기 때문에, 우선 plugin 객체를 만든 뒤 나중에 plugin.configs.fsd를 붙이는 방식으로 처리하도록 작성했습니다.
const plugin: LayeredImportsPlugin = {
meta: {
name: "eslint-plugin-layered-imports",
version: "0.1.0",
},
rules: {
"import-spacing": importSpacingRule,
},
configs: {
fsd: {} as Linter.Config,
},
};
plugin.configs.fsd = {
plugins: {
"layered-imports": plugin,
},
rules: {
"layered-imports/import-spacing": [
"error",
{
internalAliases: ["@/"],
groups: ["builtin", "external", "internal", "relative"],
internalLayerOrder: [
"app",
"pages",
"widgets",
"features",
"entities",
"shared",
],
},
],
},
};
(11) preset과 기본값은 다르다
이 과정에서 기본값과 preset의 차이도 정리할 수 있었습니다. 기본값은 사용자가 rule을 직접 켰을 때, 옵션을 생략하면 적용되는 값입니다.
"layered-imports/import-spacing": "error"
이 경우 rule 내부에서는 다음 기본값을 사용합니다.
internalAliases: ["@/"]
groups: ["builtin", "external", "internal", "relative"]
반면 preset은 rule 설정 자체를 묶어서 제공하는 것입니다.
export default [
layeredImports.configs.fsd,
];
즉, 기본값은 rule 내부의 fallback입니다. preset은 사용자가 ESLint config에서 가져다 쓰는 설정 묶음으로, 제가 만든 FSD preset은 기본값을 대신하는 것이 아니라, FSD 구조를 사용하는 프로젝트에서 필요한 설정을 한 번에 켤 수 있게 해주는 역할로 장치합니다.
(12) 테스트로 옵션과 preset 검증하기
옵션이 늘어났습니다. 테스트도 함께 늘어나야죠! 😇 먼저 internalAliases 옵션 테스트입니다.
{
code: invalidCustomAliasExternalToInternal,
options: [{ internalAliases: ["~/"] }],
output: validCustomAliasExternalToInternal,
errors: [{ messageId: "missingBlankLine" }],
}
이 테스트는 ~/ alias를 internal로 판단하는지 확인합니다.
{
internalAliases: "~/"
}
다음은 잘못된 옵션 형태를 검증하는 테스트입니다. 위 예시에서, internalAliases는 문자열 배열이어야 하므로, 문자열 하나만 넘기면 schema에서 막혀야 합니다. 이런 경우는 일반적인 lint error와 성격이 다릅니다. 코드가 잘못된 것이 아니라 rule 설정이 잘못된 것이기 때문입니다.
그래서 RuleTester의 invalid 케이스로 보기보다는, Linter를 직접 사용해서 schema validation이 실패하는지 확인했습니다.
preset도 테스트했습니다. vitest를 사용했습니다!
import { describe, expect, it } from "vitest";
import layeredImports from "./index";
describe("plugin configs", () => {
it("exports an fsd config", () => {
expect(layeredImports.configs.fsd).toBeDefined();
});
});
이 테스트는 plugin이 configs.fsd를 정상적으로 export하는지 확인합니다. rule 내부 동작은 물론, 패키지를 사용하는 입장에서 필요한 API가 제대로 노출되는지도 테스트할 수 있었습니다.
(13) 정리
이번 글에서는 eslint-plugin-layered-imports에 옵션과 프리셋을 추가할 수 있었습니다. 처음 플러그인을 만든 이유도 제 프로젝트에 적용하기 위함이었습니다. rule은 제 프로젝트 구조를 기준으로 만들어졌기에, internal import는 @/로 시작한다고 판단했고, import group 순서도 builtin, external, internal, relative로 고정되어 있었습니다. 그러나 만들다보니 욕심이 조금 생겼고, 유연하게 만들어보고 싶다는 마음에 프로젝트마다 다른 alias와 구조를 고려할 수 있었습니다.
그리하여, 이 옵션들을 추가했습니다.
- internalAliases
- groups
- internalLayerOrder
추가적으로, FSD 구조를 사용하는 프로젝트를 위해 configs.fsd preset을 작성했습니다.
preset은 사용자가 매번 긴 rule 옵션을 직접 적지 않아도 되도록, plugin과 rule 설정을 묶어서 제공하는 방식이었습니다. 이번 작업을 하면서 사용자가 어떤 프로젝트에서 이 rule을 사용할지, 어떤 옵션을 잘못 넣을 수 있을지, 기본값과 preset의 경계를 어떻게 나눌지까지 함께 고민해야 했습니다. 저번 글에서도 그랬듯, 이번에도 역시 판단의 연속이었네요.
다음 글에서는 마지막으로 npm 배포, 그리고 간단한 회고를 다루려고 합니다. 패키지를 빌드하고, npm에 배포하고, README와 문서를 정리하면서 느낀 점을 정리해보겠습니다.
그럼 긴 글 읽어주셔서 감사합니다! 지금까지 electrohyun이었습니다. ⚡️
'공부 > Project' 카테고리의 다른 글
| [AI Skill] 내 작업 흐름에 맞는 에이전트 스킬 만들기 (1) | 2026.06.23 |
|---|---|
| eslint-plugin-layered-imports 제작기 - 5 | 배포와 회고 (0) | 2026.06.21 |
| eslint-plugin-layered-imports 제작기 - 3 | Autofix 구현하기 (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 |