ESLINT

ESLint로 런타임 에러 예방하기: 커스텀 룰 개발부터 테스트까지

박상우 프로필 이미지

박상우

2025.07.07

16 min read

ESLint로 런타임 에러 예방하기: 커스텀 룰 개발부터 테스트까지

안녕하세요, 코멘토 프론트엔드 인턴 개발자 박상우입니다. 오늘은 실제 업무에서 발생한 문제를 해결하기 위해 ESLint 커스텀 룰을 개발하고 개선해 나간 과정을 공유해보려고 합니다.

Next.js 환경에서 개발 중인 디자인 시스템의 컴포넌트를 서비스 레포지토리에서 사용할 때 'use client' 선언 누락으로 인한 에러가 발생했었습니다. 실수로 발생한 에러였지만, 이런 이슈는 개발 단계에서 미리 감지할 수 있다면 예방할 수 있을 것으로 생각했습니다. 그래서 여러 해결책 중에서 ESLint 커스텀 룰을 통해 앞으로의 에러 발생 가능성을 줄이고자 했습니다. 지금부터 ESLint가 어떻게 코드를 분석하는지, 그리고 커스텀 룰을 어떻게 만들고 개선해 나갔는지 과정에 대해서 알아보겠습니다.

ESLint?

ESLint는 JavaScript 정적 코드 분석 도구입니다. ESLint의 Rule을 통해서 코드 에디터에 작성한 코드 베이스에서 잠재적으로 발생할 수 있는 런타임 에러나, 룰에 해당하지 않는 코드들을 감지하고 수정을 제안 또는 강제합니다. 이를 통해서 여러 사람이 개발하더라도 동일한 코드 컨벤션을 유지할 수 있도록 도와줍니다.

import 구문의 순서가 규칙에 맞지 않으면 발생하는 eslint error

ESLint가 우리의 코드를 분석하는 방법

그럼 ESLint는 어떻게 우리 코드를 분석할까요? ESLint가 동작하는 과정은 다음과 같습니다.

  1. JavaScript 코드를 읽는다
  2. JavaScript 버전에 맞는 파서(Parser)로 코드를 추상 구문 트리(AST: Abstract Syntax Tree)로 구조화 한다
  3. 구조화된 트리와 ESLint Rule과 비교한다.
  4. Rule 위반 여부에 대해서 수정을 제안하거나 수정한다.

추상 구문 트리(AST)?

본격적으로 ESLint의 동작 원리를 이해하고, 커스텀 룰을 작성하기 전에 꼭 알아야 할 개념이 하나 있습니다. 바로 추상 구문 트리(AST : Abstract Syntax Tree) 입니다.

추상 구문 트리(AST)는 작성한 코드를 컴퓨터가 이해하기 쉬운 트리 구조로 변환한 것을 의미합니다. 코드 문법 구조에 따라 코드를 분해한 것이라고 이해해도 될 것 같습니다. 이런 구조 변환을 통해 컴퓨터는 코드가 어떤 함수를 호출하고, 어떤 인자를 어떤 순서로 전달하는지 이해할 수 있습니다.

실제로 어떻게 구조를 바꾸는지 아래 간단한 예시를 통해 알아보겠습니다.

function Comento() {
  console.log('Hello, Comento!');
}

ESLint는 파서(espree parser)를 통해 다음과 같이 구문 트리를 만들어 줍니다.

{
  "type": "Program",
  "body": [
    {
      "type": "FunctionDeclaration",
      "id": { "type": "Identifier", "name": "Comento" },
      "body": {
        "type": "BlockStatement",
        "body": [
          {
            "type": "ExpressionStatement",
            "expression": {
              "type": "CallExpression",
              "callee": {
                "type": "MemberExpression",
                "object": { "type": "Identifier", "name": "console" },
                "property": { "type": "Identifier", "name": "log" }
              },
              "arguments": [
                { "type": "Literal", "value": "Hello, Comento!" }
              ]
            }
          }
        ]
      }
    }
  ]
}

console.log를 실행하는 단순한 함수 코드지만 AST 내부에서는 함수의 시작 지점, 함수 구분자, 함수 본문 등 상당히 구체적인 정보들을 담고 있는 것을 볼 수 있었습니다.

💡 AST 구조에 대해서는 AST Explorer를 통해서 테스트와 함께 확인할 수 있습니다.

ESLint는 이 AST를 통해서 우리 코드 컨벤션에 적용된 룰에 위반하는 구문을 AST를 순회하며 탐색할 수 있습니다.

