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을 알게 되었다.

적용해보기

Eslint plugin 개발을 생각하게 된 이유


현재 내가 참여하고 있는 프로젝트에서는 FSD 구조를 활용중이다. 기존의 Components 기반으로 된 구조보다 참조 관계가 명확하고, 또 컴포넌트가 의미 단위로 분리된다는 점에서 되게 매력있는 구조라고 생각을 했다.

하지만 적용을 하면서 보니 문제가 존재했다.

  1. FSD 구조에서 지켜져야 할 참조관계가 지켜지지 않을 경우, 코드 리뷰에서 파악해야 한다.
  • 특히나 처음 접하는 부원의 경우에는 이 구조에 대해 어려움을 겪을 가능성이 크다.
  • app -> pages -> widgets …. 해당 참조관계가 반드시 지켜져야 한다.
  1. 세그먼트에서 주관적인 요소가 개입될 수 있다.
  • 레이어나 슬라이스에서는 주관적인 요소가 거의 없는 것 같다. 레이어는 미리 정해진 레이어에서 작업이 되고, 슬라이스는 우리 프로젝트에서 자주 사용하는 도메인 명이기 때문에 이를 혼동하는 경우는 거의 없었다.
  • 하지만 가장 문제가 생기는 부분은 세그먼트 부분인데, 예를 들어서 유저의 전화번호 정보를 가공해주는 유틸이 있다고 치면 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 필드를 통해 해당 테스트 에러 포멧을 어떻게 보여줄 지를 정의하였다.