[번역] NPM 패키지 크기 대폭 축소하기

Sonny·2024년 7월 15일
27

Article

목록 보기
24/25
post-thumbnail

원문 : https://dev.to/yoskutik/extremely-reducing-the-size-of-npm-package-3420

어느 날 저는 테스트 커버리지, 멋진 문서, 유효한 버전 및 변경 로그 관리 등 모든 "모범 사례"를 담은 작은 NPM 라이브러리를 만들고 싶었습니다. 심지어 그 라이브러리가 어떤 문제를 해결하고 라이브러리를 어떻게 사용하는지 자세히 설명하는 글도 썼습니다. 당시 제가 라이브러리를 만들 때 특히 관심을 가졌던 작업 중 하나는 다른 프로그래머가 사용할 코드인 NPM 패키지의 출력 크기를 최소화하는 것이었습니다. 그리고 이 글에서는 그 목표를 달성하기 위해 어떤 방법을 적용했는지 설명하고자 합니다.

NPM 패키지 개발자는 다른 개발자에게 제공하는 패키지 크기에 특별한 주의를 기울여야 한다고 생각합니다. 최종 제품의 개발자가 가장 발전한 경량화 도구를 사용하더라도 NPM 패키지 개발자가 도와주지 않으면 출력 번들 크기를 항상 최대로 최적화할 수 없기 때문입니다.

참고로 클라이언트 코드에 번들로 포함될 패키지에 대해서만 이야기하겠습니다. 아래 내용은 백엔드 패키지와는 관련이 없습니다.

1부. 일반적인 조언

저는 이 글을 두 부분으로 나누기로 결정했습니다. 첫 번째 부분에서는 의존성 설정이나 빌드 프로세스와 같은 일반적인 팁에 대해 설명합니다. 그리고 반드시 이러한 팁을 참고해야 합니다.

두 번째 부분에서는 패키지를 더 작게 만드는 코드를 작성하는 방법을 알려드리겠습니다. 일부 팁은 패키지 크기에 큰 영향을 미칠 수 있지만 개발자 경험을 크게 저하시킬 수도 있습니다. 따라서 이러한 기술을 적용하는 것은 여러분의 선택에 달려 있습니다.

서드파티 패키지 임포트

가장 간단하고 이해하기 쉬운 것부터 시작해봅시다. 서드파티 패키지의 코드를 임포트할 때 따라야 할 몇 가지 간단한 규칙이 있습니다.

첫째, 당신과 최종 제품의 개발자 모두가 특정 서드파티 패키지를 사용할 것이 확실한 라이브러리를 만드는 경우 해당 서드파티 패키지를 외부 의존성으로 표시해야 합니다. 예를 들어, 당신이 React 애플리케이션을 위한 UI Kit을 개발하고 있다면, 'react'를 외부 의존성으로 표시해야 합니다.

ℹ️ 롤업에서의 외부 의존성 구성 예시
<pre>
  import { nodeResolve } from '@rollup/plugin-node-resolve';
  import commonjs from '@rollup/plugin-commonjs';

  export default {
    input: 'main.js',

    output: {
      file: 'bundle.js',
      format: 'iife',
      name: 'MyModule'
    },

    plugins: [
      // 활성화 하기위해 nodeResolve와 commonjs가 필요합니다.
      // 모듈을 가져올 수 있습니다.
      nodeResolve(),
      commonjs(),
    ],

    // 여기서는 외부 의존성 목록을 선언합니다.
    external: [
      'react',
    ],
  };
</pre>

외부 라이브러리로 표시된 패키지의 코드는 패키지의 코드에 번들로 포함되지 않습니다.

// 이것이 당신의 코드라고 가정해 보겠습니다.
import find from 'lodash/find';
export const getPositiveNumbers = (arr: unknown[]) => find(arr, it => it > 0);

// 그리고 lodash/find를 외부 의존성으로 표시했다고 하겠습니다.
// 이것이 NPM 패키지 코드가 됩니다.
import e from"lodash/find";let o=o=>e(o,e=>e>0);export{o as getPositiveNumbers};

// 그러나 lodash/find가 외부 의존성이라고 표시되지 않으면 lodash/find의 내용이 코드에 완전히 삽입되므로 패키지가 14KB만큼 커집니다.

두 번째 중요한 팁은 라이브러리에 최소한의 임포트 개수가 있어야 한다는 것입니다. 이에 대한 자세한 내용은 아래에서 확인해보겠습니다.

폴리필

다양한 브라우저와의 호환성을 높이기 위해 폴리필을 임포트할 필요는 없습니다. 필요한 경우, 최종 제품의 개발자가 이 작업을 처리할 것입니다. 당신과 다른 개발자가 모두 폴리필을 붙여넣으면 두 번 적용되고, 종종 경량화 도구는 이러한 중복된 코드를 제거하지 못합니다.

