[Next.js 13] eslint-plugin-server-component-rules 개발기

Sming·2023년 6월 25일
24

server-component 개발을 하며..

저는 현재 Nextjs13의 server component를 활용하여 개발을 하고 있습니다.

그러던 중 하나의 불편함을 느꼇습니다. server component 에서 여러가지 제약이 있는데 그 제약을 확인하기가 약간의 불편함이 있었습니다.

개발을 하며 빌드를 해야 그 제약을 확인할 수 있었고 제약중 하나인 client component에서 server component를 import하지 못하는것은 확인하지 못하였습니다.

그래서 이러한 제약을 개발하며 에디터 (vscode) 단에서 확인하면서 개발하면 좋지 않을까? 라는 생각으로 eslint-plugin을 이용했습니다.

eslint-custom-rule

https://eslint.org/docs/latest/extend/plugins

eslint의 공식문서에는 custom rule을 만들때 굉장히 잘 나와있습니다.

일단 eslint는 코드를 ast tree 형태로 변경을 하여 파일, 함수, 변수, jsx, 토큰 단위들로 확인을 할 수 있습니다.

https://astexplorer.net/ 이 사이트를 사용하면 코드를 ast tree로 변경했을때 어떠한 결과값이 나오는지를 확인할 수 있습니다.


eslint의 custom rule을 만들게 될때의 지켜야 될것이 있습니다.

  • package이름은 무조건 eslint-plugin으로 시작을 해야합니다.
  • 사용하는 측에서는 eslint-plugin-example 이렇게 설치한다면 eslintrc.js 부분의 plugins 부분에 ['example'] 식으로 추가하면 됩니디.
  • 배포한 rules들을 사용하는측의 rules에서 이용하게 되며 여기서는 {plugin}/{rules} 형식으로 들어가게 됩니다.
  plugins: ['example'],
  rules: {
    "example/test-rule", ["warn"] // warn, error, off
  },

eslint-custom-rule을 만들때는 다음과 같은 형태로 만들어 줘야합니다. rule에는 meta정보를 담는 meta 프로퍼티와 룰을 만들 수 있는 create 프로퍼티를 이용하여 커스텀 룰을 만들수 있습니다.