ESLint 커스텀 룰 만들기

이번에는 ESLint의 커스텀 룰을 만들어 보겠습니다.

ESLint이 제공하는 기본적인 룰과 커뮤니티의 룰을 활용하여 다양하게 코딩 컨벤션을 커버할 수 있습니다. 하지만 기존 룰들이 현재 사내 코드 베이스와 맞지 않는다면 활용하기 어렵기 때문에 우리에게 맞는 룰을 추가하여 활용할 수 있습니다.

Next.js App Router에서는 React Hook을 사용하는 컴포넌트가 반드시 클라이언트 컴포넌트여야 하므로 use client 지시어가 필요합니다. 특히 디자인 시스템 컴포넌트처럼 다양한 환경에서 재사용되는 컴포넌트의 경우, Hook 사용 시 use client 선언을 누락하면 서버 컴포넌트에서 import할 때 런타임 에러가 발생할 수 있습니다.

따라서 아래와 같은 예시 코드를 통해 React 훅을 사용하는 경우 'use client' 선언이 필요하다. 는 규칙을 직접 만들어보겠습니다.

export default function NameComponent() {
  const [name, setName] = useState('comento');
  return <div>{name}</div>;
}

NameComponent는 AST에서 다음과 같이 변환이 됩니다.

{
  "type": "VariableDeclaration",
  "declarations": [
    {
      "type": "VariableDeclarator",
      "id": {
        "type": "ArrayPattern",
        "elements": [
          { "type": "Identifier", "name": "name" },
          { "type": "Identifier", "name": "setName" }
        ]
      },
      "init": {
        "type": "CallExpression",
        "callee": { "type": "Identifier", "name": "useState" },
        "arguments": [
          { "type": "Literal", "value": "comento" }
        ]
      }
    }
  ],
  "kind": "const"
}

AST를 추적하면서 AST 트리 노드들이 callee 안에 있는 name 이 useState인 구문이 있다면 에러가 발생하도록 룰을 작성할 수 있습니다.

CallExpression(node) {
  // 'use~'로 시작하는 구문 노드인 경우
  // use client 설정이 필요함을 의미하는 플래그 변수를 true로 설정
  if (
    node.callee?.type === 'Identifier' &&
    node.callee.name?.startsWith('use')  
  ) {
    useClientFeature = true;
  }
}

이 코드는 React Hook의 규칙에 해당하는 노드인 경우 플래그 변수로 정의한 useClientFeature를 true로 수정하도록 하는 세부 규칙입니다.

규칙을 작성하는 것에 있어서 AST 자체를 이해하려고 하기보다 "내가 무엇을 체크하고 싶은지"에 따라 필요한 노드의 타입과 속성 위주로 파악하면 더 수월하게 규칙을 작성할 수 있습니다.

커스텀 룰 완성하기

위에서 목표로 하는 규칙들을 작성한 후 아래와 같이 ESLint가 룰을 이해할 수 있는 구조로 파일을 구성합니다.

// require-use-client.js

module.exports = {
  // meta 객체 : ESLint가 룰을 이해하기 위한 메타데이터
  meta: {
    type: 'problem',
    docs: {
      description: 'React Hook 사용 시 "use client" 선언을 강제합니다'
    },
    fixable: 'code',
    messages: {
      missingUseClient: 'React Hook을 사용할 때는 "use client" 선언이 필요합니다.'
    }
  },
	
  // ESLint의 실제 검사 로직
  create(context) {
    let usesClientFeature = false;
    let hasUseClientDirective = false;

    return {
      // 코드에 'use client'가 있는지 확인하는 세부 규칙
      Program(node) {
        const firstStatement = node.body[0];
        if (firstStatement?.type === 'ExpressionStatement' &&
            firstStatement.expression.value === 'use client') {
          hasUseClientDirective = true;
        }
      },
      
      ...
      // React Hook에 대응하기 위해 함수의 'use~' 키워드를 감지하는 세부 규칙
      CallExpression(node) {
        if (node.callee?.type === 'Identifier' &&
            node.callee.name?.startsWith('use')) {
          usesClientFeature = true;
        }
      },
      
      ...
      
      // Program:exit: 파일 전체 분석 후 최종 판단하는 시점
      'Program:exit'() {
        if (usesClientFeature && !hasUseClientDirective) {
          // context.report(): 에러를 보고하고 자동 수정하는 함수 호출
          context.report({
            node: context.getSourceCode().ast,
            messageId: 'missingUseClient',
            fix(fixer) {
              return fixer.insertTextBefore(
                context.getSourceCode().ast.body[0],
                '"use client";\n\n'
              );
            }
          });
        }
      }
    };
  }
};