동시에 패키지의 올바른 기능을 위해 일부 폴리필의 존재가 매우 중요한 상황이 있을 수 있습니다. 예를 들어 이전 버전의 데코레이터를 사용하는 경우, 'reflect-medata' 패키지가 필요할 가능성이 높습니다. 하지만 이 경우에도 직접 임포트할 필요는 없으며 패키지 문서에서 이러한 필요성을 지적하는 것이 더 낫습니다. 그러면 최종 제품의 개발자가 직접 처리할 것입니다. 폴리필 임포트에 대한 책임은 항상 개발자가 가져야 합니다.

타입스크립트를 사용하는데 패키지가 폴리필에서 타입이 필요한 경우 어떻게 해야 할까요? 예를 들어 'reflect-metadata'Reflect 객체의 타입을 확장합니다. 패키지를 임포트할 수 없으므로 패키지에서 타입을 가져오는 것도 불가능해 보일 수 있습니다. 하지만 아닙니다. 자바스크립트 코드를 임포트하지 않고도 타입을 가져오는 것이 가능합니다.

이렇게 하려면 확장자가 *.d.ts 인 파일을 생성하여 폴리필을 임포트해야 하며, tsconfig.json 에서 이 파일을 지정하면 됩니다. 짜잔! 프로젝트에 필요한 타입이 표시되었습니다. 그리고 서드파티 패키지의 코드는 없습니다. *.d.ts 확장자를 가진 파일은 원칙적으로 자바스크립트 코드를 생성할 수 없기 때문입니다.

ℹ️ 자바스크립트 코드를 임포트하지 않고 타입을 가져오는 예시

global.d.ts

    import 'reflect-metadata';
  

tsconfig.json

    {
      "compilerOptions": {
        ...
      },
      "include": [
        "src",
        // 생성된 d.ts 파일을 설정하는 것이 중요합니다.
        "global.d.ts"
      ],
    }
  

유틸리티 패키지

유틸리티 패키지의 상황은 조금 더 복잡합니다. 필요한 기능이 이미 구현된 패키지가 있다면, 해당 유틸리티 패키지를 사용하는 것은 꽤 합리적입니다. 그러나 트리 쉐이킹이 어떻게 작동하는지, 임포트한 라이브러리가 어떻게 합쳐지는지, 경량화 도구가 어떻게 작동하는지 모른다면 패키지의 크기가 너무 커져서 큰 어려움을 겪을 수 있습니다.

위의 예시에서 저는 lodash를 사용했고 14KB를 추가로 얻었습니다. 하지만 상황은 더욱 나빠질 수 있습니다. 아래 예시를 살펴보세요. 저는 기능적으로 동일한 임포트 두 개를 작성했습니다. 그러나 첫 번째 경우에는 임포트한 후 패키지 크기가 70KB 증가합니다. 거의 5배나 더 커집니다. 첫 번째 경우에는 전체 라이브러리를 가져 왔고 두 번째 경우에는 특정 파일만 가져 왔기 때문입니다.

import { find } from 'lodash';
import find from 'lodash/find';

이제는 NPM 패키지를 개발할 때 'lodash'를 사용하지 않을 것을 적극 권장합니다. 이 패키지에서 제공하는 대부분의 기능은 이미 자바스크립트에 내장되어 있습니다.

유틸리티 패키지 임포트에 대한 몇 가지 간단한 규칙을 알아두는 것이 중요합니다.

  1. 필요한 기능이 간단한 경우, 가장 쉬운 방법은 임포트하는 것이 아니라 함수를 복사하고 이 글의 두 번째 부분에 설명된 방법을 적용하는 것입니다. 따라서 잠재적인 임포트 오버헤드를 제거할 수 있을 뿐만 아니라 출력 파일의 크기를 더욱 최적화할 수도 있습니다.

  2. 트리 쉐이킹 메커니즘을 사용할 수 있는 라이브러리를 사용해 보세요. 이 메커니즘을 사용하면 실제로 사용되지 않는 임포트한 서드파티 코드를 삭제할 수 있습니다. 간단히 말해, 'import { … } from 'package'라고 작성할 때마다를 뜻합니다. 모든 라이브러리 엔티티(함수, 클래스 등)가 포함된 파일을 참조하는 것이므로 실제로는 하나의 함수만 임포트한 경우에도 이러한 모든 엔티티가 최종 번들에 포함된다는 의미입니다. 하지만 트리 쉐이킹 덕분에 프로덕션 모드의 컴파일 단계에서 사용하지 않은 임포트를 간단히 삭제할 수 있습니다. 이 메커니즘은 패키지가 ESM 형식으로 작성된 경우, 즉 import/exports 구문을 사용하는 경우에만 사용할 수 있습니다.

  3. 패키지가 ESM 형식으로 작성되지 않은 경우, 예시에서와 같이 필요한 코드만 임포트 하세요. Lodash는 친절하게도 함수를 별도의 파일로 분리했습니다. 필요한 파일만 가져올 수 있다면 그렇게 하세요.

  4. Common JS 형식으로 작성되고(require가 사용되는 경우) 하나의 파일로만 구성된 패키지라면 이는 좋지 않은 패키지입니다. 사용하지 마세요. 이 패키지의 개발자는 다른 개발자가 이 패키지를 어떻게 사용할지 생각하지 않았습니다. 첫 번째 요점으로 돌아가서 함수를 직접 작성하거나 복사하세요. 물론, 저희는 필요한 기능 외에도 불필요한 코드가 있는 패키지에 대해서만 이야기하고 있습니다. 전체 라이브러리가 필요하다면 이렇게 고생할 필요가 없습니다.