export const customRule = {
  meta: {
    type: 'problem', 
    fixable: true,
    docs: {
      url: '...',
    },
  },
  create: (context) => {
    return {
      Program: function (node) {
         context.report({
          node,
          message: `에러에러`,
        });
      }
    }
  }

그리고 module.exports의 rules에 test-rule을 key로 하면 위의 예시처럼 example/test-rule 로 사용할 수 있습니다.

module.exports = {
  rules: {
    'test-rule': {...customRule}
  },
};

create안에서는 https://astexplorer.net/ 에서 나오는 파란색 부분을 key로 이용할 수 있습니다. 예를 들어서 Program, JSXElement, ExportNamedDeclaration 같은것 들이죠.

이것들은 eslint에서 selector이라고 부르며 단순 참조하는것뿐만 아니라 내부에서 로직적인 처리, onCodePathStart같은 이벤트핸들러를 key로 둘수도 있습니다.

https://eslint.org/docs/latest/extend/selectors

context, node 그리고 key

context객체에는 source코드를 가져오는 기능, 파일네임을 가져오는 기능, 실제 에러를 보여주도록 하는 기능등 custom rule을 만들때 필요한 유틸적인 요소들을 포함한 객체입니다.

context객체는 처음의 create 의 매개변수 에서 가져올 수 있습니다.

https://eslint.org/docs/latest/extend/custom-rules#the-context-object
https://eslint.org/docs/latest/extend/code-path-analysis


node객체는 context객체와는 다르게 create의 매개변수가 아닌 그 하위인 Program, JSXElement등의 selector들의 매개변수로 참조를 할수있습니다.

이 node객체에는 그 selector에 해당하는 정보가 담기게 됩니다.

예를 들어 Program같은 경우 하나의 파일단위로 탐색을 하는것이기때문에 context.getSourceCode(node)를 하게되면 그 파일의 소스코드가 모두 나오게됩니다.

추가적으로 JSXElement의 node객체를 이용하여 context.getSourceCode(node)를 하게되면 <div>테스트</div> 같은 Jsx요소를 결과로 받을 수 있습니다.

server components rule만들기

이제 이러한 커스텀 룰을 이용하여 server component rule 을 만들때입니다.

서버 컴포넌트 판별하기

	create: function (node) {
      let isServerComponent = true;
      
      Program: function (node) {
        const sourceCode = context.getSourceCode().getText(node);
        const extension = filename.substring(filename.lastIndexOf('.') + 1);

        if (extension === 'tsx' || extension === 'jsx') {
          if (sourceCode.includes('use client')) {
            isServerComponent = false;
          }
        } else {
          isServerComponent = false;
        }
      }
    }

먼저 let isServerComponent = true 를 선언하여 Program selector에서 서버 컴포넌트를 판별합니다.

확장자가 tsx, jsx인것중에 파일에 'use client'가 포함되어있는것을 client component로 판별을 하도록 했습니다.

custom hook 판별하기

create: function (node) {
   ExportNamedDeclaration: function (node) {
      if (node.declaration?.type === 'VariableDeclaration') {
        if (node.declaration.declarations[0].id.name.match(/^use[A-Z]/)) {
          isCustomHook = true;
        }
      }

      if (node.declaration?.type === 'FunctionDeclaration') {
         if (node.declaration.id.name.match(/^use[A-Z]/)) {
          isCustomHook = true;
         }
      }
  },

  ExportDefaultDeclaration: function (node) {
      if (node.declaration?.type === 'Identifier') {
          if (node.declaration.name.match(/^use[A-Z]/)) {
        	isCustomHook = true;
          }
      }

      if (node.declaration?.type === 'FunctionDeclaration') {
          if (node.declaration.id.name.match(/^use[A-Z]/)) {
            isCustomHook = true;
          }
      }
   },
}

ExportNamedDeclaration, ExportDefaultDeclaration를 이용하여 혹시 export named, export default로 내보내는 이름을 파악한 다음에 그 이름이 use로 시작한다면 customHook으로 판별하도록 했습니다.

이 2개의 조합으로 custom hook은 아니면서 server component인것들을 판별하여 custom rule을 적용시킬 예정입니다.

⭐️ server-component-rules/file-name

Program: function (node) {
  const sourceCode = context.getSourceCode().getText(node);
  const filename = context.getFilename();
  const extension = filename.substring(filename.lastIndexOf('.') + 1);

  // 다른곳에서 판별한 server component처리 로직
  if (isServerComponent && !isCustomHook) {
    const fileName = context.getFilename();
    const { options } = context;
    const option = options.find((opt) => 'middle' in opt);
    const middle = option.middle;

    if (fileName.includes('tsx') && !fileName.endsWith(`index.${middle}.tsx`)) {
      const suggestedFileName = fileName.replace(/\.tsx$/, `.${middle}.tsx`);

      context.report({
        node,
        message: `server component's file name should be '${suggestedFileName}'`,
      });
    }
  }
},

server component에 대한 file name을 제한하는 룰입니다. server component에 file name convention을 지정하는룰입니다.

index.${middle}.tsx를 convention으로 하여서 client component와의 확실한 구분을 하도록 하였고, 이는 나중에 no-import-use-client rule에 이용과 함께 이용이 됩니다.

  plugins: ['server-component-rules'],
  rules: {
    "server-component-rules/file-name", ["error", {middle: 'server'}] // warn, error, off
  },

또한 사용하는 측에서 배열의 두번째 요소의 옵션에 {middle: {name}} 과 같은 식으로 이용을 하면 됩니다. 위의 예시로는 서버컴포넌트의 컨벤션은 index.server.tsx이 됩니다.

⭐️ server-component-rules/no-import-use-client

ImportDeclaration: function (node) {
  if (isServerComponent || isRouteHandler) {
    return;
  }

  if (isCustomHook) {
    return;
  }

  const importedComponent = node.source.value;
  const importSourceCode = context.getSourceCode().getText(node);

  if (!importSourceCode.includes('/') || !importSourceCode.includes('from')) {
    // 외부 라이브러리들 import를 제외하는것입니다. ex) import React from 'react';
    return;
  }

  const { options } = context;
  const option = options.find((opt) => 'middle' in opt);
  const middle = option.middle;

  if (importSourceCode.split('from')[1].split('/').at(-1).includes(middle)) {
    context.report({
      node,
      message: `Can't import server component in client component (${importedComponent})`,
    });
  }
},

client component에서는 server component를 import하지 못한다라는 룰입니다.

ImportDeclaration selector를 이용하여 모든 import 선언문을 가져오고 그 import 선언문에서 from 뒤에 있는 확장자를 가져와서 server component 인지 판별하고 에러를 뱉게 하는 기능입니다.

현재로서는 import한 컴포넌트가 서버 컴포넌트인지 판단하는 방법이 file naming 밖에 없어서 위의 server-component-rules/file-name 룰과 함께 이용을 하여 제어할 수 있습니다.

⭐️ server-component-rules/no-use-event-handler

JSXAttribute: function (node) {
    const attributeName = node.name.name;

    if (attributeName.startsWith('on') && isServerComponent && !isCustomHook) {
      context.report({
        node,
        message: `Can't use ${attributeName} in server component`,
      });
    }
  },
};

