Javascript를 최적화하는 13가지 팁

August 27, 2019

이 글은 https://medium.com/@bretcameron/13-tips-to-write-faster-better-optimized-javascript-dc1f9ab063d8를 번역한 글입니다.

10년전 아마존은 100ms의 대기시간마자 1%의 매출로 1년의 비용이 들었다고 밝혔습니다. 1년 로드되는 시간동안 1초가 추가되면 16억달러의 비용이 듭니다. 마찬가지로 Google은 검색페이지 생성 시간이 500밀리 초 더 걸리면, 트래픽이 20% 감소하여 잠재적인 광고 수익의 5분의 1의 손실을 감수해야 했습니다.

아마존이나 구글과 같은 상황을 겪을 사람들이 많지는 않으나, 동일한 원칙은 더 작은 규모의 어플리케이션에도 적용됩니다. 코드가 빠를수록, 사용자 경험이 향상될것입니다. 특히 웹 개발에서의 속도는 경쟁 회사에 우위를 제공하게 되는 중요한 요소가 될 수 있습니다. 빠른 네트워크에서 낭비되는 milliseconds는 느린 네트워크에서 더 증폭될 수 있을 것입니다.

이 포스트에서는 Node.js를 사용하여 서버측 코드를 작성하든, 클라이언트측 Javascript를 사용하든 Javascript를 이용하여 개발하는 코드의 속도를 높일 수 있는 실질적인 13가지 최적화 방법을 살펴보겠습니다.

Do It Less

"가장 빠른 코드는 실행하지 않는 코드입니다."

  1. 불필요한 기능을 제거하기

이미 작성된 코드를 최적화 하는 것은 쉬운 일이나, 가장 큰 성능 향상은 한걸음 물러나서 코드가 먼저 있어야 하는지 스스로 물어보는 것입니다.

최적화를 하기 전, 프로그램이 수행되는 모든 작업을 수행해야 하는지 스스로에게 문의해 보시길 바랍니다. 그 특징, 구성요소 또는 기능이 필요합니까? 그렇지 않은 경우에는 과감히 제거해 보세요, 이 단계는 코드 속도를 향상시키는데 매우 중요하나 쉽게 간과되는 부분입니다.

2) 불필요한 단계 피하기

벤치마크: https://jsperf.com/unnecessary-steps

더 작은 규모에서 함수가 최정 결과를 얻기까지 모든 단계가 필요한 단계입니까? 예를 들어, 최종 결과를 얻기위해 데이터가 불필요한 과정을 지나가고 있나요? 다음 예제에는 지나치게 단순화 되었으나, 더 큰 코드베이스에서는 발견하기 훨씬 어려울 수 있습니다.

'incorrect'.split('').slice(2).join('');  // Array 형태로 변환합니다.
'incorrect'.slice(2);                     // string을 유지합니다.

이 간단한 예에서도 성능의 차이는 극적입니다. 일부 불필요한 코드를 실행하는 것은 코드를 실행하지 않는 것보다 훨씬 느릴 수 있습니다.

Do It Less Often

코드를 제거할 수 없다면, 덜 자주 실행할 수 있는지 확인합니다.

3) 가능한 빨리 loop를 해제합니다.

벤치마크: https://jsperf.com/break-loops/1

loop에서 모든 반복을 완료할 필요가 없는 경우를 찾습니다. 예를 들어 특정 값을 검색하고 해당 값을 찾는 경우, 후속 코드의 반복은 필요가 없습니다. break 문을 사용하여 loop 실행을 중단해야 합니다.

for (let i = 0; i < haystack.length; i++) {
  if (haystack[i] === needle) break;
}

또는, loop의 특정 요소에 대해서만 작업을 수행해야 하는 경우 continue 문을 사용하여 다른 요소에 대한 작업 수행을 건너 뛸 수 잇습니다. continue는 현재 반복에서 명령문의 실행을 종료 후 즉시 다음 명령문으로 이동시킵니다.

for (let i = 0; i < haystack.length; i++) {
  if (!haystack[i] === needle) continue;
  doSomething();
}

label 을 이용하여 중첩된 loop를 벗어날 수 있다는 점도 기억해 주세요. 이를 통해 break 또는 continue 문을 특정 루프와 연결지을 수 있습니다.

loop1: for (let i = 0; i < haystacks.length; i++) {
  loop2: for (let j = 0; j < haystacks[i].length; j++) {
    if (haystacks[i][j] === needle) {
      break loop1;
    }
  }
}

4) 가능하면 한번 사전계산을 합니다.