경량화 도구

경량화 도구는 번들의 크기를 줄이는 데 사용됩니다. 사용하지 않는 코드를 제거하고, 표현식을 단축하는 등의 작업을 수행할 수 있습니다. 현재 몇 가지 인기있는 경량화 도구들이 이미 있으며 계속해서 등장하고 있습니다. 친숙한 자바스크립트로 작성된 Terser와 UglifyJS가 있고 심지어 바벨에도 자체 버전의 경량화 도구가 있으며, 더 현대적이고 Rust로 작성된 SWC와 Go로 작성된 ESBuild, 그리고 기타 덜 알려진 경량화 도구들도 있습니다. 그리고 이 저장소를 살펴보는 것을 추천합니다. 여기에는 다양하고 인기있는 경량화 도구들의 최신 테스트 결과가 포함되어 있습니다.

이러한 테스트들에 대한 간략한 설명은 다음과 같습니다.

  • 각각의 경량화 도구는 다양한 수준의 최적화를 제공할 수 있습니다. 상위 5개 항목의 차이는 평균 1~2%이지만, 전체적으로는 다른 경량화 도구 간의 차이가 10%까지 날 수 있습니다.
  • 경량화 도구들의 속도 차이는 수백 배로 극적으로 다를 수 있습니다. 하지만 이 글에서는 작업 속도에 대해서는 이야기하지 않겠습니다. 지금은 압축 품질만 필요합니다.

경량화 도구마다 프로젝트에 따라 결과가 다를 수 있습니다. 정말로 최소 파일 크기를 달성하고 싶다면 직접 테스트를 실행하여 자신에게 가장 적합한 경량화 도구를 찾을 수 있습니다.

지금 당장의 저는 SWC의 경량화 도구를 사용하는 것을 선호합니다. constlet으로 변환할 수 있고, Terser처럼 기본적으로 중괄호를 추가하지 않으며, 변수를 인라인 처리할 수 있는 등 다양한 기능을 제공합니다.

그건 그렇고, 중괄호 추가에 대해 모르시는 분들을 위해 말하자면, terser 및 일부 다른 도구가 이러한 중괄호를 추가하는 이유는 OptimizeJS 벤치 마크에서 자바스크립트 코드 구문 분석 속도에 영향을 미친다고 나타났기 때문입니다. 하지만 이후 V8 개발자들은 이러한 기법이 해롭다고 설명했습니다. 또한 OptimezeJS의 주요 개발자는 자신의 프로젝트를 중단했습니다. 따라서 사용 중인 도구가 추가 중괄호를 생성하는 것을 발견하면, 이를 제거해보세요.

EcmaScript 버전

EcmaScript 기능은 새 객체를 추가하거나 API를 확장하는 기능과 언어 구문을 변경하는 기능, 이렇게 두 가지 그룹으로 나눌 수 있습니다. 다음은 또 다른 저장소로, 설명과 예시와 함께 연도별 모든 ECMAScript 기능이 편리하게 포함되어 있습니다. ES2017 업데이트를 살펴보면 첫 번째 기능 그룹에는 Object.valuesObject.entries 기능이 포함되고 두 번째 그룹에는 비동기 함수가 포함됩니다.

흥미롭게도, 이러한 기능에 대한 이전 버전 브라우저의 하위 호환성 지원은 그룹마다 다르게 구현됩니다. 첫 번째 그룹의 기능의 경우 폴리필을 추가해야 하며 위에서 언급했듯이 NPM 패키지 개발자는 이 작업을 수행해서는 안 됩니다. 하지만 두 번째 그룹의 기능의 경우 모든 것이 더 복잡해집니다.

이전 브라우저가 async 키워드를 인식하면 어떤 폴리필을 사용하든 그 키워드가 무엇인지 이해하지 못할 것입니다. 따라서 두 번째 기능 그룹은 브라우저가 인식할 수 있는 형태로 컴파일해야 합니다.

