브라우저가 이미 대신하는 의존성을 찾는 첫 라이브러리, outgrown

오픈소스에 이슈를 올리거나 코드를 고쳐 본 적은 있지만, 라이브러리를 처음부터 만들어 배포해 본 적은 없었습니다.

그래서 라이브러리 배포 방식을 익혀보고자 한 번 만들어 보기로 했습니다.

꼭 세상에 필요한 도구를 만들겠다는 거창한 목표는 아니었으며 문제를 정하고, 공개 API를 만들고, 다른 사람의 프로젝트에서 한번 실행해 보고, 패키지로 배포하는 과정을 처음부터 끝까지 경험해 보고 싶었을 뿐이였습니다.

그래서 생각해본것이 오래된 프론트엔드 프로젝트에는 당시 브라우저가 지원하지 않던 기능을 메우기 위해 넣은 polyfill과 보조 라이브러리가 많이 남아 있습니다. whatwg-fetch, intersection-observer, resize-observer-polyfill 같은 것이죠.

설치할 때는 분명 필요했지만 브라우저는 계속 바뀝니다. 몇 년 뒤에는 브라우저가 같은 일을 직접 하고 있어도 의존성은 그대로 남습니다.

마이그레이션할 때 이런 의존성을 한 번에 보여 주면 참고할 만하지 않을까 생각했습니다. 그렇게 만든 것이 outgrown입니다.

npx outgrown

비슷한 라이브러리로는 knip이나 depcheck가 있는데 이 라이브러리들은 사용하지 않는 의존성을 찾는다면, outgrown은 여전히 사용 중이지만 프로젝트가 이미 졸업했을 수 있는 의존성을 찾습니다.

 DROP  @floating-ui/react
  → CSS anchor positioning + Popover API

 CHECK  framer-motion
  → @starting-style + transition-behavior: allow-discrete
  Uses features CSS cannot express: layoutId

 NOT YET  aos
  → scroll-driven animations
  firefox unsupported · safari 18.0 < 26

아무도 안쓸수도 있지만, 다만 이걸 만들면서 생각보다 어려웠던 것은 코드를 작성하는 일보다 도구가 어디까지 확신해도 되는지 정하는 일이었습니다.


같은 의존성도 프로젝트마다 답이 다릅니다

whatwg-fetch를 발견했다고 바로 지워도 된다고 말할 수는 없습니다. 어떤 프로젝트는 최신 Chrome만 지원하지만, 어떤 프로젝트는 오래된 Safari까지 지원합니다.

그래서 두 데이터를 교차해 판정하죠.

프로젝트의 browserslist × web-features의 브라우저 지원 데이터
  • browserslist는 이 프로젝트가 지원해야 하는 브라우저를 말합니다.
  • web-features는 각 웹 기능이 어느 브라우저 버전부터 들어왔는지 말합니다.

같은 코드를 검사해도 대상 브라우저가 달라지면 결과가 바뀝니다.

outgrown --targets "safari >= 18"
# @floating-ui/react → NOT YET

outgrown --targets "safari >= 26.2"
# @floating-ui/react → DROP

이게 이 도구에서 가장 중요한 부분입니다. “요즘 브라우저는 지원한다”가 아니라 이 프로젝트가 지원하기로 한 모든 브라우저가 지원하는가를 물어야 합니다.

브라우저가 새 기능을 배포하고 web-features 데이터가 갱신되면 코드를 한 줄도 바꾸지 않아도 결과가 달라집니다. 한 번 실행하고 끝내는 검사라기보다 마이그레이션 전이나 몇 달에 한 번 다시 볼 만한 검사에 가깝습니다.


수많은 라이브러리를 어떻게 확인할 것인가?

처음에는 npm에서 polyfill 패키지를 검색하면 될 줄 알았습니다.

그런데 polyfill 키워드를 가진 패키지만 6,000개가 넘었습니다. 우엑..

전부 확인하는 것은 현실적이지 않았고, npm 검색 결과도 기능과 패키지를 자주 잘못 연결했습니다.

그래서 방향을 반대로 잡아봤습니다.

npm에 어떤 라이브러리가 있는지부터 찾지 않고, 최근 브라우저에 어떤 기능이 들어왔는지부터 찾는다.

브라우저에 최근 몇 년 사이 들어온 기능 목록은 유한합니다. 그 기능을 대신하던 패키지를 찾고 다운로드 수로 우선순위를 정하면 검토할 목록을 줄일 수 있습니다.