벤치마크: https://jsperf.com/pre-compute-once-only

function whichSideOfTheForce(name) {
  const light = ['Luke', 'Obi-Wan', 'Yoda']; 
  const dark = ['Vader', 'Palpatine'];
  
  return light.includes(name) ? 'light' : 
    dark.includes(name) ? 'dark' : 'unknown';
};
whichSideOfTheForce('Yoda');   // returns "light"
whichSideOfTheForce('Anakin'); // returns "unknown"

이 코드내에서의 문제점은 whichSideOfTheForce를 호출할 때마다 새 객체를 생성한다는 것입니다. 모든 함수 호출에서 메모리는 불필요하게 light, dark에 할당되게 됩니다.

lightdark 가 정적 (static)인 변수로 한번 선언을 한 이후, whichSideOfTheForce를 호출시에 참조하는 것이 더 나은 솔루션 입니다. 전역 범위에서 변수를 정의하여 이 작업을 수행 할 수 있으나 함수 외부에서 변경이 될 수 있습니다. 더 나은 해결책으로는 클로저(closure) 를 사용하여 함수를 반환한다는 의미로 이해하시면 될 것 같습니다.

function whichSideOfTheForce2(name) {
  const light = ['Luke', 'Obi-Wan', 'Yoda'];
  const dark = ['Vader', 'Palpatine'];
  return name => light.includes(name) ? 'light' :
    dark.includes(name) ? 'dark' : 'unknown';
};

이제, lightdark 는 한번만 인스턴스화 되빈다. 중첩함수도 마찬가지입니다. 다음 예제를 살펴보겠습니다.

function doSomething(arg1, arg2) {
  function doSomethingElse(arg) {
    return process(arg);
  };
  return doSomethingElse(arg1) + doSomethingElse(arg2);
}

doSomething을 실행할때마다 내부의 중첩함수 doSomethingElse가 처음부터 작성됩니다. 이를 수정하기 위해 클로저를 사용하겠습니다. 함수를 반환하게 되면, doSomethingElse는 비공개로 유지되지만 한번만 생성됩니다.

function doSomething(arg1, arg2) {
  function doSomethingElse(arg) {
    return process(arg);
  };
  return (arg1, arg2) => doSomethingElse(arg1) + doSomethingElse(arg2);
}

5. 작업수를 최소화 하기 위한 코드

벤치마크: https://jsperf.com/choosing-the-best-order/1

함수의 동작 순서를 신중하게 생각하면, 코드속도를 향상시킬 수 있습니다. 센트단위로 저장된 다양한 품목 가격이 있고, 이 품목들을 합산하여 결과를 달러로 반환하는 기능이 필요하다고 가정 해 보겠습니다.

const cents = [2305, 4150, 5725, 2544, 1900];

함수는 두가지 작업을 수행해야 합니다. (센트를 달러로 변환, 요소를 합치기) 먼저 달러로 변환하려면 다음과 같은 함수를 사용할 수 있습니다.

function sumCents(array) {
  return '$' + array.map(el => el / 100).reduce((x, y) => x + y);
}

그러나 위의 함수에서는 배열의 모든 항목에 대해 나누기 작업을 수행합니다. 이 내용을 반대로 두면, 한번만 나누면 됩니다.

function sumCents(array) {
  return '$' + array.reduce((x, y) => x + y) / 100;
}

6. Big O 표기법 배우기

Big O 표기법에 대한 학습은 일부 기능들이 다른 기능보다 더 빠르게 실행되고, 메로리를 덜 차지하는 이유를 이해하는 가장 좋은 방법 중 하나일 수 있습니다. 예를 들어, Big O 표기법을 사용하여 Binary Search가 가장 효율적인 검색 알고리즘 중 하나인 이유와 Quicksort가 데이터를 정렬하는 데 가장 성능이 좋은 방법인 이유를 한눈에 알 수 있습니다.

7. Built-in Method 활용

벤치마크: https://jsperf.com/prefer-built-in-methods/1

컴파일 언어 또는 low-level의 언어를 경험한 분들에게는 이 점이 분명해 보일 수 있습니다. 그러나 일반적인 규칙으로 Javascript에 내장 메소드가 있는 경우 이를 사용하는 것을 권장합니다. 컴파일러 코드는 메소드 혹은 객체 유형에 특정된 성능 최적화를 위해 설계되었습니다. 기본언어는 c++ 입니다. 유스케이스가 구체적이지 않은 한, 기존 메소드보다 성능이 뛰어난 javascript 구현 가능성은 매우 낮습니다!