const func = async ({ a, b, ...other }) => {
    console.log(a, b, other);
};
ℹ️ ES5로 컴파일된 코드
    "use strict";
    var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
        function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
        return new (P || (P = Promise))(function (resolve, reject) {
            function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
            function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
            function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
            step((generator = generator.apply(thisArg, _arguments || [])).next());
        });
    };
    var __generator = (this && this.__generator) || function (thisArg, body) {
        var _ = { label: 0, sent: function() { if (t[0] & 1) throw t[1]; return t[1]; }, trys: [], ops: [] }, f, y, t, g;
        return g = { next: verb(0), "throw": verb(1), "return": verb(2) }, typeof Symbol === "function" && (g[Symbol.iterator] = function() { return this; }), g;
        function verb(n) { return function (v) { return step([n, v]); }; }
        function step(op) {
            if (f) throw new TypeError("Generator is already executing.");
            while (g && (g = 0, op[0] && (_ = 0)), _) try {
                if (f = 1, y && (t = op[0] & 2 ? y["return"] : op[0] ? y["throw"] || ((t = y["return"]) && t.call(y), 0) : y.next) && !(t = t.call(y, op[1])).done) return t;
                if (y = 0, t) op = [op[0] & 2, t.value];
                switch (op[0]) {
                    case 0: case 1: t = op; break;
                    case 4: _.label++; return { value: op[1], done: false };
                    case 5: _.label++; y = op[1]; op = [0]; continue;
                    case 7: op = _.ops.pop(); _.trys.pop(); continue;
                    default:
                        if (!(t = _.trys, t = t.length > 0 && t[t.length - 1]) && (op[0] === 6 || op[0] === 2)) { _ = 0; continue; }
                        if (op[0] === 3 && (!t || (op[1] > t[0] && op[1] < t[3]))) { _.label = op[1]; break; }
                        if (op[0] === 6 && _.label < t[1]) { _.label = t[1]; t = op; break; }
                        if (t && _.label < t[2]) { _.label = t[2]; _.ops.push(op); break; }
                        if (t[2]) _.ops.pop();
                        _.trys.pop(); continue;
                }
                op = body.call(thisArg, _);
            } catch (e) { op = [6, e]; y = 0; } finally { f = t = 0; }
            if (op[0] & 5) throw op[1]; return { value: op[0] ? op[1] : void 0, done: true };
        }
    };
    var __rest = (this && this.__rest) || function (s, e) {
        var t = {};
        for (var p in s) if (Object.prototype.hasOwnProperty.call(s, p) && e.indexOf(p) < 0)
            t[p] = s[p];
        if (s != null && typeof Object.getOwnPropertySymbols === "function")
            for (var i = 0, p = Object.getOwnPropertySymbols(s); i < p.length; i++) {
                if (e.indexOf(p[i]) < 0 && Object.prototype.propertyIsEnumerable.call(s, p[i]))
                    t[p[i]] = s[p[i]];
            }
        return t;
    };
    var func = function (_a) { return __awaiter(void 0, void 0, void 0, function () {
        var a = _a.a, b = _a.b, other = __rest(_a, ["a", "b"]);
        return __generator(this, function (_b) {
            console.log(a, b, other);
            return [2 /*return*/];
        });
    }); };
  

비동기 함수(__await, __generator)를 생성할 수 있는 가능성과 나머지 연산자(__rest)를 사용할 수 있는 가능성에 대한 구현을 추가했습니다. 또한 컴파일러는 함수 인자에 객체 구조 분해를 사용하는 대신 ES5 호환 구문을 사용할 수 있도록 추가 코드를 작성했습니다.

컴파일된 코드는 엉망인 것처럼 보입니다. 하위 호환성을 제공하기 위해, 컴파일러는 async 키워드가 있기 때문에 __await__generator 함수를 생성하고 rest 매개 변수가 있기 때문에 __rest 함수를 생성했습니다.

NPM 패키지 개발자의 임무는 코드를 어떤 ES 버전으로 컴파일할지 결정하는 것입니다. 비동기 함수를 사용한 다음 ES5용 라이브러리를 컴파일하는 경우 추가 코드를 생성하게 됩니다. 최종 제품의 개발자가 동일한 작업을 수행하면 비동기 함수에 대한 코드가 두 번 추가됩니다. 반대로 ES2017용 라이브러리를 컴파일하면 결국 하위 호환성이 불필요해지며 async/await 기능을 사용하는 최신 구문을 사용하면 됩니다.

"옛날 버전 신봉자"들은 코드를 ES5(또는 심지어 ES3)로 컴파일하는 것이 여전히 가장 논리적이라고 생각할 수 있습니다. 이렇게 하면 최종 제품의 개발자가 ES5가 필요할 때 바벨과 같은 도구를 사용하지 않을 수 있기 때문입니다. 그리고 저는 그들이 틀렸다고 생각합니다. 현재 98%의 브라우저에서 ES6(ES2015)를 지원하며, 이를 사용하지 않는 브라우저에 대한 지원은 종료되었거나 완료될 예정이므로 이제 ES5에서 컴파일을 거부하는 추세는 명확해졌습니다. 동시에 ES6 -> ES5로 컴파일하는 것은 다른 전환에 비해 패키지 크기에 가장 큰 영향을 미칠 수 있는데, 이는 ES6가 언어 구문에 많은 개선을 가져왔기 때문입니다. 또한 ES6 구문은 이 글의 두 번째 부분에서 설명하는 팁에 필요합니다.