최근 Baseline에 들어온 기능

그 기능을 대신하던 npm 패키지 검색

다운로드 수로 우선순위 정렬

기능 ID와 실제 역할을 사람이 확인

규칙에 추가

자동으로 찾은 후보를 바로 규칙으로 넣지는 않았습니다. 패키지 이름에 polyfill이 들어 있어도 검색기가 전혀 다른 기능과 연결하는 경우가 많았기 때문이죠.

후보 생성 결과는 어디까지나 리뷰 대기열로 두고, 기능 ID와 패키지 역할을 직접 확인한 것만 규칙에 넣었습니다.

직접 작성한 규칙 외에는 e18e의 module-replacements가 관리하는 native 대체 목록도 사용했습니다. e18e가 “이 패키지는 이 네이티브 API로 바꿀 수 있다”를 관리하고 있다면, outgrown은 거기에 “현재 프로젝트의 브라우저에서도 가능한가”를 더합니다.

현재는 7개 규칙에서 317개 패키지를 확인하게 설정 해뒀습니다. 중요한 건 317이라는 숫자보다 그 밖의 패키지는 모른다고 말하는 것이었습니다.

checked 317 known packages across 7 rules
— a curated set, not every dependency you have

검사 결과가 비었다고 프로젝트의 모든 의존성이 적절하다는 뜻은 아닙니다. 아는 규칙에 걸리지 않았다는 뜻일 뿐입니다.

첫 버전에는 이 문장이 없었고, 만들다 보니 기능을 더 넣는 것만큼 도구가 모르는 범위를 출력하는 것도 기능이라는 생각이 들었습니다.


문제 1 — 모르는 브라우저를 빼니 모두 안전해졌습니다

browserslist에는 Chrome, Firefox, Safari만 나오는 것이 아닙니다. IE, Opera Mini, KaiOS, Samsung Internet, Safari Technology Preview도 나올 수 있습니다.

문제는 web-features가 이 브라우저들을 모두 같은 수준으로 다루지 않는다는 것입니다. 데이터가 없는 브라우저를 단순히 검사 대상에서 빼자 이상한 결과가 나왔습니다.

지원 여부를 판단할 수 있는 브라우저가 0개
→ 막은 브라우저도 0개
→ 모든 기능이 지원됨
→ DROP

아무것도 확인하지 못했는데 전부 안전하다는 결과가 나온 겁니다. 코드로 보면 빈 배열에 대한 every()true가 되는 것과 비슷했습니다.

처음에는 IE처럼 오래된 브라우저를 그냥 “미지원”으로 처리하는 것도 생각했습니다. 그런데 이것도 사실이 아닙니다. IE 11도 requestAnimationFrame처럼 지원하는 기능이 있습니다.

데이터가 없다는 것은 미지원이 아니라 모른다는 뜻입니다.

그래서 세 상태로 나눴습니다.

상태처리
지원 데이터가 있고 버전을 비교할 수 있음정상 판정
데이터가 없거나 파생 브라우저라 정확한 버전을 모름DROPCHECK로 낮춤
판정 가능한 브라우저가 하나도 없음검사 중단

Samsung Internet처럼 Chromium을 따라가는 브라우저도 “Chrome과 같다”고 단정하지 않았습니다. 기능은 결국 들어오겠지만 얼마나 늦게 따라오는지를 데이터가 말해 주지 못하기 때문이죠.

Safari TP도 stable Safari가 함께 있을 때만 중복 대상으로 뺄 수 있습니다. TP만 있다면 그 항목이 Safari에 관한 유일한 정책입니다.

프로젝트에 browserslist 자체가 없는 경우도 고민이었습니다. browserslist의 기본값은 시장 점유율 기반이라 Opera Mini와 KaiOS가 포함됐고, 이들을 판정하지 못해 모든 결과가 막혔습니다.

결국 baseline widely available을 임시 정책으로 사용하되 결과보다 먼저 이렇게 알리게 했습니다.

This project has no browserslist, so "baseline widely available" stood in for one.
That is an assumption, not your policy.

어떤 기본값을 택해도 추측입니다. 중요한 것은 추측하지 않는 척하지 않는 것이었습니다.


문제 2 — 못 찾은 것은 없다고 판단했습니다

초기 규칙은 소스에서 몇 가지 위험한 사용법을 찾지 못하면 라이브러리를 지워도 된다고 판단했었습니다.

