Eslint Plugin 개발 과정 정리
Eslint의 동작 방식
eslint에서 특정 규칙을 파악하는 방법
AST
Eslint는 AST를 만들어서 규칙을 정의하고 적용한다.
AST는 소스 코드를 읽어낸 후에 구문 자료를 정리해서 나타낸 트리 형태의 자료 구조이다.
- AST의 상세한 구조는 파서마다 다르다.
eslint에서 AST의 형태로 파싱하는 이유는 파일 내부의 특정 형식을 명확하게 찾기 위함이다.
- 간단한 예로 특정 파일에서 import 문을 찾고 싶은 경우, 소스 코드가 단순한 텍스트라면 문자열 내부에 등장하는 “import"와 실제 모듈을 가져오는 import 구문을 구분하기 어렵다.
- 따라서 ESLint는 소스 코드를 AST(Abstract Syntax Tree) 로 변환한 뒤, 각 구문을 의미 단위의 노드로 분석한다.
- 이를 통해 ImportDeclaration, CallExpression, Identifier와 같은 명확한 노드 타입을 기준으로 코드를 탐색할 수 있고, 문자열 비교가 아닌 문법적으로 올바른 코드 패턴만을 정확하게 대상으로 삼을 수 있다.
다음은 간단한 import 문을 Eslint를 활용해서 분석했을 때 나오는 AST이다.
<코드>
import React, { useState } from "react";
<변환된 AST>
{
"type": "ImportDeclaration",
"source": {
"type": "Literal",
"value": "react"
},
"specifiers": [
{
"type": "ImportDefaultSpecifier",
"local": { "type": "Identifier", "name": "React" }
},
{
"type": "ImportSpecifier",
"imported": { "type": "Identifier", "name": "useState" },
"local": { "type": "Identifier", "name": "useState" }
}
]
}
- 문법 규칙에 맞춰서 AST로 파싱되기 때문에, 결과 AST 구조를 보면서 eslint rule을 간편하게 짤 수 있다.
규칙을 직접 정의하기
Eslint는 기본적으로 Espree라는 파서를 통해서 소스 코드를 파싱하고, 이 결과를 각 플러그인에서 순회하며 규칙을 실행한다. 따라서, Express를 통해 생성된 AST를 읽고, 커스텀 Eslint 규칙을 만들 수 있다.
- 공식 문서를 보니 Espree말고 Esprima라는 파서로 갈아낄 수도 있는 것 같다.
Parser를 통해서 변환되는 AST가 구체적으로 어떤 형태인지 보는 방법
이를 통해 Eslint가 Espree Parser를 통해서 코드 -> AST로 파싱이 되고, Eslint 커스텀 규칙을 만드는 것 까지는 이해가 되었다. 하지만 구체적으로 어떻게 AST가 형성되는 것일까? 공식 문서를 찾아봐도 좀 헷갈리는 점들이 많았다.
다행히도 토스 글을 참고해보니 비슷하게 DX 향상을 위해 eslint rule을 커스터마이징하는 내용이 있었고, astexplorer.net을 알게 되었다.
- https://toss.tech/article/improving-code-quality-via-eslint-and-ast
- 참고한 아티클
- https://astexplorer.net/
- 코드 -> AST로 변환해주는 사이트
- 파서도 직접 선택할 수 있다.
적용해보기
Eslint plugin 개발을 생각하게 된 이유
현재 내가 참여하고 있는 프로젝트에서는 FSD 구조를 활용중이다. 기존의 Components 기반으로 된 구조보다 참조 관계가 명확하고, 또 컴포넌트가 의미 단위로 분리된다는 점에서 되게 매력있는 구조라고 생각을 했다.
- https://velog.io/@teo/separation-of-concerns-of-frontend
- fsd 구조 관련해서 정말 잘 정리되어 있는 글
하지만 적용을 하면서 보니 문제가 존재했다.
- FSD 구조에서 지켜져야 할 참조관계가 지켜지지 않을 경우, 코드 리뷰에서 파악해야 한다.
- 특히나 처음 접하는 부원의 경우에는 이 구조에 대해 어려움을 겪을 가능성이 크다.
- app -> pages -> widgets …. 해당 참조관계가 반드시 지켜져야 한다.
- 세그먼트에서 주관적인 요소가 개입될 수 있다.
- 레이어나 슬라이스에서는 주관적인 요소가 거의 없는 것 같다. 레이어는 미리 정해진 레이어에서 작업이 되고, 슬라이스는 우리 프로젝트에서 자주 사용하는 도메인 명이기 때문에 이를 혼동하는 경우는 거의 없었다.
- 하지만 가장 문제가 생기는 부분은 세그먼트 부분인데, 예를 들어서 유저의 전화번호 정보를 가공해주는 유틸이 있다고 치면 entities/user/lib에서 utils를 하위로 파서 할 것을 예상을 했고, 그렇게 컨벤션을 정했지만 코드를 작성하는 입장에서 entities/user/utils 이렇게 파게 될 수도 있다. (아직 구조가 익숙하지 않다면).
- 이런 부분들이 코드 리뷰에서 쌓이게 되면, 리뷰하는 입장에서도 받는 입장에서도 난처할 수 있다.
- 참고로 현재 세그먼트에서 합의된 폴더명은 api(필요에 따라), ui, model, lib, config 이다.
문제 해결 방안
- 코드 리뷰하면서 해당 부분들을 파악하고 걸러낼 수도 있다.
- 하지만 저런 구조와 관련된 부분들은 당연하게 지켜져야 하는 요소이고, 규칙이 명확하기 때문에 eslint rule로 pre-commit 단계에서 잡아주는게 맞지 않나 라는 생각이 들었다.
- 따라서, 내가 현재 겪고 있는 문제를 명확하게 해결해줄 eslint rules이 필요하다고 생각했다.
FSD-Eslint-Plugin Rules
1. no-cross-layer-import
- 첫 번째로 만들 규칙은 no-cross-layer-import이다.
- FSD에서는 app -> pages -> widgets -> features -> entities -> shared로 일관된 위계관계를 갖는다.
- 해당 규칙에서는 pages -> app, entities -> widgets 등의 올바르지 않는 위계 관계가 나타날 경우에 error를 명시해주고자 한다.
설계 구조
- A파일 -> B파일을 참조할 경우에 A파일이 속한 레이어와 B파일이 속한 레이어를 확인해주는 과정이 필요하다.
- A 파일의 레이어 정보 확인하기
- create(context)에서 context.filename을 통해 불러올 수 있다.
- B 파일의 레이어 정보 확인하기
- A 파일에서 import 문을 분석해서 from 뒤에 나오는 부분에서 B파일에 대한 레이어 정보를 얻을 수 있다.
< 현재 해당하는 파일 이름 가져오기 >