또한 오래된 구문을 사용하면 성능에 영향을 미칠 수 있습니다. 이에 대한 상세한 내용은 다른 글에서 다룰 예정입니다.

그렇다면 코드를 ES의 어떤 버전으로 컴파일해야 할까요? 본인 또는 작업 중인 프로젝트에서 직접 사용할 코드를 작성하는 경우, 메인 프로젝트에 명시된 버전을 지정하세요. 호환성을 위해서 다른 개발자들에게는 ES6를 사용하게 하는 것이 좋습니다. 하지만 다른 개발자들에게 패키지를 추가로 컴파일해야 할 수도 있다는 것을 이해할 수 있도록 하기 위해 문서에 사용 중인 ES 버전을 명시하는 것을 잊지 마세요.

하지만 그게 다가 아닙니다. 특정 버전에 대한 코드만 컴파일할 필요는 없습니다. 또한 사용하려는 버전과 비교하여 이후 버전의 ES 구문을 주의해서 사용해야 합니다. 예를 들어 패키지를 ES2015로 컴파일하는 경우, ES2017의 기능(예: 비동기 함수)을 사용해서는 안 됩니다.

ℹ️ 그렇게 무섭진 않아요

ES의 새로운 버전에서 대부분의 기능은 단순히 "문법적 설탕"에 불과합니다. 비동기 함수 대신 Promises를 사용할 수 있고, Object.entries 대신 Object.keys를 사용할 수 있으며, Array.prototype.includes 대신 Array.prototype.find를 사용할 수 있습니다. 그리고 아직 해당 기능의 유사한 것이 없다면 직접 작성할 수도 있습니다.

    // ESNext syntax
    const func = async () => {
      const result = await otherFunc();
      console.log(result);
      return result.data;
    };

    // ES6 syntax
    const func = () => {
      return new Promise(resolve => {
        otherFunc().then(result => {
          console.log(result);
          then(result.data);
        });
      });
    };

    // ==================

    // ESNext syntax
    if ([1, 2, 3].includes(anyConst)) { /* ... */ }

    // ES6 syntax
    if (!![1, 2, 3].find(it => it === anyConst)) { /* ... */ }

    // ==================

    // ESNext syntax
    Object.entries(anyObj).forEach(([key, value]) => { /* ... */ });

    // ES6 syntax
    Object.keys(anyObj).forEach(key => {
      const value = anyObj[key];
      /* ... */
    });
  

주의해서 추가해야 할 점은, 기능 기반의 폴리필을 사용하는 것은 허용되나 구문 기반은 절대 "금지"라는 것입니다.

프로덕션/개발 빌드 환경 분리

짧지만 흥미로운 주제입니다. 만약 개발자만 볼 수 있어야 하는 일부 기능(예: 함수 매개변수 유효성 검사, 콘솔에 오류 출력 등)을 남기고 싶다면, 빌드 환경을 분리하고 이러한 추가 기능을 개발 환경의 빌드에만 남겨두어야 합니다.

React, MobX 또는 Redux Toolkit과 같은 많은 패키지가 이를 지원합니다. 그리고 실제로 구성하는 것도 매우 간단합니다.

ℹ️ 소스 코드
    export const someFunc = (a: number, b: number) => {
      if (__DEV__) {
        if (typeof a !== 'number' || typeof b !== 'number') {
          console.error('Incorrect usage of someFunc');
        }
      }

      console.log(a, b);
      return a + b;
    };
  
rollup.config.js
    import typescript from '@rollup/plugin-typescript';
    import define from 'rollup-plugin-define';

    export default [false, true].map(isDev => ({
      input: 'src/index.tsx',
      output: {
        file: `dist/your-package-name.${isDev ? 'development' : 'production'}.js`,
        preserveModulesRoot: 'src',
        format: 'esm',
      },
      plugins: [
        typescript(),
        define({
          replacements: {
            __DEV__: JSON.stringify(isDev),
          },
        }),
      ],
    }));
  
ℹ️ 개발 버전 출력
    const someFunc = (a, b) => {
        {
            if (typeof a !== 'number' || typeof b !== 'number') {
                console.error('Incorrect usage of someFunc');
            }
        }
        console.log(a, b);
        return a + b;
    };

    export { someFunc };
  
ℹ️ 프로덕션 버전 출력
    const someFunc = (a, b) => {
        console.log(a, b);
        return a + b;
    };

    export { someFunc };
  

이를 공식화해 봅시다. 코드의 컴파일 단계에서 일부 문자 시퀀스(제 경우에는 __DEV__)를 원하는 값(true 또는 false)으로 바꾸는 것으로 충분합니다. 다음으로 조건에 생성된 플래그를 사용해야 합니다. 코드에서 플래그가 대체되면 조건 if (true) { ... } 및 if (false) { ... } 조건이 됩니다. 그리고 if (false) {... } 코드는 호출되지 않으므로 코드가 잘립니다.