예를 들어 framer-motion에서 layoutId, drag, motion value 같은 패턴을 찾지 못하면 단순한 등장·퇴장 애니메이션으로 보고 CSS로 바꿀 수 있다고 했습니다.

그런데 framer-motion의 API는 대부분 motion 하나를 import한 뒤 JSX 속성으로 사용합니다.

import { motion } from 'framer-motion'

<motion.div exit={{ opacity: 0 }} />
<motion.div animate={{ x: 100 }} />

두 코드의 import는 같습니다. 몇 개의 정규식에 걸리지 않았다고 첫 번째 사용법이라고 확정할 근거가 없었습니다.

floating-ui도 비슷했습니다. 위험 패턴 네 개가 없다는 이유로 “placement, flipping, offset만 쓴다”고 판정했지만, 실제로는 useInteractions, useDismiss, focus management처럼 CSS가 대신할 수 없는 기능을 import할 수 있었습니다.

여기서 판정 기준을 바꿨습니다.

위험하다는 증거가 없다는 것과 안전하다는 증거가 있다는 것은 다르다.

floating-ui는 CSS가 대체할 수 있는 named import를 명시하고, 실제 import가 전부 그 목록 안에 있을 때만 DROP을 냅니다.

반대로 framer-motion은 import만으로 사용 범위를 증명할 방법이 없어서 이제 DROP을 내지 않습니다. 단순한 등장·퇴장 효과라면 바꿀 수 있다는 CHECK까지만 보여 줍니다.

모든 파일에서 라이브러리 고유 기능을 쓰고 있다면 아무 경고도 하지 않습니다. 애니메이션 라이브러리가 애니메이션다운 일을 하고 있는 것은 문제가 아니기 때문입니다.

일부 파일만 네이티브로 옮길 수 있음 → CHECK와 파일 목록
모든 파일이 라이브러리 고유 기능을 사용함 → 아무것도 출력하지 않음

처음에는 더 많이 찾아 주는 도구가 좋은 도구라고 생각했습니다. 만들고 나서는 사용자가 행동할 수 없는 경고를 하지 않는 것도 정확도의 일부라고 생각하게 됐습니다.


문제 3 — fixture에서는 맞았는데 실제 저장소에서는 틀렸습니다

테스트용 프로젝트에서는 잘 동작했습니다. 그래서 실제 오픈소스 프로젝트 여섯 곳에 실행해 봤습니다.

fixture를 직접 만들 때는 나오지 않던 문제가 3개가 나왔습니다.

모노레포를 거의 검사하지 않았습니다

스캐너는 하위 폴더에서 package.json을 만나면 별도 프로젝트라고 보고 탐색을 멈췄습니다. 예제 폴더를 건너뛰기에는 맞는 동작이었지만 workspace에는 틀렸습니다.

Storybook에서는 수백 개 패키지 중 루트의 파일 두 개만 검사하고 “Nothing to drop”을 출력했습니다.

이제 건너뛴 패키지 수와 이름을 보여 주고 --workspaces 옵션을 제공합니다.

14 nested packages not scanned
— run outgrown inside one, or pass --workspaces

workspace를 하나로 합쳐 판정하지 않고 패키지마다 따로 검사합니다. 패키지별로 browserslist가 다를 수 있어서 합치면 어느 패키지에도 해당하지 않는 결과가 되기 때문입니다.

CommonJS의 반환값을 놓쳤습니다

require("raf").polyfill();

처음 스캐너는 require() 앞부분만 보고 독립된 side-effect import로 판단했습니다. 하지만 뒤의 .polyfill()이 반환값을 사용하고 있습니다.

이제 호출 앞뒤를 모두 보고, 결과가 다른 표현식에 소비되면 자동 삭제 대상에서 제외합니다.

조건부 import를 지울 뻔했습니다

if (!window.fetch) {
  require("whatwg-fetch");
}

브라우저 지원 여부만 보면 지워도 될 것처럼 보입니다. 하지만 코드를 자동으로 고치는 단계에서 조건문 안의 import를 지우는 것은 동작 변경이 될 수 있습니다.

그래서 검색은 가볍게 정규식으로 하되, 실제 삭제 계획은 @babel/parser로 AST를 읽도록 나눴습니다.