- eslint 공식문서를 확인해본 결과 현재 파일명은 context.filename으로 접근해서 불러올 수 있다.
< static import 문은 다음과 같이 Espree AST에서 파싱이 된다 >

- 따라서 ImportDeclaration -> source -> value에 접근해서 참조하고자 하는 레이어를 파악할 수 있다.
< dynamic import 문은 다음과 같이 Espree AST에서 파싱이 된다 >

- dynamic import 문이 조금 depth를 타고 들어가야 한다.
- 가장 겉에서는 variableDeclaration으로 되어있따. ( LazyComponent 변수를 선언하는 문이기 때문에 )
- 타고 타고 들어가다보면 ImportExpression이 나오게 되는데
- 동일하게 ImportExpression -> source -> value로 접근해서 참조하고자 하는 레이어를 파악할 수 있다.
< 구현한 코드 >
module.exports = {
meta: {
// 메타 데이터 입력
},
create(context) {
// 실행문
// 현재 파일이름 가져오기
const filename = context.getFilename();
const currentLayer = extractLayer(filename);
// 추가로 현재 파일이 속한 레이어가 FSD 레이어가 아닐 경우 early return하는 로직 추가
return {
ImportDeclaration(node) {
// 타깃 파일 이름 가져오기
const importPath = node.source.value;
// 같은 레이어간 참조일 경우 skip
// 레이어 순서 체크에서 벗어날 경우 report
// 여기서 data로 현재 레이어와 imported 레이어값을 넘겨주는데
// 사용자가 어느 부분에서 에러가 났는지 명확하게 파악할 수 있게 하기 위함이다.
if (!isImportAllowed(currentLayer, importedLayer)) {
context.report({
node: node.source,
messageId: 'crossLayerImport',
data: {
fromLayer: currentLayer,
toLayer: importedLayer,
},
});
}
},
다음은 pseudo코드처럼 풀어서 설명했던 내용들을 코드로 구현한 내용이다.
코드 예제는 static import만 있다.
- require 방식으로 불러오는 것과, dynamic import 부분도 이후에 구현이 되어있지만, 해당 내용들은 importedLayer에 접근하는 방식만 다를 뿐, 코어 로직은 같기 때문에 명시하지 않았다.
추가로 context.report 메서드를 통해서 에러를 보고하게 되는데, currentLayer 값과 toLayer 값을 같이 넘김으로써 사용자가 어떤 레이어에서 에러가 발생했는지 더 파악하기 명확하게 하고자 하였다.
2. enforce-segment-naming
- 두 번째 규칙은 세그먼트 네이밍 규칙이다.
- 세그먼트에서의 네이밍을 api, ui, lib, model, config로 한정하는 것이다.
- 해당 세그먼트 이외의 추가될 수 있는 세그먼트도 있지 않을까? 라는 생각도 했었다.
- 그런데 논의해본 결과
- 해당 api, ui, lib, model, config 내에서 다 처리될 수 있을 것 같다는 의견이 나왔다.
- 만약 처리되지 않는다면 빠르게 세그먼트 정의를 추가할 계획이다.
- 무엇보다도 명확한 컨벤션이 중요하다는 의견이 주를 이뤄서 이렇게 구성하게 되었다.
<구현한 코드>
module.exports = {
meta: {
// 메타 데이터 입력
},
create(context) {
// 실행문
// 현재 파일이름 가져오기
const filename = context.getFilename();
const { layer, slice, segment } = extractSegmentFromFilePath(filename);
// 추가로 현재 파일이 속한 레이어가 FSD 레이어가 아닐 경우 early return하는 로직 추가
if (!allowedSegments.includes(segment)) {
return {
Program(node) {
context.report({
node,
messageId: 'invalidSegment',
data: {
segment,
layer,
slice,
allowedSegments: allowedSegments.join(', '),
},
});
}
}
}
return {};
},
- 파일 전체를 대표하는 최상위 노드 Program 노드에서 에러를 report 하게 된다.
- no-cross-layer-import쪽에서 했던 것중에 현재 파일 이름 불러오는 것만 하면 되서 어려운 작업은 아니었다.
테스트코드 작성
테스트 코드를 작성하는 이유
- 코드만으로는 로직을 파악하기 어렵다.
- npm으로 배포할 예정이기 때문에 PR을 올리는 과정에서 자동으로 테스트가 돌아가고, publish될 수 있는 상태인지 체크가 필요하다고 생각했다.
ruleTester
- eslint에서 RuleTester 객체를 제공한다고 한다.
- 이걸 활용해서 테스트하기가 굉장히 수월했다.
< ruleTester load & config >
// elint RuleTester 불러오기
const { RuleTester } = require('eslint');
// 테스트할 eslint rule 불러오기
const rule = require('../../../lib/rules/enforce-segment-naming');
const ruleTester = new RuleTester({
parserOptions: {
ecmaVersion: 2020,
sourceType: 'module'
,}
});
< valid testCode 작성 >
ruleTester.run('enforce-segment-naming', rule, {
valid: [
// ✅ 허용된 세그먼트: model
{
code: 'export const userModel = {};',
filename: '/project/src/entities/user/model/store.ts',
name: '[entities/user/model/] Valid segment - model',
},
// ✅ 허용된 세그먼트: ui
{
code: 'export const UserCard = () => {};',
filename: '/project/src/entities/user/ui/UserCard.tsx',
name: '[entities/user/ui/] Valid segment - ui',
},
]
},
- segment naming에 대한 테스트 코드이다.
- 여기서 code는 중요하지 않고, filename만 중요하다.
- code쪽이 필수 입력 필드라 그냥 간단하게 넣었다.
< invalid testCode 작성 >
ruleTester.run('enforce-segment-naming', rule, {
invalid: [
// ❌ 잘못된 세그먼트: utils
{
code: 'export const userModel = {};',
filename: '/project/src/entities/user/utils/helper.ts',
name: '[entities/user/utils/] Invalid segment - utils',
errors: [
{
messageId: 'invalidSegment',
data: {
segment: 'utils',
layer: 'entities',
slice: 'user',
allowedSegments: 'model, ui, api, lib, config',
},
},
],
}
},
- invalid segment에 대한 코드는 다음과 같이 작성하였다.
- code, filename, name 필드는 동일하다.
- errors 필드를 통해 해당 테스트 에러 포멧을 어떻게 보여줄 지를 정의하였다.