두 개의 파일을 가지고 있으면 어떻게든 최종 제품의 개발자의 빌드 환경으로 대체해야 합니다. 그 전에는 메인 패키지 파일에서 NODE_ENV 환경 변수를 참조하는 것으로 충분합니다. 동시에 최종 제품의 개발자는 패키지를 사용할 때 이 변수를 구성할 필요가 없습니다. 예를 들어 Webpack은 자체적으로 이 변수를 구성합니다.

그리고 유효한 파일을 사용하려면 기본 파일에 조건을 추가해야 합니다.

// process.env.NODE_ENV는 최종 프로젝트 측에 설정됩니다.
if (process.env.NODE_ENV === 'production') {
  // 프로덕션 버전 사용
  module.exports = require('./dist/react-vvm.production.js');
} else {
  // 개발 버전 사용
  module.exports = require('./dist/react-vvm.development.js');
}

또한 패키지의 개발 빌드 환경의 코드를 최소화할 필요가 없다고 말할 수 있습니다. "개발"이므로 개발자가 적극적으로 상호 작용할 수 있습니다. 그리고 최소화된 코드와 상호 작용하는 것은 매우 어렵습니다.

2부. 더 나쁜 DX, 하지만 더 좋은 결과

최종 제품의 개발자는 코드를 자신에게 편리하도록 작성할 수 있으므로 기본적으로 코드 베이스가 더 커집니다. 그에 반해 NPM 패키지 개발자는 더 작은 코드베이스를 갖고 있으므로 최종 크기를 줄이기 위해 때때로 덜 "아름다운" 코드를 작성하는 것이 비용 측면에서 더 효율적입니다.

이는 경량화 도구가 마법의 도구가 아니기 때문입니다. 개발자는 도구와 협력해야 합니다. 우리는 특정 방식으로 코드를 작성하여 경량화 도구가 코드를 더 많이 압축할 수 있도록 해야합니다.

반복성 및 재사용성

물론, 이것은 일반적인 관행이지만 패키지 크기에도 영향을 미칩니다. 반복되는 코드는 없어야 합니다.

함수 생성

코드에 부분적으로 또는 전체적으로 반복되는 코드가 있는 경우, 반복되는 기능을 별도의 함수로 분리하세요. 이 항목은 예시가 필요하지 않다고 생각합니다.

객체의 프로퍼티

객체에는 몇 가지 흥미로운 점이 있습니다. 코드에서 object.subObject.field 표현식을 사용하면 경량화 도구는 추가 압축이 안전한지 여부를 알 수 없으므로 이 표현식을 최대 o.subObject.field 형태로만 압축할 수 있습니다. 따라서 객체에서 동일한 필드를 자주 참조하는 경우에는 해당 필드에 대해 별도의 변수를 생성하여 사용하세요.

ℹ️ 최적화 전의 예시
소스 코드
import { SomeClass } from 'some-class';

export const func = () => {
  const obj = new SomeClass();
  console.log(obj.subObject.field1);
  console.log(obj.subObject.field2);
  console.log(obj.subObject.field3);
};
경량화된 코드 (182 바이트)

명확하게 설명하기 위해 줄 바꿈과 들여쓰기를 추가했지만 파일 크기는 이러한 기능 없이 지정됩니다.

import {SomeClass as o} from "some-class";

const e = () => {
    const e = new o;
    console.log(e.subObject.field1), console.log(e.subObject.field2), console.log(e.subObject.field3)
};
export {e as func};
ℹ️ 최적화 후의 예시
소스 코드
import { SomeClass } from 'some-class';

export const func = () => {
  const obj = new SomeClass();
  const sub = obj.subObject;
  console.log(sub.field1);
  console.log(sub.field2);
  console.log(sub.field3);
};
경량화된 코드 (164 바이트)
import {SomeClass as o} from "some-class";

const e = () => {
    const e = (new o).subObject;
    console.log(e.field1), console.log(e.field2), console.log(e.field3)
};
export {e as func};

다음 최적화는 DX를 악화시키므로 극단적이라고 할 수 있습니다. 객체에서 특정 속성이나 메서드를 자주 사용해야 하는 경우 해당 프로퍼티나 메서드의 이름으로 변수를 만들 수 있습니다.

ℹ️ 최적화 전의 예시
소스 코드
import { useEffect, useLayoutEffect, useRef } from 'react';

export const useRenderCounter = () => {
  const refSync = useRef(0);
  const refAsync = useRef(0);

  useLayoutEffect(() => {
    refSync.current++;
  });

  useEffect(() => {
    refAsync.current++;
  });

  console.log(refSync.current, refAsync.current);

  return {
    syncCount: refSync.current,
    asyncCount: refAsync.current,
  };
};
경량화된 코드 (254 바이트)
import {useRef as r, useLayoutEffect as e, useEffect as t} from "react";