다음은 server component에서 이벤트 핸들러를 사용하지못하는 룰입니다.

JSXAttribute를 이용하면 JSXElement에 있는 attribute들을 참조할 수 있습니다. 여기서 node.name.name을 참조하면 그 attribute의 key값을 가져올 수 있는데요.

이렇게 그 attribute를 가져온 후 앞에 on으로 시작하는 속성이면 에러를 뱉도록 처리하였습니다.

⭐️ server-component-rules/no-use-browser-api

Identifier: function (node) {
  const { name } = node;

  if (!isServerComponent) {
    return;
  }

  if (isCustomHook) {
    return;
  }

  if (name === 'document' || name === 'window') {
    context.report({
      node,
      message: `Do not use browser APIs such as '${name}' in server component`,
    });
  }
},

다음은 서버 컴포넌트에서 window나 document같은 브라우저의 객체를 참조하지 못하는 룰입니다.

Identifier selector를 이용하여 모든 식별자들을 받아오고 그 식별자의 이름이 document, window인 것에 에러를 뱉도록 처리하였습니다.

하지만 window객체같은 경우 내부 메서드들을 바로 참조해도 문제가 없는데요.
현재 이 window객체의 내부 메서드까지 판단할 수는 없는 문제가 있습니다.

⭐️ server-component-rules/no-use-custom-hook

CallExpression: function (node) {
  if (node.callee.type === 'Identifier') {
    const { name } = node.callee;

    if (name.match(/^use[A-Z]/) && isServerComponent && !isCustomHook) {
      context.report({
        node,
        message: `Do not use ${name} hook inside JSX files in server component`,
      });
    }
  }
},

마지막으로 서버 컴포넌트에서 hook을 사용하지 못하는 룰입니다.

CallExpression selector를 이용하면 호출한 함수들을 다 받아올 수 있습니다. custom hook도 함수이기 때문에 여기에 포함이 됩니다.

그런 다음 그 식별자(함수)의 이름을 가져온뒤 시작이 use로 시작하며 그 다음에 대문자가 오는 camel케이스인지 파악을 하도록 했습니다.

startsWith('use')로 처리를 하니 user... 이라는 함수도 가져와지는 문제가 있더라고요. 🌧

Ending

이걸 만들고 난후 server component에서 가끔씩 하는 실수들을 코드를 작성하면서 바로 처리하여 생산성이 올라가고 기존 잡을 수 없는 client component에서 server component를 Import 못하는 룰을 린트시에 에러가 나올수 있도록 하여 예상치 못한 런타임 문제를 해결하였습니다.

이러한 rule 제한 외에도 팀에서 특정 컨벤션을 문서화로만 관리하지말고 이렇게 커스텀 룰을 만들어서 제한하는 방법도 좋다고 생각합니다.


https://github.com/hwangstar156/eslint-plugin-server-component-rules

실제 소스코드는 여기서 확인할 수 있습니다.

profile
딩구르르

3개의 댓글

comment-user-thumbnail
2023년 6월 26일

지링지린다..

답글 달기
comment-user-thumbnail
2023년 6월 27일

천재적인 아이돌 개발자사마ww

답글 달기
comment-user-thumbnail
2023년 11월 9일

사소한거지만 README의 npm install 패키지명 부분이 잘못 되어있습니다...!

답글 달기