ESLint 테스트 코드 작성하기

완성된 ESLint 룰은 'use client' 키워드가 필요한 다양한 상황들에 대한 세부 규칙들을 포함하고 있어서, 코드만으로는 어떤 케이스들을 감지할 수 있는지 직관적으로 파악하기 어려웠습니다.

그래서 PR 리뷰를 통해 같은 프론트팀의 동훈님과 종석님께서 테스트 코드 작성을 제안해주셨고, 테스트 코드를 작성하면 룰이 커버하는 케이스들을 하나씩 직관적으로 파악할 수 있게 되기 때문에 테스트 코드를 추가하게 되었습니다. ESLint는 테스트를 위한 공식 플러그인인 RuleTester를 활용해서 테스트 코드를 작성할 수 있었습니다.

RuleTester는 기본적으로 테스트 환경을 인자로 받아 ESLint가 동작하는 환경에 대한 정보를 설정할 수 있습니다.

const ruleTester = new RuleTester({
  ...options,
});

그리고 테스트 실행부에서 valid/invalid 케이스를 각각 작성하여 테스트를 실행할 수 있습니다.

ruleTester.run('my-rule', rule, {
  valid: [
    // ... 유효한 테스트 코드 
  ],
  invalid: [
    // ... 유효하지 않은 테스트 코드
  ],
});

valid/invalid 케이스 모두 테스트 대상을 문자열로 작성해야 하며, 각 케이스에 별도의 테스트 옵션을 부여할 수 있습니다.

valid : [
  // 문자열 만으로 테스트 케이스를 설정할 수 있고,
  'const x = 1;',
  
  // 객체로 정의하여 사용할 수 있습니다.
  {
    code: 'const y = 2;',
    options: [{ ruleOption: true }],
  },
],

invalid 케이스의 경우 autofix를 사용하는 룰에 대해서 에러 감지 후에 수정된 결과 값(output)과 에러에 대한 정보(errors)를 함께 작성하여 테스트에 사용할 수 있습니다.

invalid: [
  {
    code: 'const a = 1;',
    output: 'const b = 1;',
    errors: [
      { messageId: 'ruleMessage' }
    ],
  },
]

RuleTester를 활용하여 아래와 같이 작성해보았습니다.

// 테스트 환경 설정
const ruleTester = new RuleTester({
  languageOptions: {
    ecmaVersion: 2022 as const,
    sourceType: 'module' as const,
    parserOptions: {
      ecmaFeatures: { jsx: true },
    },
  },
});

// 테스트 케이스 설정
ruleTester.run('require-use-client', requireUseClient, {
  valid: validTestCases, // 유효한 테스트 케이스
  invalid: invalidTestCases, // 유효하지 않은 테스트 케이스
});
const validTestCases = [
	...
  	{
        // 테스트 이름
        name: 'useEffect 사용시 "use client" 키워드 선언됨',
        // 테스트 대상
        code: `
  	'use client'
  	import { useEffect } from 'react';
  	
  	export default function Component() {
  	  useEffect(() => {
  	    console.log('mounted');
  	  }, []);
  	  
  	  return <div>Component</div>;
  	}`,
	},
	...
]

const invalidTestCases = [
	...
	{
        // 테스트 이름
        name: 'useEffect 사용시 "use client" 키워드 누락', 
        // 테스트 대상
        code: `
	import { useEffect } from 'react';
	
	export default function Component() {
	  useEffect(() => {
	    console.log('mounted');
	  }, []);
	  
	  return <div>Component</div>;
	}`,
        // 테스트 실패시 나타나야 하는 에러 메시지
        error: ESLINT_ERROR_MESSAGE,     
        // auto fix 적용 결과 코드
    	output: `
	'use client';
	import { useEffect } from 'react';
	
	export default function Component() {
	  useEffect(() => {
	    console.log('mounted');
	  }, []);
	  
	  return <div>Component</div>;
	}`,
	},
	...
]

여기서 한가지 아쉬운 점은 테스트 케이스의 가독성이 너무 좋지 않다는 점이었습니다.

RuleTester의 API가 테스트할 코드를 문자열 형태로 받도록 설계되어 있어서, code와 output을 모두 텍스트로 작성해야 했습니다.