const n = () => {
  let n = r(0), u = r(0);
  return e(() => {
    n.current++
  }), t(() => {
    u.current++
  }), console.log(n.current, u.current), {syncCount: n.current, asyncCount: u.current}
};
export {n as useRenderCounter};
ℹ️ 최적화 후의 예시
소스 코드
import { useEffect, useLayoutEffect, useRef } from 'react';

const CURRENT = 'current';

export const useRenderCounter = () => {
  const refSync = useRef<number>(0);
  const refAsync = useRef<number>(0);

  useLayoutEffect(() => {
    refSync[CURRENT]++;
  });

  useEffect(() => {
    refAsync[CURRENT]++;
  });

  console.log(refSync[CURRENT], refAsync[CURRENT]);

  return {
    syncCount: refSync[CURRENT],
    asyncCount: refAsync[CURRENT],
  };
};
경량화된 코드 (234 바이트)
import {useRef as e, useLayoutEffect as r, useEffect as t} from "react";

  const n = "current", o = () => {
    let o = e(0), u = e(0);
    return r(() => {
      o[n]++
    }), t(() => {
      u[n]++
    }), console.log(o[n], u[n]), {syncCount: o[n], asyncCount: u[n]}
  };
  export {o as useRenderCounter};

current 사용 횟수가 많을수록, 이 최적화의 효과는 더 커집니다.

ES6 구문 사용

ES6 구문을 경량화 도구와 함께 사용하면 코드를 단축하는 좋은 방법이 될 수 있습니다.

화살표 함수 사용

압축성 측면에서 화살표 함수는 모든 면에서 기존 기능보다 우수합니다. 두 가지 이유가 있습니다. 첫째, const 또는 let을 통해 연속으로 화살표 함수를 선언할 때 첫 번째를 제외한 모든 후속 const 또는 let이 단축됩니다. 둘째, 화살표 함수는 return 키워드를 사용하지 않고도 값을 반환할 수 있습니다.

ℹ️ 최적화 전의 예시
소스 코드
export function fun1() {
  return 1;
}

export function fun2() {
  console.log(2);
}

export function fun3() {
  console.log(3);
  return 3;
}
경량화된 코드 (126 바이트)
function n() {
    return 1
}

function o() {
    console.log(2)
}

function t() {
    return console.log(3), 3
}

export {n as fun1, o as fun2, t as fun3};
ℹ️ 최적화 후의 예시
소스 코드
export const fun1 = () => 1;

export const fun2 = () => {
  console.log(2);
};

export const fun3 = () => {
  console.log(3);
  return 3;
}
경량화된 코드 (101 바이트)
const o = () => 1, l = () => {
    console.log(2)
}, c = () => (console.log(3), 3);
export {o as fun1, l as fun2, c as fun3};

Object.assign 및 스프레드 연산자

이것은 첫 번째 부분에서 설명한 일반 규칙의 구체적인 사례입니다. ES6에서는 객체에 스프레드 연산자 같은 것이 없었습니다. 따라서 ES6에서 라이브러리를 컴파일하는 경우, 이 연산자 대신 Object.assign을 사용하는 것이 좋습니다.

ℹ️ 최적화 전의 예시
소스 코드
export const fun = (a: Record<string, number>, b = 1) => {
  return { ...a, b };
};
경량화된 코드 (76 바이트)
const s = (s, t = 1) => Object.assign(Object.assign({}, s), {b: t});
export {s as fun};

보시다시피 Object.assign은 두 번 적용될 수 있습니다.

ℹ️ 최적화 후의 예시
소스 코드
export const fun = (a: Record<string, number>, b = 1) => {
  return Object.assign({}, a, { b });
};
경량화된 코드 (61 바이트)
const s = (s, t = 1) => Object.assign({}, s, {b: t});
export {s as fun};

화살표 함수에서 값을 반환해 보세요

"극단적"이라는 카테고리에 속하는 또 다른 최적화 방법입니다. 함수 컴포넌트에 영향을 미치지 않는다면 함수에서 값을 반환하는 것이 좋습니다. 절약 효과는 적지만, 절약할 수 있습니다. 함수에 표현식이 하나만 있는 경우에 효과가 있습니다.

ℹ️ 최적화 전의 예시
소스 코드
document.body.addEventListener('click', () => {
  console.log('click');
});
경량화된 코드 (68 바이트)
document.body.addEventListener("click",()=>{console.log("click")});
ℹ️ 최적화 후의 예시
소스 코드
document.body.addEventListener('click', () => {
  return console.log('click');
});
경량화된 코드 (66 바이트)
document.body.addEventListener("click",()=>console.log("click"));

함수에서 변수 생성 중지

지금 소개할 방법은 "극단적"이라는 카테고리에 속하는 또 다른 최적화 방법입니다. 일반적으로 변수의 수를 줄이려는 시도는 최적화를 위한 일반적인 아이디어로, 인라인 코드 삽입이 가능한 경우 경량화 도구가 변수를 제거합니다. 경량화 도구는 동일한 보안상의 이유로 모든 변수를 독립적으로 제거할 수 없지만, 여러분이 제거를 도와줄 수 있습니다.

