서비스를 오픈한 후 구버전 브라우저를 사용하는 유저에게서 오류가 발생하는 상황을 경험해보셨나요?
웹뷰 브라우저 기반 서비스에서 최신 JavaScript 문법을 사용하게 되면, 구버전 브라우저에서는 해당 기능을 지원하지 않아 오류가 발생할 수 있어요. 이런 문제를 해결하는 방법 중 하나가 바로 Polyfill입니다.
Polyfill이란?
Polyfill은 구버전 브라우저에서 지원하지 않는 최신 JavaScript 기능을 구현해주는 코드예요.
Polyfill이라는 단어 자체가 '충전재'라는 뜻인데, 브라우저 간의 기능 격차를 메워준다고 이해하면 좋아요.
예를 들어 String.prototype.replaceAll() 메서드가 브라우저에서 지원되지 않으면, 이 기능을 직접 구현한 코드를 추가해서 모든 브라우저에서 동일하게 작동하도록 만들 수 있습니다.
Shim이란?
Shim은 Polyfill보다 넓은 개념이에요.
기존 API의 동작을 수정하거나 보완하는 코드인데, 단순히 없는 기능을 추가하는 것뿐만 아니라 기존 기능의 버그를 수정하고 일관성 없는 동작을 통일하는 역할도 합니다.
Polyfill 적용 방법
Polyfill을 적용하는 방법은 여러 가지가 있어요. 각각의 장단점을 살펴보겠습니다.
1. Core-JS 사용하기
Core-JS는 가장 널리 사용되는 polyfill 라이브러리예요.
// src/polyfills.js
import 'core-js/stable/string/replace-all';
위처럼 replaceAll과 같은 개별 polyfill만 import하면 해당 polyfill만 번들에 포함됩니다.
2. Vite Legacy 플러그인 사용하기
Vite의 @vitejs/plugin-legacy를 사용하면 빌드 시점에 자동으로 polyfill이 적용돼요. 이 플러그인도 내부적으로 Core-JS를 기반으로 동작합니다.
// vite.config.js
import { defineConfig } from 'vite';
import legacy from '@vitejs/plugin-legacy';
export default defineConfig({
plugins: [
legacy({
targets: ['defaults', 'not IE 11']
})
]
});
필요한 polyfill만 명시적으로 지정할 수도 있어요.
// vite.config.js
export default defineConfig({
plugins: [
legacy({
targets: ['defaults', 'not IE 11'],
// 레거시 브라우저용 polyfill 지정
polyfills: ['es.string.replace-all', 'es.promise.finally'],
// 모던 브라우저용 polyfill 지정
modernPolyfills: ['es.string.replace-all']
})
]
});
기본적으로 legacy 플러그인은 polyfills를 명시하지 않으면 사용량을 기반으로 자동 감지를 해요.
// vite.config.js
export default defineConfig({
plugins: [
legacy({
targets: ['last 2 versions', 'not dead', '> 0.3%'],
// polyfills를 명시하지 않으면 사용량 기반으로 자동 감지
})
]
});
위처럼 targets를 지정하면 @babel/preset-env의 useBuiltIns: 'usage'를 사용해서 실제 번들에서 사용되는 기능과 target 브라우저 범위에 따라 polyfill chunk를 생성합니다.
다만 Vite legacy 플러그인에서 modernPolyfills가 targets 설정을 제대로 반영하지 못하는 이슈가 종종 발생하는 것 같아요.
만약 매우 안전하게 최신 브라우저에서도 polyfill을 포함하고 싶다면 modernPolyfills를 true로 설정할 수 있지만, 공식 문서에서는 권장하지 않아요.
If modernTargets is not set, it is not recommended to use the true value (which uses auto-detection) because core-js@3 is very aggressive in polyfill inclusions due to all the bleeding edge features it supports. Even when targeting native ESM support, it injects 15kb of polyfills!
번역: modernTargets가 설정되지 않은 경우, true 값 사용은 권장하지 않습니다. core-js@3가 지원하는 모든 최신 기능들로 인해 매우 공격적으로 polyfill을 포함하기 때문입니다. 네이티브 ESM을 지원하는 브라우저를 대상으로 하더라도 15kb의 polyfill이 주입됩니다!
출처: https://www.npmjs.com/package/@vitejs/plugin-legacy
3. 직접 구현해서 Import 하기
// src/utils/polyfills.js
export function applyPolyfills() {
// String.prototype.replaceAll 폴리필
if (!String.prototype.replaceAll) {
String.prototype.replaceAll = function(search, replace) {
if (search instanceof RegExp) {
if (!search.global) {
throw new TypeError('String.prototype.replaceAll called with a non-global RegExp argument');
}
return this.replace(search, replace);
}
if (typeof search !== 'string') {
search = String(search);
}
if (typeof replace !== 'string' && typeof replace !== 'function') {
replace = String(replace);
}
let result = this;
let index = 0;
while ((index = result.indexOf(search, index)) !== -1) {
result = result.slice(0, index) + replace + result.slice(index + search.length);
index += replace.length;
}
return result;
};
}
// String.prototype.at 폴리필
if (!String.prototype.at) {
String.prototype.at = function(index) {
const length = this.length;
const relativeIndex = index >= 0 ? index : length + index;
return (relativeIndex >= 0 && relativeIndex < length) ? this[relativeIndex] : undefined;
};
}
}
// src/main.tsx
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
import { applyPolyfills } from './utils/polyfills';
// 앱 시작 전에 폴리필 적용
applyPolyfills();
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
</React.StrictMode>
);
TypeScript를 사용한다면 타입 선언을 확장해주어야 해요.
TypeScript는 런타임에 추가된 메서드를 알지 못하기 때문에 컴파일 시점에 "이런 메서드가 없다"는 에러를 발생시킵니다.
declare global을 통해 기존 인터페이스를 확장하여 TypeScript에게 이러한 메서드가 존재한다는 것을 알려줘야 해요.
// src/utils/polyfills.ts
// 타입 확장 추가
declare global {
interface String {
replaceAll(search: string | RegExp, replace: string | ((substring: string, ...args: any[]) => string)): string;
at(index: number): string | undefined;
}
}
export function applyPolyfills() {
// String.prototype.replaceAll 폴리필
if (!String.prototype.replaceAll) {
// ... 구현 코드
}
// String.prototype.at 폴리필
if (!String.prototype.at) {
// ... 구현 코드
}
}
4. 개별 NPM 패키지 사용하기
string.prototype.replaceall과 같은 개별 패키지를 설치하여 사용하는 방법이에요.
조건부로 사용할 수 있어요.
// src/main.js
// 해당 기능이 없을 때만 polyfill 로드
if (!String.prototype.replaceAll) {
const replaceAll = await import('string.prototype.replaceall');
replaceAll.default.shim();
}
더 간단하게는 아래처럼 사용할 수 있어요.
// src/main.js
import 'string.prototype.replaceall/auto';
처음에 /auto가 무슨 의미인지 궁금해서 NPM 패키지의 auto.js를 직접 살펴봤어요.
// string.prototype.replaceall/auto.js
'use strict';
require('./shim')();
위 내용이 전부예요. 일단 import는 하되, 무조건 shim()을 실행하네요.
하지만 shim() 함수 내부에서 조건부 검사가 이루어져요. shim.js 파일을 살펴보면...
// shim.js
var define = require('define-properties');
var getPolyfill = require('./polyfill');
module.exports = function shimReplaceAll() {
var polyfill = getPolyfill();
define(
String.prototype,
{ replaceAll: polyfill },
{ replaceAll: function () { return String.prototype.replaceAll !== polyfill; } }
);
return polyfill;
};
getPolyfill()은 실제 polyfill 구현체를 가져오는데, polyfill.js를 보면...
// polyfill.js
'use strict';
var implementation = require('./implementation');
module.exports = function getPolyfill() {
return String.prototype.replaceAll || implementation;
};
네이티브 replaceAll이 있으면 그걸 반환하고, 없으면 구현한 implementation을 반환해요.
다시 shim.js로 돌아가서 define 함수를 주목해보면...
define(
String.prototype,
{ replaceAll: polyfill },
{ replaceAll: function () { return String.prototype.replaceAll !== polyfill; } }
);
define-properties 라이브러리의 define 함수 사용법은 다음과 같아요.
define(객체, 속성, 조건)
- 객체: 속성을 추가할 객체 (String.prototype)
- 속성: 추가할 속성 ({ replaceAll: polyfill })
- 조건: 각 속성을 추가할 조건
결국 이미 기능이 있을 때는 건드리지 않고, 없는 경우에만 polyfill을 추가하게 되는 거예요.
정리: /auto 방식과 조건부 동적 Import 방식 비교
import 'string.prototype.replaceall/auto';
- 항상 패키지가 로드됩니다 → 번들에 포함됨
- 하지만 실제 polyfill 적용은 필요할 때만 됨
if (!String.prototype.replaceAll) {
const replaceAll = await import('string.prototype.replaceall');
replaceAll.default.shim();
}
- 조건부로 패키지가 로드됩니다 → 런타임에 결정됨
구현이 간단하길 원한다면 /auto로 import하는 방식을 선택하면 좋고, 번들 크기 최적화가 정말 중요하고 polyfill이 크다면 조건부 방식을 선택하는 것이 좋겠어요. (지금 string.prototype.replaceall같은 경우에는 크지 않지만요)
결론
처음에는 Core-JS나 Vite Legacy 방식이 번들 크기를 너무 키울까 걱정해서 개별 NPM 패키지 방식을 선택했어요.
하지만 두 방법 모두 필요한 모듈만 선택적으로 로드할 수 있다는 걸 알게 되었고, 확장성을 고려하면 Core-JS를 미리 설치해두는 것도 좋은 선택인 것 같아요. 새로운 polyfill이 필요할 때마다 패키지를 설치하는 것보다 import문만 추가하는 게 더 간편하거든요.
물론 번들 크기나 의존성 관리 측면에서 차이는 있지만, 프로젝트 상황에 맞게 선택하면 될 것 같아요.
ref:
https://www.npmjs.com/package/string.prototype.replaceall?activeTab=code https://www.npmjs.com/package/@vitejs/plugin-legacy