이를 테스트해보기 위해, Array.prototype.map 메소드의 자체 Javascript 구현을 작성해보겠습니다.

function map(arr, func) {
  const mapArr = [];
  for(let i = 0; i < arr.length; i++) {
    const result = func(arr[i], i, arr);
    mapArr.push(result);
  }
  return mapArr;
}

1부터 100까지의 랜덤한 정수를 삽입하는 array를 생성하겠습니다.

const arr = [...Array(100)].map(e=>~~(Math.random()*100));

배열에 각 정수의 2를 곱하는 것과 같이 간단한 작업을 수행하려는 경우에도 성능차이가 나타납니다.

map(arr, el => el * 2);  // 직접 만든 메소드
arr.map(el => el * 2);   // 빌트인 메소드

해당 테스트에서 새로운 javascript map 함수를 사용하는 것은 빌트인 메소드를 사용하는 것보다 약 65% 느렸습니다.

8. 작업에 가장 적합한 Object 사용하기

벤치마크1: [Set 객체를 통한 value 삽입 vs Array.push](https://jsperf.com/adding-to-a-set-vs-pushing-to-an-array)

벤치마크2: [Map 객체에 항목 추가 vs Object내에 직접 삽입](https://jsperf.com/adding-map-vs-adding-object)

마찬가지로, 현재 작업에 가장 적합한 내장 객체를 선택하는 것도 성능을 향상시킬 수 있는 방법 중 하나입니다.

Array.push를 하는 것보다 Set.add 객체를 이용한 추가가, Object내 직접 할당보다 Map.add 를 통한 할당이 성능 향상에 도움이 될 수 있습니다. (그러나 Set, Map 객체의 경우 IE11에서 부터 지원 가능한 항목입니다.)

9. Memory에 대해 신경쓰기

고급 언어인 javascript는 많은 하위 수준의 세부 정보를 처리합니다. 그러한 세부사항 중 하나는 메모리 관리 입니다. javascript는 garbage collector라는 시스템을 사용하여 개발자의 명시적인 지시없이는 더이상 메모리를 확보하지 않습니다.

메모리관리는 javascript에서 자동으로 수행되나, 이것이 완벽하다는 것을 의미하지는 않습니다. 메모리를 관리하고 메모리 누수 가능성을 줄이기 위해 수행할 수 있는 몇가지 추가 단계가 있습니다.

예를들면, Set 또는 Map 의 경우에는 WeakSetWeakMap 이라고 알려진 weak 변형이 있습니다. 이것들은 객체에 대한 약한 참조를 담고 잇습니다. 이들은 참조되지 않은 값들이 garbage collector에 수집되도록 함으로써 메모리 누수를 방지합니다.

ES2017 에 도입된 javascript의 TypedArray 객체를 사용하여 메모리 할당을 보다 효과적으로 제어할 수도 있습니다. 예를들어 Int8Array는 -128에서 127 사이의 값을 사용할 수 있으며 크기는 1byte 입니다. 그러나 TypedArray를 사용하면 성능이 매우 작을 수 있습니다. 정규 배열과 Uint32Array 를 사용하면 쓰기 성능이 약간 향상되나 읽기 성능은 거의 또는 전혀 향상되지 않습니다.

10. 가능한 단형 형태로 개발하기

벤치마크1: 단형 vs 다형

벤치마크2: 1개의 참조변수 vs 2개의 참조변수

const a = 2 로 설정시에 변수 a 는 다형성으로 간주될 수 있습니다. (변경이 가능한 변수) 대조적으로, 2를 직접 사용한다면, 이것은 단형으로 간주가 될 수 있습니다. (값이 고정되기 때문)

물론 변수를 여러번 사용해야 하는 경우 변수 설정이 매우 유용합니다. 그러나 변수를 한번만 사용하면, 다형성의 변수를 갖는 function보다 빠를 수 있습니다. 간단한 곱셈 함수를 살펴보겠습니다.

function multiply(x, y) {
  return x * y;
};

만약, multiply(2, 3) 을 실행한다면, 이는 1% 정도 실행에 빠를 수 있습니다.

let x = 2, y = 3;
multiply(x, y);

만약, 큰 코드베이스에서 이처럼 실행이 된다면 작은 성능의 향상도 크게 효과를 볼 수 있습니다.

마찬가지로 함수에서 인수를 사용하게 되면 성능을 희생하면서 유연성을 제공할 수 있습니다. 다시 말씀드리지만, 인수는 프로그래밍의 필수 부분입니다. 그러나 필요하지 않은 경우에 사용하지 않으면 성능은 향상될 수 있습니다. 따라서, 더 빠른 버전의 곱셈함수는 다음과 같습니다.

function multiplyBy3(x) {
  return x * 3;
}

성능향상은 작을 수 있습니다. 그러나 대규모 코드베이스에서 이러한 종류의 개선이 여러번 이루어질수 있다면 고려해볼 가치가 충분히 있습니다. 일반적으로 값이 동적이어야 하는 경우에만 인수를 도입하고, 변수가 두번 이상 사용될 경우에만 변수를 도입하는 것이 좋습니다.

11. "Delete" 키워드 피하기

벤치마크1: object내에 key 삭제하기 vs 삭제할 key들을 undefined 처리하기

벤치마크2: delete 실행 vs Map.prototype.delete

delete 키워드는 객체에서 항목을 제거하는데 사용됩니다. 어플리케이션에서 필요할 수 있으나 피할수 있으면, 해당 문법은 피하는 것이 좋습니다. 배후에서 delete 는 V8 엔진에서 HiddenClass의 이점을 제거하여 일반적으로 느린 객체를 생성하게 됩니다.

필요에 따라 삭제하고자 하는 속성을 undefined로 설정하는 것으로 충분할 수 있습니다.

const obj = { a: 1, b: 2, c: 3 };
obj.a = undefined;

웹에서 다음과 같은 함수를 사용하여 특정 속성없이 원본 객체의 사본을 만드는것이 더 빠를 수 있다는 제안을 보았습니다.

const obj = { a: 1, b: 2, c: 3 };
const omit = (prop, { [prop]: _, ...rest }) => rest;
const newObj = omit('a', obj);

그러나 벤치마크에서 테스트를 확인해본 결과 delete 키워드보다 느리다는 것을 입증하였습니다. 또한, 이와같은 함수는 delete obj.a 또는 obj.a = undefined 보다 읽기 어렵습니다.

또한 Map.prototype.delete 가 delete문보다 훨씬 빠르므로 Object 대신 Map 을 이용할 수 있는지 고려해 보는 것도 좋을것 같습니다.

Do It Later

더 적게 실행하거나 더 빨리 실행할 수 없다면, 정확히 같은 시간이 걸리더라도 코드를 더 빨리 실행시킬 수 있는 네번째 최적화 방법이 있습니다. 가장 중요한 작업이 덜 중요하거나 까다로운 작업에 의해 차단당하지 않도록 코드를 재 구성하는 방법입니다.

12. 비동기(Asynchronous) 코드를 사용하여 스레드를 차단하기

기본적으로 javascript는 단일 스레드에 코드를 한번에 하나씩 동기적으로 실행합니다. (기본적으로 브라우저내 코드는 이벤트를 캡쳐하고 핸들러를 트리거하기 위해서 여러 스레드를 실행할 수 있지만, javascript 코드에 관련해서는 단일 스레드 형태를 취하고 있습니다.)

이것은 대부분의 javascript 코드에서 잘 작동할 수 있으나, 시간이 오래 걸리는 이벤트가 존재하는 경우 코드의 실행을 차단하거나 지연시킬 수 있습니다. 해결책으로는 비동기 코드를 사용하는 것입니다. 이는 fetch() 또는 XMLHttpRequest() 와 같이 내장 메소드처럼, 동기적인 함수를 비동기식으로 만들 수 있다는 점을 주목해야 합니다. 예를들어, 큰 배열을 처리하는 경우 해당 코드를 비동기식으로 만들고, 다른 코드에 방해받지 않도록 코드를 수정하는 것입니다.

13. Code Splitting 사용하기

클라이언트측에서 js를 사용하는 경우 우선순위에 대한 내용은 가능한 빨리 표시될 수 있도록 해야 합니다. 주요 벤치마킹은 devtool내의 first meaningful paint 로, 탐색에서 브라우저가 DOM의 첫번째 비트를 렌더링하는 시간까지 측정하는 기간입니다.

이를 개선할 수 있는 가장 좋은 방법중 하나는 javascript내의 code splitting 기능을 이용하는 것입니다. 하나의 큰 번들로 js파일을 import 하는 대신, 필요한 최소 js 코드가 실행되도록 작은 코드로 분할하는 것이 좋습니다. 코드 분할 방법은 React, Angular, Vue 혹은 Vanilla js 사용 여부에 따라 다를 수 잇습니다.

관련한 전술은 tree shaking 으로, 이는 코드베이스에서 사용되지 않거나 불필요한 종속성을 제거하는데 중점을 둔 코드 제거 형태입니다.

...