백 틱(`)으로 작성하여 줄바꿈을 통해 가독성을 높이고자 했으나, 줄바꿈, 공백, 들여쓰기 모두 테스트 케이스에 반영되다보니 모든 문자를 하나씩 직접 맞추는 것에도 한계가 있었고, 데이터 구조나 코드 구조에 맞게 작성하는 것에도 어려움이 있었습니다.

그리고 테스트 케이스 내에서도 import { useEffect } from ‘React’use client 와 같은 구문들은 반복적으로 사용되고 있어 재사용성 면에서 개선이 필요해 보였습니다.

템플릿을 활용한 개선

반복되는 코드 패턴과 작성의 어려움을 해결하기 위해, 컴포넌트 코드를 자동으로 생성할 수 있는 템플릿 시스템을 만들어 활용하는 방향으로 개선해보았습니다.

// 테스트 케이스 생성
class TestCaseBuilder {
  static component(body: string, imports: string = ''): string {
    return `${imports}${imports ? '\n' : ''}
export default function Component() {${body}
}`;
  }

  static clientComponent(body: string, imports: string = ''): string {
    return `"use client";\n\n${imports}${imports ? '\n' : ''}
export default function Component() {${body}
}`;
  }

  static createValidCase(
    name: string,
    body: string,
    imports: string = '',
    filename: string | null = null,
  ): ValidTestCase {
    const testCase: ValidTestCase = {
      name,
      code:
        // 코드에 export/import/test 등 포함 시 전체 코드로 간주
        body.includes('export') ||
        body.includes('import') ||
        body.includes('test(')
          ? body // 이미 완전한 코드인 경우 (스토리북, 테스트 등)
          : this.component(body, imports), // 컴포넌트 템플릿이 필요한 경우
    };

    if (filename) {
      testCase.filename = filename;
    }

    return testCase;
  }

  static createInvalidCase(
    name: string,
    body: string,
    imports: string = '',
  ): InvalidTestCase {
    return {
      name,
      code: this.component(body, imports), // 원본 코드 ( use client 없음 )
      output: this.clientComponent(body, imports), // auto fix로 인해 수정된 코드 ( use client 추가 )
      errors: [{ message: MISSING_USE_CLIENT_MESSAGE }],
    };
  }
}

그리고 반복되는 코드 조각들에 대해서는 문자열을 상수화하여 관리하였습니다.

// 테스트 생성에 필요한 코드 셋
const IMPORTS = {
  REACT_HOOKS: `import { useState, useEffect } from 'react';`,
  USE_STATE: `import { useState } from 'react';`,
  USE_EFFECT: `import { useEffect } from 'react';`,
  REACT: `import React from 'react';`,
  REACT_WITH_HOOKS: `import React, { useState, useEffect } from 'react';`,
  REACT_QUERY: `import { useQuery } from '@tanstack/react-query';`,
  UTILS: `import { cn } from '@/lib/utils';`,
} as const;

const COMPONENT_BODIES = {
	USE_EFFECT: `
  useEffect(() => {
    console.log('mounted');
  }, []);
  return <div>Component</div>;`,

  REACT_USE_EFFECT: `
  React.useEffect(() => {
    console.log('mounted');
  }, []);
  return <div>Component</div>;`,

  MULTIPLE_HOOKS: `
  const [count, setCount] = useState(0);
  const handleClick = useCallback(() => setCount(c => c + 1), []);
  
  useEffect(() => {
    document.title = \`Count: \${count}\`;
  }, [count]);
  
  return <button onClick={handleClick}>{count}</button>;`,
  ...
}

이를 통해 아래와 같이 훨씬 간결하게 테스트 코드를 작성할 수 있게 되었습니다.

// 테스트 케이스 리스트
const validTestCases = [
	...
	TestCaseBuilder.createValidCase(
    '정적 콘텐츠만 있는 컴포넌트',
    COMPONENT_BODIES.STATIC_CONTENT,      // body
    `${IMPORTS.REACT}\n${IMPORTS.UTILS}`, // import
  ),
  TestCaseBuilder.createValidCase(
    'use로 시작하는 변수명 (훅이 아님)',
    COMPONENT_BODIES.USE_PREFIX_VARIABLE,
  ),
	...
]

const invalidTestCases = [
	...
	TestCaseBuilder.createInvalidCase(
    'useState 훅 사용',
    COMPONENT_BODIES.USE_STATE,
    IMPORTS.USE_STATE,
  ),

  TestCaseBuilder.createInvalidCase(
    'useEffect 훅 사용',
    COMPONENT_BODIES.USE_EFFECT,
    IMPORTS.USE_EFFECT,
  ),

  TestCaseBuilder.createInvalidCase(
    'React.useEffect 사용',
    COMPONENT_BODIES.REACT_USE_EFFECT,
    IMPORTS.REACT,
  ),
	...
]

템플릿 시스템을 도입하여 테스트 케이스 작성 과정은 간소화되었지만, 가독성 부분에서는 여전히 개선이 필요했습니다.

오히려 각 테스트 케이스의 실제 컴포넌트 코드를 파악하기 위해서는 여러 상수들을 참고해야 하는 번거로움이 생겼습니다.

파일 기반 테스트 케이스 관리

위와 같은 가독성 이슈를 다시 한 번 팀에 공유했습니다. 이번에는 테스트 컴포넌트 로직을 별도 파일로 분리하여 import 하는 것이 더 좋을 것 같다는 피드백을 받을 수 있었고, 파일 기반 분리 방식을 통해 유의미하게 개선할 수 있었습니다.

import { readFileSync } from 'fs';
import path from 'path';

export function loadCode(filename: string): string {
  return readFileSync(
    path.resolve('./eslint-plugin-custom-rules/cases', '', filename),
    'utf-8',
  );
}

'./eslint-plugin-custom-rules/cases' 디렉토리 하위에 테스트할 컴포넌트 코드들을 작성하고 Node.js 기반의 fs 모듈을 활용하여 파일을 직접 읽어와 활용하였습니다.

// useClientDeclared.tsx

'use client';

import { useState } from 'react';

export default function Component() {
  const [count, setCount] = useState(0);
  return <div>{count}</div>;
}
const validTestCase = [
	...
	{
    name: '"use client" 선언된 useState 컴포넌트',
    code: loadCode('./valid/useClientDeclared.tsx'),
  },
	...
]

파일 기반 접근법을 통해 여러 가지 이점을 얻을 수 있었습니다.

우선, 테스트할 컴포넌트 코드를 실제 파일로 작성하여 코드 구조를 직관적으로 파악할 수 있게 되었습니다. 테스트 파일명과 케이스 이름을 통일시키고, 테스트 케이스별로 파일로 관리할 수 있게 되어 구분하기도 편해졌습니다.

그리고 테스트 케이스 작성이 IDE를 통해 일반 컴포넌트를 만드는 것처럼 자동완성과 문법 하이라이팅을 지원받으며 작성할 수 있게 되었고, 각 테스트 케이스가 독립적인 파일로 관리되어 수정이나 추가가 용이해져 파일 구조만으로도 테스트 케이스의 종류를 쉽게 파악할 수 있게 되었습니다.

맺으며

지금까지 ESLint 커스텀 룰을 반영하기까지의 과정을 함께 알아보았습니다.

이번 ESLint 룰 개발은 표면적으로는 작은 룰 하나를 추가하는 작업이었지만, 개인적으로 많은 것을 배울 수 있었던 의미 있는 경험이었습니다.

기술적 측면에서는 평소 당연하게 사용해왔던 ESLint의 내부 동작 원리를 깊이 있게 이해할 수 있었습니다. AST라는 개념을 통해 ESLint가 어떻게 우리 코드를 분석하는지, 그리고 이를 바탕으로 어떻게 커스텀 룰을 만들 수 있는지를 직접 경험해볼 수 있었죠. 사이드 프로젝트나 업무에서 도구로서만 활용했던 기술을 들여다본 것은 신선한 경험이었습니다.

협업 측면에서는 커스텀 룰의 도입부터 개발, 테스트 과정에 이르기까지 프론트엔드 팀 내에서 이루어진 코드 리뷰와 미팅을 통해 점진적으로 더 나은 방향으로 발전해가는 과정을 경험할 수 있었습니다.

특히 인상 깊었던 점은 사소해 보이는 개선 작업도 팀 내 상호작용을 이끌어낼 수 있다는 점이었습니다. 작은 문제 제기에서 시작해 함께 해결책을 찾고, 더 나은 방법을 모색하는 과정 자체가 팀 내에서 긍정적인 작용을 한다고 생각합니다.

이런 코멘토의 문화는 구성원들이 적극적으로 문제를 발견하고 해결해나갈 수 있게 하는 원동력이 되는 것 같습니다.😉

Reference


커리어의 성장을 돕는 코멘토에서 언제나 함께 성장할 개발자를 기다리고 있습니다. 채용 페이지에서 코멘토가 어떤 회사인지, 어떤 사람을 찾는지 더 자세히 확인해보세요. 😊