Gu Doyoon

[Eslint 플러그인 제작] Suspense의 문제점 해결해보기

1/31/2026 작성

서론

이번에 캡스톤 프로젝트로 AI 챗봇 사이트인 도란도란 프로젝트를 진행하던 도중 Suspense 사용으로 인한 에러가 발생하게 되었고 이는 앱 크레시까지 이어지게 되었다...
이를 해결하기 위해 네이밍 컨밴션(Eslint)룰을 만들기까지의 해결과정을 공유하고자 글을 작성하게 되었당

1. 왜 만들게 되었나?

ReactSuspense는 선언적인 비동기 처리를 가능하게 하지만, 특정 훅이나 컴포넌트가 Promisethrow하는지 코드만 보고 판단하기는 쉽지 않습니다.

//이 함수들은 Promise를 throw할까? useAuth(); <ProfileCard />

저는 위의 추상화된 함수를 만든 후 추후 다른곳에서 재사용하게 되었는데, 이때 앱 전체가 흰 화면으로 깨지게 되었습니다.

이유는 바로 내부에

const { data: user } = useSuspenseQuery(userQueries.me());

위와같이 suspense를 발생시키는 로직이 들어있었기 때문입니다.
저는 이를 깜빡한 체 함수를 호출했고 루트에 <Suspense>를 감싸놓지 않은 제 프로젝트는 던저진 Promise는 어디서도 잡지 않아 앱 전체가 깨져버렸습니다.

저는 예측 가능한 코드를 작성하고 어디서 비동기 작업이 일어나는지 한눈에 파악하기 위한 방법을 모색했습니다.

2. 어떤 방식으로 해결할까?

첫번째 시도

첫 시도는 코드를 분석하여 함수 내부에 Promise를 throw하는 로직이 있는지 확인하여 개발자에게 힌트를 제공해주는 아이디어였습니다.

function throwsPromise(node) { // ThrowStatement는 찾을 수 있지만... if (n.type === 'ThrowStatement') { // 이게 Promise인지는 런타임에만 알 수 있음! } }

ThrowStatement(예외를 던지는 곳) 자체는 찾을 수 있지만 무엇을 Throw하는지 자체는 찾아낼 수 없어 첫 시도는 실패했습니다.

그렇다면 네이밍을 강제해보자

정적 분석 및 js 언어의 특성상 런타임에서 실행되는 Promise throw를 찾아낼 수 없었고 따라서 저는 네이밍 컨벤션을 통해 개발자에게 미리 경고해주는 방식을 채택했습니다.

3. 어떻게 이름을 감지하고 강제할까?

ESLint는 소스 코드를 AST(Abstract Syntax Tree) 라는 트리 구조로 분석하며 방문자 패턴 을 이용해 특정 노드 패턴을 발견하면 저희가 작성한 코드가 호출되는 구조를 가지고 있습니다.

저는 여기서 CallExpression노드에 방문자(Visitor) 를 설정했습니다. 이를 통해 모든 함수 호출을 실시간으로 감시하며, 특정 조건(Suspense 트리거)에 부합하는지 정적 분석을 수행할 수 있었습니다.

본격적인 Eslint 플러그인 개발!

먼저 다양한 Suspense 유발 패턴을 유연하게 찾아내기 위해

함수가 실행될 때 위와 같은 패턴을 가진 함수를 찾아냈습니다.

가장 까다로운 부분은 이름이 숨겨진 경우 였습니다. React.memo()forwardRef() 같은 고차 컴포넌트(HOC) 내부에 있는 경우에도 실제 변수명을 정확히 추출해야 했습니다.

getFunctionName(node) { // 1. 함수 이름이 직접 선언된 경우 (예: function MyComponent() {}) if (이름이_있는_함수_선언식) { return node.id.name; } let current = node.parent; // 현재 노드가 memo(() => ...) 같은 호출문 안에 있다면, 부모로 한 단계 더 올라갑니다. if (current.type === 'CallExpression') { current = current.parent; } // 3. 변수에 할당된 경우 (예: const MyComponent = memo(...)) if (변수_선언문(VariableDeclarator)인_경우) { return 변수_식별자_이름; } // 4. 객체의 속성으로 정의된 경우 (예: const obj = { MyMethod: ... }) if (객체_속성(Property)인_경우) { return 속성_키_이름; } // 5. 이름 없는 export default인 경우 (예: export default () => ...) if (기본_내보내기(ExportDefaultDeclaration)인_경우) { return 'DefaultExport'; // 이후 로직에서 파일명으로 대체 } return null; }

함수의 이름을 찾아주는 로직을 의사코드로 표현해봤습니다.
위와 같은 로직을 통해 다양한 케이스에서 안정적으로 함수의 이름을 추출할 수 있게 되었습니다.

위에서 찾은 함수 이름을 통해

const isSuspenseAware = /Suspense|Boundary/i.test(funcName); if (!isSuspenseAware) { const messageId = language === 'kr' ? 'suspenseNamingError_kr' : 'suspenseNamingError_en'; context.report({ node: parent.id || parent, messageId, data: { name: funcName }, }); }

현재 함수의 이름을 사용자가 Suspense라는 이름을 포함해주지 않았다면 부모 함수에 메세지가 뜨도록 작성해주었습니다.

또한 요구사항이 추가되더라도 기존 로직을 해치는 불상사가 없도록 vitest를 통해 테스트 코드들을 작성했습니다.
편의를 위해 분리해놓은 함수별로 유닛 테스트 진행했습니다.
초기에는 RuleTester가 Vitest의 테스트 코드를 인식하지 못하는 문제가 있었으나, vitest.config.js에서 globals: true 설정을 통해 해결했습니다.

또한 E2E 테스트 겸 모노레포로 demo 패키지를 만들어 실제로 잘 작동하는지 확인했습니다.

image

4. 최종 결과

이제 useSuspenseQuery 와 같이 Suspense를 유발하는 함수를 호출하면서 부모의 이름을 잘못 지으면 즉시 경고가 발생합니다!

물론 네이밍 자체가 강제되는것 자체가 어느정도 오버헤드를 안고가는것 또한 인지하고 있습니다. 다만 저같은 경우는 이런식으로 컨벤션을 확실히 가져가 런타임 에러를 방지하고 귀찮게 파일을 뒤적이는것이 프로젝트가 커질수록 좋다고 생각되어 개발하고 적용하게 되었습니다!

추가적으로 저와 같은 문제를 겪는 사람과, Eslint 플러그인을 처음 만들어본 기념? 을 위해 Github에 올림과 동시에 문서화 및 npm 배포를 진행하게 되었습니다.

5. 앞으로의 계획

일회성인 플러그인이 아닌 개인적으로 한번 지속적인 관리를 하고싶어 앞으로의 계획을 간단하게 세워봤습니다.

회고

이번 기회를 통해 ESLint의 동작 원리를 파악하고 NPM 및 첫 오픈소스를 만들게 되었는데 물론 처음에 생각한것처럼 완벽하게 체킹을 해주는 플러그인을 개발하지는 못했지만, 앞으로 auto fix, 다양한 패턴을 지원하며 완성도를 올려보고 싶은 욕심이 들었다. 불편함을 무시하지 않고 해결하려는 생각이 들었다는게 뿌듯한 프로젝트였던 것 같다 ㅎㅎ

[Eslint 플러그인 제작] Suspense의 문제점 해결해보기 | 구도윤 기술 블로그