
Baseline을 활용하여 불필요한 JavaScript를 줄이는 방법
요약
본 기사는 웹 개발 시 불필요하게 포함된 JavaScript 의존성을 줄이는 방법을 다룹니다. 브라우저 자체 기능이 발전함에 따라 많은 라이브러리가 더 이상 필요 없게 되었으며, 이를 재감사하는 것이 중요합니다. Baseline 프로젝트를 활용하여 어떤 기능을 제거할 수 있는지 체계적으로 검토하고 반복 가능한 프로세스를 구축하는 방법을 제시합니다.
핵심 포인트
- 웹 플랫폼의 발전으로 많은 JS 의존성이 브라우저 자체 기능으로 대체되고 있습니다.
- Baseline은 웹 기능이 주요 브라우저에서 얼마나 안전하게 사용 가능한지 평가하는 프로젝트입니다.
- 기능별 가용성(Newly/Widely)에 따라 라이브러리 교체 시 주의 깊은 검토가 필요합니다.
- 의존성을 그룹별로 재감사하여 불필요한 코드를 줄이는 프로세스를 구축할 수 있습니다.
우리 대부분은 의존성(dependency)을 한 번 설치하고 다시는 살펴보지 않습니다. 그것은 제 역할을 하고, 테스트도 통과하며, 우리는 다음 단계로 넘어갑니다. 하지만 웹 플랫폼 역시 계속 움직이고 있으며, 오늘날 여러분의 package.json에 있는 라이브러리 중 놀라울 정도로 많은 수가 이제 브라우저 자체에 내장되고 있습니다.
일반적인 규모의 JavaScript 애플리케이션에서는, 플랫폼이 스스로 처리할 수 있게 된 의존성 코드가 60KB에서 90KB(최소화 및 gzipped 기준) 사이인 경우가 많습니다. 날짜 및 숫자 형식 지정, HTTP 요청, 모달, 툴팁, 깊은 복제(deep cloning), 배열 그룹화 등이 있었습니다. 이 모든 것이 몇 년 전만 해도 실제적인 공백이었습니다. 하지만 이제는 더 이상 공백이 아닌 것들이 많습니다.
그런 라이브러리들이 남아있는 이유는 게으름 때문이 아닙니다. 대부분의 팀이 Baseline 주기로 의존성을 재감사(re-audit)하지 않거나, 브라우저가 얼마나 빠르게 기능을 제공하는지 단순히 알지 못하기 때문입니다. 여러분은 보안을 위해 npm audit를 확인하지만, '이 라이브러리가 여전히 브라우저가 할 수 없는 무언가를 하고 있는가?'라는 질문은 거의 던져지지 않습니다. 그래서 라이브러리들이 남아있게 되는 것입니다.
본 기사에서는 함께 그 감사를 진행해 보겠습니다. 의존성을 하나하나 검토하는 대신, 그룹별로 작업할 것이기 때문에 더 많은 이점을 얻을 수 있습니다. 우리는 번들 계산(bundle math)을 하고 재사용 가능한 작은 의사결정 프레임워크를 구축하며, 플랫폼이 여전히 미흡한 경우에 대해서는 솔직하게 논의할 것입니다. 끝날 무렵에는 여러분 자신의 package.json에서 실행할 수 있는 반복 가능한 프로세스를 갖게 될 것입니다.