라이브러리의 컴파일된 파일을 살펴보세요. 본문에 변수가 있는 함수가 있는 경우 변수를 만드는 대신 코드에서 함수 인자를 사용할 수 있습니다.

ℹ️ 최적화 전의 예시
소스 코드
export const fun = (a: number, b: number) => {
  const c = a + b;
  console.log(c);
  return c;
};
경량화된 코드 (71 바이트)
const o=(o,n)=>{const t=o+n;return console.log(t),t};
export{o as fun};
ℹ️ 최적화 후의 예시
소스 코드
export const fun = (a: number, b: number, c = a + b) => {
  console.log(c);
  return c;
};
경량화된 코드 (58 바이트)
const o = (o, c, e = o + c) => (console.log(e), e);
export {o as fun};

결국 이 방법은 const뿐만 아니라 빌드된 파일에서 return 키워드도 제거하기에 매우 강력한 최적화 방법이라 볼 수 있습니다. 그러나 이러한 최적화는 라이브러리에서 내보내지 않는 비공개 클래스 메서드와 함수에만 적용해야 한다는 점을 명심하세요. 최적화로 인해 라이브러리의 API에 대한 이해가 복잡해져서는 안됩니다.

상수 사용 최소화

다시 말하지만, "극단적인" 조언입니다. 빌드된 코드에서는 원칙적으로 letconst를 최소한의 횟수만 사용해야 합니다. 예를 들어, 이를 위해 모든 상수를 한 곳에서 차례로 선언할 수 있습니다. 동시에 말 그대로 모든 상수를 한 곳에 선언하려고 할 때만 극단적인 조언이 됩니다.

ℹ️ 최적화 전의 예시
소스 코드
export const a = 'A';

export class Class {}

export const b = 'B';
경량화된 코드 (67 바이트)
const s = "A";

class c {}

const o = "B";
export {c as Class, s as a, o as b};
ℹ️ 최적화 후의 예시
소스 코드
export const a = 'A';
export const b = 'B';

export class Class {}
경량화된 코드 (61 바이트)
const s = "A", c = "B";

class o {}

export {o as Class, s as a, c as b};

극단적인 감소를 위한 일반적인 조언

사실, 많은 팁을 생각해 낼 수 있습니다. 이를 기준으로, 저는 단순히 압축된 경량화 파일이 어떻게 보여야 하는지 설명하기로 했습니다. 그리고 만약 출력 파일이 지정된 설명과 일치하지 않는다면, 아직 압축해야 할 여지가 많다고 볼 수 있습니다.

  1. 반복이 없어야 합니다. 반복적인 기능은 함수로 분리하고, 자주 사용하는 객체 필드는 상수로 작성해야 합니다.

  2. 여기에는 자주 반복되는 사용, 중첩된 객체 또는 메서드를 사용하는 것 등이 포함됩니다. 경량화된 파일에는 가능하면 단일 문자 표현식으로만 구성되는 것이 좋습니다.

  3. function, return, const, let 등의 키워드도 최소한으로 유지해야 합니다. const로 선언된 화살표 함수를 사용하고 상수를 연속으로 선언하며 함수 내에서 상수를 선언하는 대신 인자를 사용하는 등의 방법을 권장합니다.

그리고 가장 중요한 점입니다. 다른 모든 최적화가 이미 적용된 후에만, 그리고 패키지의 기능에 영향을 미치지 않는 경우에만 극단적인 축소를 시도하는 것이 합리적입니다. 또한, 다시 한번 강조하지만 최적화는 API(및 타입)에 영향을 미치지 않아야 합니다.

결론

제 예시에서는 최대 몇십 바이트 정도의 크기만 줄었기 때문에 극단적인 압축이 의미가 없는 것처럼 보일 수 있습니다. 하지만 실제로는 대표성을 위해 최소한으로 특별히 만든 사례이며 실제 상황에서는 그 이득이 훨씬 더 클 수 있습니다.

하지만 결국 "극단적인" 조언을 따를지 여부는 여러분에게 달려 있습니다. 저에게는 최소한의 파일 크기를 달성할 수 있을지 도전해보는 것이었습니다. 이 방법이 얼마나 유용한지 궁금하신 분들을 위해 말씀드리자면, 제 라이브러리 크기를 2172바이트에서 1594바이트로 줄이는 데 도움을 주었다고 말씀드릴 수 있습니다. 한편으로는 578바이트에 불과하지만 다른 한편으로는 전체 패킷 용량의 27%에 달하는 양입니다.

관심을 가져 주셔서 감사합니다. 의견이 있으시면 댓글로 공유해주세요. 극단적인 부분을 제외하고 최소한 일반적인 조언으로라도 제 글이 도움이 되었기를 바랍니다. 제 글에서 언급하지 않은 내용이 있을 가능성도 있습니다. 그런 경우 여러분의 제안에 따라 기꺼이 내용을 보충하겠습니다.

profile
FrontEnd Developer

0개의 댓글