AST(Abstract Syntax Tree)는 코드를 글자 나열이 아니라 문법 구조의 트리로 표현한 것입니다. 정규식은 require('whatwg-fetch')라는 글자를 찾을 뿐이지만, AST는 그 호출이 if 문 안에 있다는 것까지 알려 줍니다.

import 'whatwg-fetch'
→ 삭제 가능

if (!window.fetch) require('whatwg-fetch')
→ 조건부 로드, 그대로 둠

import ResizeObserver from 'resize-observer-polyfill'
→ 바인딩 이름이 네이티브 전역과 같으면 삭제 가능

import RO from 'resize-observer-polyfill'
→ RO를 대신할 전역이 없으므로 그대로 둠

outgrown fixpackage.json을 직접 고치거나 패키지 매니저를 실행하지 않습니다. 먼저 diff를 보여 주고, --write를 줘야 파일을 수정하며, 마지막에는 사용자가 실행할 npm uninstall 명령만 출력합니다.

판정은 사람이 한 번 더 확인하는 단계가 남아 있지만, 자동 수정은 그 단계 없이 파일에 반영됩니다. 되돌릴 여지가 다른 만큼 기준도 다르게 잡아야 했습니다.


라이브러리를 만든다는 것은 결국 경계를 정하는 일이었습니다

기능 자체는 단순해 보였습니다.

  1. 프로젝트에서 의존성 사용을 찾는다.
  2. 대상 브라우저를 구한다.
  3. 네이티브 기능의 지원 버전과 비교한다.
  4. 지울 수 있는 후보를 출력한다.

그런데 실제 작업의 대부분은 그 사이의 경계를 정하는 일이었습니다.

  • 브라우저 데이터가 없을 때 미지원이라고 할지, 모른다고 할지
  • 소스에서 위험한 패턴을 못 찾았을 때 안전하다고 할지
  • 수천 개 패키지 중 확인한 범위를 어떻게 밝힐지
  • 자동 수정이 어디까지 손대도 되는지
  • 빈 결과가 정말 깨끗하다는 뜻인지, 아무것도 못 봤다는 뜻인지

이런 결정을 하고 나니 라이브러리는 코드 묶음이라기보다 다른 사람의 프로젝트에 대해 어디까지 말할 책임을 질 것인지 정한 결과물에 가까웠습니다.

최종적으로 DROP, CHECK, NOT YET 세 단계로 나눈 것도 같은 이유입니다.

판정의미
DROP모든 대상 브라우저가 지원하고, 현재 사용 방식도 대체 가능하다는 근거가 있음
CHECK브라우저는 지원하지만 사용 방식은 사람이 확인해야 함
NOT YET대상 브라우저 중 아직 지원하지 않는 버전이 있음

그리고 모든 결과 끝에 이 문장을 남겼습니다.

outgrown advises, it does not decide — read the diff and run your tests

만들어 본 것으로 충분했습니다.

결국 누가 이 라이브러리를 쓸지는 솔직히 모르겠습니다. 이미 비슷한 문제를 각자의 방식으로 해결하고 있을 수도 있고, 오래된 프로젝트에서 의존성 몇 개를 정리할 때 한 번 실행하고 끝낼 수도 있습니다.

그래도 처음 직접 만든 라이브러리로 다음 과정을 전부 지나가 봤습니다.

  • 아이디어를 작은 CLI의 입력과 출력으로 바꾸기
  • 외부 데이터를 그대로 믿지 않고 판정 규칙 만들기
  • 공개된 실제 저장소에 실행해서 fixture 밖의 문제 찾기
  • 자동 수정이 실패해도 안전한 쪽으로 제한하기
  • 지원 범위와 모르는 범위를 문서에 함께 적기
  • 테스트하고 npm 패키지로 배포하기

현재 테스트는 127개입니다. 테스트 수가 라이브러리의 가치를 증명하지는 않지만, 처음 생각했던 것보다 예외가 훨씬 많았다는 기록은 됩니다.

오픈소스 이슈를 해결할 때는 이미 있는 설계 안에서 문제 하나를 고쳤고, 직접 라이브러리를 만들어 보니 문제를 고치는 것보다 먼저 무엇을 문제라고 부를지, 그리고 어디서부터는 모른다고 말할지를 정해야 했습니다.

목적은 충분히 달성했고, 누군가의 레거시 프로젝트에서 의존성 하나라도 정리하는 데 참고가 된다면 정말 나이스죵

검색어를 입력해 보세요.