'Baseline'이 실제로 의미하는 것
무언가를 삭제하기 전에, Baseline이 무엇인지 간단히 요약해 보겠습니다. 이미 익숙하다면 이 섹션은 건너뛰셔도 됩니다.
Baseline은 WebDX Community Group의 프로젝트로, 웹 기능이 주요 브라우저(Chrome, Edge, Firefox, Safari) 전반에 걸쳐 사용하기에 얼마나 안전한지 평이하게 알려줍니다. 특정 기능은 세 가지 상태 중 하나일 수 있습니다:
- 제한적 사용 가능 (Limited availability)
이 기능은 아직 모든 주요 엔진에 배포되지 않았습니다. 폴백(fallback) 없이 의존하기에는 안전하지 않습니다. - 기준선 신규 사용 가능 (Baseline Newly available)
이 기능은 방금 모든 주요 엔진에 도입되었습니다. 최신 브라우저를 사용하는 사용자에게는 작동하지만, 실제 환경의 구형 장치에서는 아직 지원되지 않을 수 있습니다. - 기준선 광범위하게 사용 가능 (Baseline Widely available)
이 기능은 30개월 동안 모든 주요 엔진에 존재했습니다. 이 시점에서는 크게 고민할 필요 없이 사용할 수 있습니다.
'신규(Newly)'와 '광범위(Widely)' 사이의 30개월 차이는 이번 감사에서 매우 중요합니다. 광범위하게 사용 가능한 기능은 오늘 당장 라이브러리를 교체해도 되는 것입니다. 반면, 신규로만 사용 가능한 기능은 청중을 먼저 확인하거나 작은 기능 검사에 자신이 있는 경우에만 라이브러리를 교체할 수 있습니다. 저희는 이 두 가지 경우를 전체적으로 다르게 취급할 것입니다.
어떤 기능이든 webstatus.dev, MDN (모든 참조 페이지 상단 근처에 Baseline 배지가 표시됨), 또는 web-features npm 패키지를 통해 프로그래밍 방식으로 확인할 수 있습니다. 나중에 실제 프로젝트로 감사를 실행할 때 이 세 가지 방법을 모두 사용할 것입니다.
무언가를 삭제하기 전 결정 프레임워크 (A Decision Framework Before You Delete Anything)
'브라우저가 지금 이렇게 한다'라는 문구를 읽고 바로 라이브러리를 제거하고 싶은 유혹을 느낄 수 있습니다. 그렇게 해서는 안 됩니다. 종이 위에서는 무료로 보이는 교체 작업이라도 사용자의 일부에게 조용히 문제를 일으키거나, 인지하지 못했던 의존 기능에 비용을 발생시킬 수 있습니다.
따라서 어떤 라이브러리를 제거하기 전에는 세 가지 질문을 던져야 합니다. 이 질문들은 아래 모든 클러스터에서 재사용될 것입니다.
1. 교체된 것이 나의 청중에게 기준선(Baseline)-안전한가?
추상화(abstract)에서 ‘Baseline이냐’가 중요한 것이 아니라, ‘실제로 내 앱을 사용하는 사람들에게 안전하냐’가 중요합니다. 네이티브 기능이 널리 사용 가능하다면(Widely available), 보통은 그렇습니다. 만약 새로 사용할 수 있게 된 경우(Newly available)라면, 분석 도구(analytics)나 browserslist 설정을 확인하여 사용자 중 얼마나 많은 비율이 이 기능을 놓치게 될지 확인해야 합니다. 모든 사람이 최신 브라우저를 사용하는 B2B 대시보드와 오래된 Android 기기가 긴 꼬리(long tail)로 존재하는 공개 웹사이트는 매우 다른 상황입니다.
2. 스왑이 실제로 비용을 얼마나 발생시키는가?
라이브러리를 제거하는 것이 항상 공짜는 아닙니다. 때로는 네이티브 기능이 아직 충분히 광범위하게 지원되지 않아, 폴리필(polyfill)을 사용해야 할 수도 있습니다. 만약 그 폴리필의 크기가 제거하려는 라이브러리보다 더 무겁다면, 조건부로 로드하지 않는 한 번들(bundle) 크기를 오히려 키우게 됩니다. 이는 나중에 Temporal에서 정확히 보게 될 것입니다.
3. 플랫폼 기능이 나의 실제 사용 사례를 커버하는가?
라이브러리는 종종 자신이 닮은 플랫폼 기능보다 더 많은 것을 수행합니다. axios는 단순히 자동 JSON 파싱을 하는 fetch 이상의 기능을 가지고 있습니다. 인터셉터(interceptors), 요청 취소(request cancellation), 재시도(retries) 등이 그것입니다. 만약 이러한 기능들을 사용하고 있다면, 무작정 fetch로 교체하는 것은 이 기능들을 다시 구현해야 함을 의미합니다. 대체품이 될 것이라고 가정하기 전에 실제로 무엇을 사용하는지 확인하세요.
이 세 가지를 염두에 두세요. 아래의 모든 클러스터는 결국 이러한 질문들이 여러분의 의존성(dependencies)의 다른 부분에 적용된 것일 뿐입니다.
클러스터 1: 국제화(Internationalization) (오늘 가장 큰 절감 효과가 기대되는 영역)
이곳은 보통 이미 광범위하게 사용 가능한 기능 위에 쌓여 있는 KB를 가장 많이 찾을 수 있는 클러스터입니다. 브라우저는 Intl 네임스페이스 아래에 전체 포맷팅 도구 패밀리를 내장하고 있으며, 이로 인해 작고 인기 있던 많은 라이브러리가 불필요해졌습니다.
일반적으로 의심되는 것들과 그것을 대체하는 것은 다음과 같습니다:
timeago.js(1 KB gz) →Intl.RelativeTimeFormatpluralize(2.3 KB gz) →Intl.PluralRulesnumeral(3.9 KB gz) →Intl.NumberFormathumanize-duration(6.6 KB gz) →Intl.DurationFormat- 리스트 결합 도우미(list-joining helpers) →
Intl.ListFormat
몇 가지를 살펴보겠습니다.
상대 시간 (Relative Time)
timeago.js는 타임스탬프를 “3시간 전”과 같은 형태로 변환하는 역할을 합니다. Intl.RelativeTimeFormat도 동일한 기능을 수행하며, 이는 Baseline에서 광범위하게 사용할 수 있습니다.
const rtf = new Intl.RelativeTimeFormat("en", { numeric: "auto" });
rtf.format(-1, "day"); // "yesterday"
...
여기서 numeric: "auto" 옵션이 유용한 부분입니다. 이 옵션을 사용하면 언어에 해당 단어가 있는 경우 “1 day ago” 대신 “yesterday”와 같은 형태로 표시해 줍니다. 숫자와 단위를 전달하면 현지화된 문자열을 반환받게 됩니다.
혹시 timeago.js가 수행하지만 위 코드 스니펫에는 없는 기능이 무엇인지 궁금할 수 있습니다. 바로 단위(unit)를 자동으로 선택한다는 점입니다. 날짜가 주어지면, timeago.js는 “초” 또는 “일” 중 어떤 단위를 사용할지 결정합니다. Intl.RelativeTimeFormat은 개발자가 이 부분을 처리해 줄 것을 기대합니다. 이는 몇 줄의 산술 연산(차이를 계산하고 적합한 가장 큰 단위 찾기)으로 해결할 수 있으며, 한 번 이러한 헬퍼 함수를 작성하면 더 이상 라이브러리가 필요하지 않습니다.
숫자, 통화 및 목록 (Numbers, Currency, And Lists)
Intl.NumberFormat은 대부분의 숫자 포맷팅 라이브러리가 수행하는 기능을 포함합니다: 천 단위 구분 기호, 통화 표시, 백분율, 그리고 간결한 표기법입니다.
new Intl.NumberFormat("en-US").format(1234567.89);
// "1,234,567.89"
...
그리고 광범위하게 사용 가능한 Intl.ListFormat은 쉼표를 포함하여 배열을 문장으로 연결하는 문제를 처리합니다. 여기에는 사람들이 까다로운 헬퍼 함수를 만드는 종류의 오옥스퍼드 콤마(Oxford comma)도 포함됩니다:
const lf = new Intl.ListFormat("en", { style: "long", type: "conjunction" });
lf.format(["Alice", "Bob", "Carol"]);
...
유일한 주의점: 지속 시간 (Durations)
humanize-duration은 밀리초 단위의 숫자를 “1시간 30분”과 같은 형태로 변환합니다. 플랫폼에서 이에 상응하는 것은 Intl.DurationFormat입니다:
const df = new Intl.DurationFormat("en", { style: "long" });
df.format({ hours: 1, minutes: 30 });
...
한 가지 염두에 두어야 할 점은 Intl.DurationFormat이 작성 시점에는 Baseline으로만 사용 가능하고 Widely available하지 않다는 것입니다. 이 기능은 2025년 3월에 모든 주요 엔진에 탑재되었으며, 2027년에 Widely available해질 예정입니다. 따라서 광범위한 사용자 대상 앱의 경우 트래픽을 먼저 확인하거나 폴백(fallback)을 추가하지 않으면 질문 1에서 실패합니다. 최신 브라우저를 사용하는 내부 도구라면 오늘 사용해도 괜찮습니다. 하지만 구형 장치를 사용하는 공개 사이트라면 1년 정도 더 기다리거나 기능 검사(feature check)로 보호해야 합니다.
이 클러스터의 수학적 계산
만약 앱이 전체 세트(humanize-duration, timeago.js, pluralize, numeral)를 사용한다면, 이는 압축된 상태에서 약 14 KB에 달하는 종속성(dependencies)을 차지하며, 이 중 대부분은 현재 Widely available한 API로 대체할 수 있습니다. 국제화 클러스터는 보통 전체 감사 과정에서 가장 쉽게 개선할 수 있는 부분입니다.
클러스터 2: HTTP 클라이언트
이 클러스터는 좀 더 미묘하므로 신중하게 접근하는 것이 좋습니다.
사람들이 사용하는 브라우저 HTTP 라이브러리는 axios (17 KB 압축)와 superagent (19 KB 압축)입니다. 대부분의 요청에 대해서는 fetch와 AbortController만으로 필요한 기능을 모두 처리할 수 있으며, 이 두 기능 모두 Widely available합니다.
기본 GET 요청은 다음과 같습니다:
// axios
const { data } = await axios.get("/api/users");
...
추가된 한 줄(res.json())은 axios에서는 암시적(implicit)이었던 것을 fetch가 명시적으로 처리하는 부분입니다. 이것이 이 전체 클러스터의 패턴입니다: fetch는 기본적으로 적게 처리해주기 때문에, 어떤 기능을 제외할지 개발자가 결정해야 합니다.
시간 초과 (Timeouts)
axios에는 timeout 옵션이 있습니다. fetch에는 AbortSignal.timeout()이 있습니다:
const res = await fetch("/api/users", {
signal: AbortSignal.timeout(5000), // 5초 후에 중단 (abort)
});
fetch가 axios를 대체할 수 없는 경우
여기가 질문 3이 가장 많은 작업을 수행해야 하는 부분이니, 격차(gap)에 대해 구체적으로 살펴보겠습니다:
fetch는 HTTP 오류 발생 시 reject하지 않습니다. 404나 500은 rejection이 아니라 resolved promise입니다. 따라서res.ok를 직접 확인해야 합니다. 반면,axios는 2xx가 아닌 모든 상태 코드에서 reject합니다.- 인터셉터(interceptors)가 없습니다. 만약 인증 토큰을 첨부하거나 401 에러를 한 곳에서 처리하기 위해
axios인터셉터에 의존한다면,fetch에는 이에 상응하는 기능이 없습니다. 동일한 동작을 얻으려면fetch를 자체 함수나 클래스로 감싸야 합니다. - 자동 재시도(automatic retries)가 없습니다.
axios는 (플러그인과 함께 사용하면) 실패한 요청을 재시도할 수 있습니다. 하지만fetch에서는 이를 직접 코드로 작성해야 합니다. - 업로드 진행률 보고 기능이 없습니다.
fetch는 여전히 업로드 진행률을 1급(first-class) 방식으로 보고할 수 없습니다. 만약 진행 표시줄이 있는 파일 업로더를 사용한다면, 이는 라이브러리를 유지해야 할 확실한 이유가 됩니다.
개인적으로 저는 Learn JavaScript와 같은 대화형 온라인 강좌에서 인터셉터(interceptors)에 크게 의존하며, 수년 동안 fetch 위에 사용자 정의 class를 사용하여 이 문제를 해결해 왔습니다. 이를 수백만 명의 사용자에게 배포했으며 많은 성공을 거두었습니다.
이러한 기능들 중 어느 것도 재구축하기 어렵지 않으며, 대부분의 앱은 그중 하나나 두 개만을 사용합니다. 하지만 여기는 맹목적으로 찾아서 바꾸기(blind find-and-replace)를 해서는 안 되는 전형적인 클러스터입니다. 실제로 HTTP 클라이언트를 어떻게 사용하는지 먼저 살펴보세요. 만약 단순한 GET과 POST 요청만 한다면, axios를 가벼운 fetch 래퍼로 대체하는 것만으로도 약 **17 KB (gzipped)**를 절약할 수 있습니다.
클러스터 3: UI 기본 요소(UI Primitives)
이 클러스터는 가장 만족스러운 교체 사례들을 가지고 있는데, 그 이유는 플랫폼 기능들이 라이브러리와 단순히 일치하는 것을 넘어 팀이 직접 구현하는 것보다 더 접근성이 높기 때문입니다.
여기서 다루는 라이브러리들은 모달 대화 상자(예: a11y-dialog, 1.8 KB gz), 툴팁 및 팝오버 라이브러리(tippy.js, 14 KB gz, 위치 지정용 Popper를 번들링함), focus-trap (6.6 KB gz), 그리고 body-scroll-lock (1.3 KB gz)입니다. 이들은 세 가지 플랫폼 기능으로 대체됩니다: <dialog> 요소, Popover API, 그리고 CSS 앵커 포지셔닝(CSS anchor positioning).
<dialog> 요소
접근성 문제를 해결하기 위해 모달(modal) 관련 코드가 엄청나게 많이 존재합니다: 모달 내부에서 포커스를 가두는 것, Escape 키로 닫기, 다이얼로그가 닫힐 때 이전 요소에 포커스를 복원하는 것, 그리고 모든 요소 위에 렌더링되는 것입니다. <dialog> 요소는 널리 사용 가능하며 이 모든 것을 자동으로 처리해 줍니다.
<dialog id="confirm">
<form method="dialog">
<p>이 파일을 삭제하시겠습니까?</p>
...
showModal()을 호출하는 것은 focus-trap이 설치되었던 작업을 수행합니다: 포커스가 다이얼로그 안으로 이동하고, 페이지의 나머지 부분은 비활성화(inert)되어 더 이상 탭 아웃할 수 없게 되며, Escape 키로 닫히고, 포커스는 이를 열었던 요소로 돌아갑니다. 이 다이얼로그는 브라우저의 최상위 레이어(Top layer)에 렌더링되므로 z-index와 싸울 필요가 없습니다. 또한 오버레이를 스타일링할 수 있는 ::backdrop 가상 요소를 얻을 수도 있습니다.
이 단일 요소만으로 모달 라이브러리와 focus-trap을 대체할 수 있습니다. 이 요소가 스스로 처리하지 못하는 유일한 부분은 배경의 스크롤 잠금(locking the background from scrolling)인데, 이것이 바로 body-scroll-lock이 담당하던 기능입니다. 이제는 CSS 한 줄로 충분합니다:
body:has(dialog:modal) {
overflow: hidden;
}
왜 dialog[open] 대신 dialog:modal을 사용하는지 궁금할 수 있습니다. 그 이유는 open 속성은 show()를 호출하는 즉시 설정되지만, 다이얼로그가 실제로 모달(modal) 상태는 아니므로 아직 스크롤 잠금을 걸고 싶지 않기 때문입니다. :modal 가상 클래스는 다이얼로그가 실제로 모달일 때만 참(true)이며, 이는 showModal()을 호출했을 때의 경우입니다.
따라서 세 개의 라이브러리가 하나의 요소와 하나의 CSS 규칙으로 통합됩니다.
Popover API 및 Anchor Positioning
AI 자동 생성 콘텐츠
본 콘텐츠는 Smashing Magazine의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기