본문 바로가기

프로그래밍(TA, AA)/자바스크립트

[자바스크립트] 성능을 높이는 코드 스타일

자바스크립트의 기본 요소인 반복문과 조건문, 문자열 연산과 함께 배열과 객체의 생성과 초기화, 문자열 연산, 정규 표현식, 변수 탐색 등을 어떻게 작성하느냐에 따라 자바스크립트의 실행 성능을 높일 수가 있습니다.


같은 기능을 실행하는 다양한 코드의 성능을 비교해 어떻게 작성할 때 성능이 더 좋은지 확인하는 것도 개발자에게 필요한 습관입니다. 아주 기초적인 코드이더라도, 키초적인 코드를 어떻게 작성하느냐에 따라서 자바스크립트의 실행 성능에 큰 차이가 날 수도 있습니다. 이후 개발을 하면서 성능 향상 문제를 해결하려 성능 개선 요소를 보며 하나하나 적용하기 보다는 제시된 최적화된 코드의 형태로 코딩하는 습관을 몸에 익혀 놓는 것이 성능 개선에 좀 더 효과적인 방법입니다. 물론 경우에 따라 직접 성능을 측정하며 최적화해야만 하는 요소도 있습니다.



객체의 생성, 초기화 성능


많이 사용하지만 성능을 생각하지 않고 작성하는 코드에 객체 선언과 초기화 구문이 있습니다. 배열(Array) 형식의 객체와 오브젝트(Object) 형식의 객체를 생성하고 초기화하는 방법의 성능을 측정해 보고 두 코드 사이에 어떤 차이점이 있는지 비교해 보면 다음과 같습니다.


배열의 생성, 초기화 성능 비교

배열은 생성자 혹은 리터럴 형식([])을 사용해 객체를 생성할 수 있습니다.


Code #1 - 생성자를 사용한 배열 생성

// Array() 생성자를 사용한 배열 생성
var arr = new Array();

Code #2 - 리터럴 형식으로 배열 생성

// 리터럴 형식으로 배열 생성
var arr = [];

이 두 방법의 성능을 jsMatch에서 비교한 결과는 다음과 같습니다. 결과에서 알 수 있듯이 객체 생성 방법에 따른 큰 차이는 없으나 리터럴 형식을 사용한경우 여러 브라우저에서 좀더 좋은 성능을 보입니다.




배열을 사용하려면 배열의 각 요소에 데이터를 할당해 초기화해야 합니다. 배열의 각 요소에 데이터를 할당하는 방법에도 여러 가지가 있습니다. 그 가운데 가장 많이 볼 수 있는 방법인 접근자 []를 사용하는 방법과 push() 메서드를 사용하는 방법의 성능을 비교하면 다음과 같습니다. 성능 비교에서는 배열의 생성 방법이 성능에 영향을 미치지 않도록 모두 리터럴 방식으로 배열 객체를 생성하였습니다.


Code #1 - 접근자를 사용한 데이터 할당

var arr = [];

for (var i=0; i<1000; i++) {
    arr[i] = i;
};

Code #2 - push() 메서드를 사용한 데이터 할당

var arr = [];

for (var i=0; i<1000; i++) {
    arr.push(i);
}
결과를 보면 크롬을 제외한 대부분의 브라우저에서 push() 메소드를 사용해 데이터를 할당하는 방법보다 접근자 []를 사용해 데이터를 할당하는 코드의 실행 속도가 2배 정도 더 빠릅니다. 접근자를 사용한 코드(Code #1)가 push() 메서드를 사용한 코드(Code #2)보다 성능이 더 좋다는 의미입니다.


배열의 생성과 초기화 방법을 비교한 결과, 배열을 사용할 때는 리터럴 형식으로 객체를 생성하고 Array.push() 메서드보다는 접근자 []를 사용해 데이터를 추가하는 코드를 작성하는 것이 좀 더 최적화된 배열 사용법이라는 사실을 확인할 수 있습니다.



오브젝트(Object) 객체의 생성, 초기화 성능 비교


오브젝트(Object) 객체도 배열처럼 객체를 생성하고 초기화하는 다양한 방법이 존재합니다. 여기서는 가장 많이 사용하는 방법인 리터럴({})을 사용하는 방법과 생성자를 사용하는 방법의 성능을 테스트해 보고 어떤 방법으로 객체를 생성하고 초기화하는 것이 효과적인지 살펴보겠습니다.

먼저 객체 생성 방법을 비교해 성능을 테스트 해보겠습니다. 다음 예제는 리터럴({})을 사용해 객체를 생성하는 예제와 Object() 생성자로 객체를 생성하는 예제 입니다.


Code #1 - 리터럴을 사용한 오브젝트 객체 생성

// 리터럴을 사용한 오브젝트 객체 생성
var obj = {};

Code #2 - 생성자를 사용한 오브젝트 객체 생성

// 생성자를 사용한 오브젝트 객체 생성
var obj = new Object();

다음의 성능 비교 결과를 보면 배열과는 달리 객체를 생성하기 위해 생성자 또는 리터럴({}) 가운데 어떤 방법의 성능이 월등히 좋다고 판가름하기 힘듭니다. 오브젝트 객체의 생성 방법 가운데 반드시 어떤 방식을 사용해야만 하는 이유가 없다면 리터럴 형식이 코드 크기를 좀 더 줄일 수 있는 방법이기 때문에 코드를 다운로드하는 시간 관점에서 성능에 더 좋다고 볼 수는 있습니다. 하지만 이와 같이 성능 차이가 거의 없는 경우에는 성능보다는 개발과 유지 보수, 가독성까지 고려해서 코드 작성방식을 선택하는 것이 올바른 최적화 방법일 것입니다.




오브젝트 객체를 초기화할 때는 다음에 제시하는 두 가지 방법으로 오브젝트 객체에 새로운 데이터를 삽입할 수 있습니다.


Code #1 - 연산자를 이용한 데이터 삽입

var obj = {};

obj.a = 1;
obj.b = 2;
obj.c = 3;
obj.d = 4;
obj.e = 5;
obj.f = 6;
obj.g = 7;
obj.h = 8;
obj.i = 9;
obj.j = 10;

Code #2 - [] 연산자를 이용한 데이터 삽입

var obj = {};

obj["a"] = 1;
obj["b"] = 2;
obj["c"] = 3;
obj["d"] = 4;
obj["e"] = 5;
obj["f"] = 6;
obj["g"] = 7;
obj["h"] = 8;
obj["i"] = 9;
obj["j"] = 10;

오브젝트 객체에 데이터를 삽입하는 초기화 방법에 대한 성능 테스트 결과도 오브젝트 객체 생성에 대한 테스트 결과와 유사했습니다. 객체의 초기화도 생성과 마찬가지로 한 가지 방식이 더 성능이 좋다고 판단할 수 없으며, 작성하는 코드의 크기와 주요 대상 브라우저 및 코드의 가독성과 유지 보수를 감안해 적절한 방식을 선택하면 됩니다.





스코프 체인 탐색과 성능


자바스크립트 성능을 다루는 책에서 항상 빠지지 않는 부분이 스코프 체인(Scope Chain)입니다. 인터프리터 언어로서 자바스크립트는 JIT(Just-In-Time) 컴파일러 도입 등 자바스크립트의 실행을 최적화하기 위한 여러 가지 방법을 도입하고 있지만 개발자가 작성한 코드 자체의 성능이 런타임 성능에도 많은 영향을 줍니다. 그렇기 때문에 코드 자체에서 성능 저해 요인을 최대한 제거하는 방식으로 코드를 작성하는 것이 개발 작업에서 최우선으로 생각해야 할 최적화 과정입니다.


런타임 환경에서 가장 많이 발생하는 브라우저의 작업 가운데 자바스크립트의 실행 성능을 저해하는 요인이 변수, 객체, 함수 등의 메모리상의 위치를 찾는 탐색 작업입니다. 이 탐색 작업이 브라우저에서 어떻게 이뤄지는지는 스코프 체인을 통해 알 수 있습니다. 스코프 체인의 동작 원리를 이해하고 브라우저의 탐색 작업을 최적화해 자바스크립트 실행 속도를 끌어올릴 수 있는 방법을 찾을 수 있습니다.


스코프 체인이란?

자바스크립트의 함수를 실행하면서 어떤 속성(변수, 객체 등)에 접근해야 할 때 해당 속성을 효율적으로 탐색하도록 속성을 일정한 객체 단위로 분류하고 각 객체에 접근하기 위한 객체의 참조를 특정한 공간에 저장해 둡니다. 이 공간이 바로 스코프 체인입니다.


스코프 체인의 구성 요소에는 활성화 객체(Activation Object)와 전역 객체(Global Object)가 있습니다. 어떤 자바스크립트 함수가 있을 때 함수에서 접근할 수 있는 모든 속성 가운데 함수 내부에서만 접근할 수 있는 함수의 지역변수나 this, arguments 객체 등의 속성은 스코프 체인의 활성화 객체에 포함돼 관리됩니다. 그리고 함수 외부에서도 접근할 수 있는 windows, document, 전역함수, 전역변수와 같은 속성은 스코프 체인의 전역 객체에 포함돼 관리됩니다. 이 활성화 객체와 전역 객체가 실행 중인 함수의 스코프 체인(유효범위 체인)에 포함돼 함수에서 어떤 속성을 탐색할 때 길잡이 역할을 하게 되는 것입니다.


전역 객체와 활성화 객체에 대해 좀 더 살펴보면 다음과 같습니다.


window와 document 등의 전역객체는 자바스크립트 동작 시 어디서나 항상 접근 가능한 데이터를 포함하고 있기 때문에 웹 페이지의 자바스크립트가 동작하는 모든 시간 동안 존재하며, 함수 실행 시 함수의 스코프 체인에서 전역 속성을 탐색하는 데 사용됩니다.


반면 활성화 객체는 실행 중인 함수의 내부 데이터(지역변수, this, arguments 등)를 포함하기 때문에 전역 객체와는 달리 함수가 실행되는 동안에만 존재하며, 함수 내부에서 자주 사용하는 데이터가 모여 있는 만큼 모든 속성을 탐색할 경우 최우선으로 탐색하는 대상 객체가 됩니다.


특정 함수에 의해 구성되는 스코프 체인의 전체적인 모습은 다음 그림과 같습니다.



실행 문맥(Execution Context)은 함수가 동작하는 환경을 나타내며, 브라우저 내부에서 사용되는 객체입니다. 실행 문맥은 함수가 실행될 때 새로 생성되고 함수가 종료될 때 소멸되며 함수의 스코프 체인에 대한 참조를 가지고 있게 됩니다. 함수는 어떤 속성에 접근해야 할 때 실행 문맥을 통해 스코프 체인에 접근합니다.


실행 문맥은 자신과 연관된 함수의 스코프 체인을 참조하고 있으며, 함수에서 접근해야할 어떤 속성의 탐색 경로는 '실행 문맥 > 스코프 체인 > 활성화 객체 > 스코프 체인 > 전역 객체'와 같이 구성됩니다.


function isZero(num) {
    var res = (num === 0);
    return res;
}
var result = isZero(0);

위 함수가 실행될 때 구성되는 스코프 체인은 다음과 같습니다



isZero() 함수가 생성되면 함수의 실행 문맥은 아직 생성되지 않은 상태이므로 함수의 내부 속성(브라우저 내부 기능)의 하나인 [[Scope]] 속성에 전역 객체(Global Object)를 가리키는 스코프 체인은 우선적으로 저장합니다. 함수의 실행 문맥은 함수가 실행되는 시점에 생성됩니다.


isZero() 함수가 실행되면 실행 문맥이 생성되며 함수의 [[Scope]] 속성에 저장한 전역 객체를 함수의 실행 문맥이 가리키는 스코프 체인에 복사합니다. 그리고 활성화 객체를 생성하고 함수의 내부에서 접근할 수 있는 속성(파라미터 변수인 num과 boolean 값을 저장하는 변수인 res 및 this, arguments 등)을 채워 넣은 후 활성화 객체를 스코프 체인에 추가합니다.


이렇게 함수를 실행할 준비를 마치면 그 후에 함수에 정의된 각 구문을 실행합니다.


var res = (num === 0);


isZero() 함수에 있는 위 구문을 실행할 때 함수의 파라미터 변수인 num과 함수의 지역변수로 선언된 res에 대해 해당 변수의 메모리에 접근해야 하는데, 둘 다 활성화 객체에 포함돼 있는 속성이기 때문에 '실행 문맥 > 스코프 체인 > 활성화 객체'의 경로로 탐색해 접근합니다. 전역 객체는 탐색하지 않습니다.


만약 isZero() 함수 내에서 window, document 등의 전역번수에 접근해야 한다면 '실행 문맥 > 스코프 체인 > 활성화 객체 > 스코프 체인 > 전역 객체'의 경로로 속성(window, document)을 탐색했을 것입니다. 즉, 활성화 객체를 먼저 탐색한 후 찾는 속성이 없을 때는 스코프 체인에 참조돼 있는 다음 탐색 대상인 전역 객체를 탐색하게 됩니다. 그럼 스코프 체인에서 발생하는 속성 탐색 과정을 성능과 연관 지어 생각해 볼 수 있습니다.



즉, 함수에서 어떤 속성에 접근해야 하는 경우를 가정해 보면 됩니다. 만약 함수가 중첩될 경우에는 중첩이 깊어질수록 활성화 객체는 함수의 중첩된 깊이만큼 생성됩니다. 즉, 3번의 중첩된 함수에서 가장 안쪽의 함수 스코프 체인에 3개의 활성화 객체를 갖게되는 것이며, 그 결과는 다음과 같습니다.




스코프 체인의 최상위에는 현재 실행 중인 가장 안쪽에 중첩된 함수의 활성화 객체를 참조하며, 그 뒤로 바깥쪽 방향으로 중첩된 함수의 순서대로 각 함수의 활성화 객체를 참조하게 됩니다. 그리고 마지막으로 전역 객체를 참조하게 됩니다.


이 경우 가장 안쪽의 함수에서 전역 속성에 접근할 때는 '실행 문맥 > 스코프 체인 > 활성화 객체 1 > 스코프 체인 > 활성화 객체2 > 스코프 체인 > 활성화 객체3 > 스코프 체인 > 전역 객체' 와 같이 긴 탐색 경로를 거쳐야 합니다. 이러한 탐색 경로를 줄임으로써 실행 시간을 단축하고 자바스크립트 성능을 향상시킬 수 있습니다.


속성의 탐색경로를 줄임으로써 우리는 자바스크립트의 성능을 높일 수 있습니다.



지역변수를 활용한 스코프 체인 탐색 성능 개선

앞에서 스코프 체인의 탐색 방법을 살펴보면 여러 개의 활성화 객체와 전역 객체를 탐색하면서 접근하려는 속성이 있는지 확인하는 과정이 반복됩니다. 그렇다면 첫 번째로 탐색하는 활성화 객체에 찾고자 하는 속성이 있는 경우 추가로 발생할 수 있는 다른 활성화 객체, 전역 객체를 탐색하는 과정을 줄여 성능을 향상시킬 수 있을 것입니다.


Code #1 - 함수 내에서 전역 스코프 변수에 직접 접근하는 예제

window.htmlstring = [];

function makeList () {
   &nbps;htmlstring.push("<ul>");
   &nbps;for(var i = 0; i < 100; i++) {
   &nbps;   &nbps;htmlstring.push("<li>value : " + i + "</li>");
   &nbps;}
   &nbps;htmlstring.push("</ul>");
}
makeList();

makeList() 함수가 실행되면 함수 내부에서 htmlstring, i 속성에 접근하기 위해 스코프 체인을 탐색합니다. i 변수는 실행 중인 함수의 지역변수 이므로 처음 탐색하는 활성화 객체에서 찾을 수 있습니다. 그러나 htmlstring 객체는 활성화 객체에 먼저 접근해서 탐색하지만 찾지 못하고, 다시 전역 객체를 탐색해서 찾아야 합니다.


Code #2 - 함수 지역변수로 참조해 전역 스코프 변수에 접근하는 예제

window.htmlstring = [];

function makeList () {
    var htmlstr = htmlstring;
    htmlstr.push("<ul>");
    for(var i = 0; i < 100; i++) {
        htmlstr.push("<li>value : " + i + "</li>");
    }
    htmlstr.push("</ul>");
}
makeList();

수정한 코드 중 var htmlstr = htmlstring; 부분이 성능 개선의 핵심입니다. 전역 객체에 존재하는 htmlstring 속성을 makeList() 함수의 지역변수에 저장해 활성화 객체에서 바로 찾을 수 있게 한 것입니다. 물론 var htmlstring = htmlstring; 구문을 실행하는 동안 htmlstring 속성에 접근해야 하므로 최초 한 번은 활성화 객체와 전역 객체를 몯 탐색해야 합니다. 하지만 그 이후에는 활성화 객체에 저장된 htmlstr 속성으로 전역변수인 htmlstring 객체에 접근할 수 있으니 활성화 객체를 거쳐 전역 객체까지 탐색할 필요가 없어집니다.


최적화 이전의 코드에서 htmlstring 객체를 찾으려면 '실행 문맥 > 스코프 체인 > 활성화 객체 > 스코프 체인 > 전역 객체'와 같이 동일한 탐색 경로를 7번 거칩니다. 하지만 수정한 코드에서는 var htmlstr = htmlstring; 구문을 실행할 경우 최초 한 번만 '실행 문맥 > 스코프 체인 > 활성화 객체 > 스코프 체인 > 전역 객체'와 같은 속성 탐색 경로를 거칩니다. 그 이후 window 객체의 htmlstring 속성에 접근하는 것은 지역변수 htmlstr 속성에 접근하는 것으로 대체돼 '실행 문맥 > 스코프 체인 > 활성화 객체'와 같이 단축된 탐색 경로를 거치므로 실행 속도가 더 빨라집니다.


많이 복잡한 코드는 아니지만 인터넷 익스플로러에서는 성능차이가 두드러졌습니다.





프로토타입 체인

자바스크립트의 모든 객체의 인스턴스는 new 연산자로 생성할 수 있으며, 생성된 인스턴스 객체는 생성자의 프로토타입(prototype)을 참조하게 됩니다.


var obj = new Object(); // obj - 인스턴스 객체, Object - 생성자 함수

이렇게 생성한 인스턴스 객체는 원본 객체 생성자 함수의 프로토타입 속성에 접근할 수 있습니다. 인스턴스 객체가 원본 객체 생성자 함수의 프로토타입 속성을 탐색할 때도 탐색을 위한 체인이 생성되는데, 이를 프로토타입 체인이라고 합니다.


위 코드에서 Object는 자신의 프로토타입을 참조하며, var obj = new Object(); 구문이 실행되면 obj는 Object이 프로토타입을 상속받습니다. 이때 스코프 체인에서 어떤 속성을 찾기 위해 활성화 객체로부터 전역 객체로 탐색 범위를 넓혀나간 것과 같이, 프로토타입 체인에서도 obj는 obj의 어떤 속성을 찾기 위해 obj의 프로토타입으로부터 프로토타입이 참조한 경로를 따라 탐색 범위를 넓혀 나가며 Object의 프로토타입 속성에 접근합니다. 이 과정에서 탐색 경로가 길어질 수 있으며, 탐색 경로의 거리에 따라 프로토타입 체인에서도 스코프 체인에서와 같은 성능 저하가 발생할 수 있습니다. 그러므로 프로토타입에 존재하는 속성을 사용할 때 스코프 체인에서와 마찬가지로 지역변수에 담아서 사용한다면 불필요한 탐색 과정을 줄여 성능을 높일 수 있습니다.



그 외 스코프 체인 탐색 성능에 영향을 미치는 요소

여러가지 스코프 체인의 변화와 그에 따른 속성의 탐색 시간에 영향을 미치는 요소가 있습니다. 그 가운에 with 구문이 존재합니다. with 구문은 쓰지 말라는 구문으로 자주 등장하는데, 그 이유를 스코프 체인 탐색 관점에서 살펴보면 다음과 같습니다


with 구문을 사용하면 참조하는 멤버가 속한 객체를 지정하는 과정을 생략해서 코드의 용량을 줄일 수 있다는 장점이 있습니다. 


obj.name = "test";
obj.age = 22;
obj.address = "seoul";
...

위와 같이 객체의 속성을 빈번하게 참조하는 구문을 with 구문을 사용해 다음과 같이 줄일 수 있습니다.


with(obj) {
    name = "test";
    age = 22;
    address = "seoul";
    ...
}

사람에 따라선 코드를 일기 편해졌다고 생각할 수도 있을 만큼 코드가 간결해졌으며, "obj."이라는 중복 코드가 제거돼 obj 객체의 멤버를 많이 참조할수록 with 구문을 사용하지 않은 코드보다 코드의 양을 줄일 수 있습니다. 자바스크립트 파일의 용량이 줄어들었으므로 다운로드 시간이 단축될 것입니다.


하지만 with 구문 내에서는 with 구문의 인자인 'obj' 객체의 멤버로 구성된 활성화 객체가 스코프 체인의 탐색 순위에서 최우선권을 갖습니다. 이로 인해 with 구문 내에서 with 구문에 포함된 영역의 지역변수 등에 접근하는 것조차 탐색 경로를 2번 거쳐야하므로 성능이 저하됩니다. try-catch 구문의 catch 구문도 with 구문과 같은 방시으로 동작하기 때문에 동일한 이유로 인한 성능 저하가 발생합니다. 그러므로 with 구문이나 catch 구문을 사용할 때는 이러한 스코프 체인 탐색에서의 성능 문제를 감안해야 합니다.



반복문과 성능


자바스크립트의 반복문(Loop)인 for, for-in, while, do-while 구문에도 성능 차이가 있습니다. 반복문의 성능을 측정해보고 각 반복문의 성능차이가 어떤지, 반복문의 성능을 올리는 방법에 대해 살펴 보겠습니다.


반복문과 성능 비교

같은 로직을 실행하는 반복문 4개(for, for-in, while, do-while)의 성능을 측정해 보면 다음과 같습니다.


다음의 '사전코드'는 나머지 4개의 반복문에서 사용할 데이터를 만드는 코드로, jsMatch의 사전코드에 입력합니다.


arr = [];
for (var i = 0; i < 400; i++) {
    arr[i] = i;
}


Code #1 - for 구문

for (var i = 0, len = arr.length; i < len; i++) {
    arr[i]++;
}


Code #2 - for-in 구문

for (var i in arr) {
    arr[i]++;
}


Code #3 - while 구문

var i = 0, len = arr.length;
while (i < len) {
    arr[i] = i;
    i++;
}


Code #4 - do-while 구문

var i =0, len = arr.length;
do {
    arr[i] = i;
    i++;
} while (i < len);


위의 코드를 jsMatch에서 테스트한 결과는 다음과 같습니다.



결괄ㄹ 보면 for-in 구문은 인터넷 익스플로러 이외의 브라우저에서는 두드러질 정도로 성능이 좋지 않습니다. 대신 인터넷 익스플로러에서의 성능은 비교적 좋게 나타납니다. 성능 측정 결과로 미뤄봤을 때, 인터넷 익스플로러를 주요 브라우저로 생각하고 서비스를 개발하고 있다면 for-in 구문을 사용해도 큰 문제가 없을 것으로 보입니다. 하지만 인터넷 익스플로러 외에 다른 브라우저까지 고려한다면 브라우저에 따라 성능이 저하될 수 있습니다. 또한 for-in 구문은 다른 브라우저에 비해 인터넷 익스플로러에서 실행 시간이 빠르긴 하지만 위와 같이 간단한 코드를 테스트하는 데도 시간이 걸린 편이긴 합니다. 따라서 빠른 응답시간을 구현하려면 되도록 for-in 구문을 사용하지 않는 것이 좋습니다.


그렇다면 왜 for-in 구문의 성능이 유독 낮은 것일까요?


for-in 구문 이외의 반복문은 주어진 배열 객체를 배열의 특성에 맞게 순차적으로 모든 요소를 탐색합니다. 반면 for-in 구문은 인자로 주어진 배열을 배열이 아닌 일반 객체로 취급하며, 반복 시점마다 객체의 모든 속성을 무작위로 탐색하게 됩니다. 이러한 탐색 방법의 차이로 for-in 구문은 그 외의 반복문에 비해 배열 탐색에서 현저하게 느립니다.


for-in 구문은 그 목적 자체가 객체의 속성을 탐색하는 것입니다. 그렇기 때문에 모든 속성이 순차적으로 정렬돼 있어 선형적인 색인(index)으로 접근할 수 있는 배열보다는 속성의 이름이 제각각이라는 색인으로는 접근할 수 없는 객체의 속성을 탐색하는 데만 사용하길 권장합니다.



for, while, do-while 구문의 최적화

앞서 for-in 구문의 실행 속도가 느리다는 것을 확인했습니다. 그러나 "반복문은 for-in 외의 구문만 사용하자"라고 성급한 결론을 내리기 전에 "그럼 for, while, do-while 구문을 어떻게 사용해야 좀 더 좋은 성능을 낼 수 있을까요?" 라는 의문을 품는 것이 필요합니다.


for, while, do-while 구문의 주요 사용처를 생각해보면, 근본적은 사용 목적은 같은 동작을 원하는 만큼 반복시키는 것이지만 서비스를 개발하다 보면 유독 배열 객체를 대상으로 반복문을 실행하는 경우가 많습니다.


다음은 배열의 모든 요소를 탐색하는 다양한 형식의 반복문입니다.


// for 구문을 이용한 배열 탐색
var arr = [ ... ];
for (int i = 0; i < arr.length; i++) {
    ...
}

// while 구문을 이용한 배열 탐색
var arr = [ ... ];
var i = 0;
while (i < arr.length) {
    ...
    i++;
}

// do-while 구문을 이용한 배열 탐색
var arr = [ ... ];
var i = 0;
do {
    ...
    i++;
} while (i < arr.length);


for-in 구문을 사용한 반복문보다는 for, while, do-while 구문을 사용한 반복문의 성능이 더 좋다는 것은 이미 확인했습니다. 그럼 반복문의 성능을 좀 더 향상시키려면 어떤 부분을 최적화해야 할까요?


스코프 체인과 프로토타입 체인의 내용으로 돌아가보면, 스코프 체인과 프로토타입 체인에서 성능에 영향을 주는 것은 속성의 탐색 시간이었으며, 원하는 속성에 접근하기 위해 스코프 체인에 속해 있는 활성화 객체를 얼마나 많이 거쳐야 하는가가 속성의 탐색 시간을 결정했습니다. 그리고 스코프 체인의 탐색 경로를 줄이고 자바스크립트의 성능을 최대화하기 위해 스코프 체인의 탐색 경로에서 멀리 떨어진 활성화 객체의 속성에 대한 참조를 가장 빨리 접근할 수 있는 활성화 객체에 복사해 두고 사용하는 방식을 사용했습니다.


긴 반복문에서 arr, i는 모두 지역변수로 선언됐습니다. 그리고 반복 횟수만큼 arr.length 속성을 참조하는 것을 볼 수 있습니다. length 속성은 arr 객체의 속성이므로 arr 객체를 찾기 위한 스코프 체인 탐색 과정과 arr 속성인 length 속성을 찾기 위한 arr 객체의 프로토타입 체인 탐색 과정을 거쳐야 합니다. length 속성에 접근하기 위한 탐색 과정을 줄여 성능을 향상 시킨 코드는 다음과 같습니다.


// for 구문
var arr = [ ... ];
for (var i = 0, len = arr.length; i < len; i++) {
    ...
}

// while 구문
var arr = [ ... ];
var i = 0, len = arr.length;
while (i < len) {
    ...
    i++;
}

// do-while 구문
var arr = [ ... ];
var i = 0, len = arr.length;
do {
    ...
    i++;
} while (i < len);

arr 객체의 length 속성을 지역변수로 복사해 스코프 체인과 프로토타입 체인의 탐색 경로를 축소시켰습니다. 이렇게 최적화된 코드와 최적화 이전의 코드는 반복문을 실행하는 횟수만큼 성능에 차이가 생길 것입니다. 다음은 각 반복문별 성능 테스트 결과입니다.



1) for 구문의 성능 최적화


2) while 구문의 성능 최적화


3) do-while 구문의 성능 최적화




효율적인 알고리즘 구현을 통한 성능 개선


반복문을 동일한 횟수로 실행할 때 반복문을 어떻게 작성하느냐에 따라 성능에 차이가 있다는 것을 알았습니다. 앞에서 설명한 방법으로 반복문을 최적화해 성능을 향상시킬 수도 있지만, 그보다 먼저 생각해야 하는 성능 개선 방법이 있습니다.


바로 반복 횟수를 최소화하는 효율적인 알고리즘을 사용하는 것입니다. 잘 짜인 알고리즘으로 반복문의 호출 횟수를 줄이는 방법은 그 어떤 방법보다 성능을 개선할 수 있는 좋은 방법이며, 반복문을 사용할 때 어떤 최적화보다 먼저 적용해야 하는 성능 개선 작업입니다. 반복 횟수를 최소화하는 알고리즘에는 반복문을 실행하는 도중 목적을 달성했을 때 break 등을 호출해 더는 불필요한 반복문을 호출하지 않고 반복 구문을 빠져 나오게 하는 것을 예로 들 수도 있습니다.


만약 데이터를 정렬(sort)하거나 탐색하는 데 반복문을 사용한다면 퀵 소트(quick-sort), 머지 소트(merge-sort), BFS(Breadth First Search), DFS(Depth First Search) 등과 같은 증명된 알고리즘을 함께 사용해 반복 횟수를 줄여 자바스크립트 실행 성능을 향상시킬 수 있습니다.



조건문과 성능


반복문만큼이나 조건문 또한 자바스크립트를 포함한 프로그래밍 전반에서 필수적으로 사용하는 요소입니다. 자바스크립트에는 if, if-else, switch, 삼항연산자(? :) 등의 조건문이 있습니다. 조건문의 성능을 비교해 보고 언제 어떤 형식의 조건문을 사용하면 자바스크립트의 실행 성능을 향상시키는 것이 가능합니다.


조건문의 성능 비교

먼저 true와 false만 판단하는 최소한의 조건 분기를 처리하는 코드로 if(-else), switch, 삼항연산자(? :)의 성능을 측정했습니다. 성능 측정에 사용할 코드는 다음과 같습니다. 1부터 10까지의 숫자를 영어 단어로 반환하는 함수 입니다.


Code #1 - if-else 구문을 활용한 조건분기

function toEnglish(value) {
var number = "zero";

if(value === 1) {
    number = "one";
} else if(value === 2) {
    number = "two";
} else if(value === 3) {
    number = "three";
} else if(value === 4) {
    number = "four";
} else if(value === 5) {
    number = "five";
} else if(value === 6) {
    number = "six";
} else if(value === 7) {
    number = "seven";
} else if(value === 8) {
    number = "eight";
} else if(value === 9) {
    number = "nine";
} else if(value === 10) {
    number = "ten";
} else {
    number = "null";
}

return number;
}

for (var i = 0; i < 12; i++) {
    toEnglish(i);
} 


Code #2 - switch-case 구문을 활용한 조건분기

function toEnglish(value) {
    var number = "zero";
    switch(value) {
    case 1:
        number = "one";
        break;
    case 2:
        number = "two";
        break;
    case 3:
        number = "three";
        break;
    case 4:
        number = "four";
        break;
    case 5:
        number = "five";
        break;
    case 6:
        number = "six";
        break;
    case 7:
        number = "seven";
        break;
    case 8:
        number = "eight";
        break;
    case 9:
        number = "nine";
        break;
    case 10:
        number = "ten";
        break;
    default:
        number = "null";
        break;
    }
    return number;
}
for(var i=0; i<12; i++) {
    toEnglish(i);
}


Code #3 - 삼항연산자를 활용한 조건분기

function toEnglish(value) {
    var number = false;
    number = (value === 1) ?
    "one" : (value === 2) ?  
    "two" : (value === 3) ?  
    "three" : (value === 4) ?  
    "four" : (value === 5) ?  
    "five" : (value === 6) ?  
    "six" : (value === 7) ?  
    "seven" : (value === 8) ?  
    "eight" : (value === 9) ?  
    "nine" : (value === 10) ?  
    "ten" : "null";

    return number;
}

for(var i = 0; i < 12; i++) {
    toEnglish(i);
}


코드마다 10개 정도의 조건문을 사용했기 때문에 코드를 읽기는 어렵지 않지만, 이렇게 조건문을 나열하는 방식은 되도록 사용하지 않기를 권장합니다. 조건이 많을 때는 배열이나 JSON, 해시(hash) 형식의 자바스크립트 객체를 사용하는 편이 개발은 물론 이후의 유지 보수 작업도 더 수월해 질 것입니다.


아래는 위 조건문별로 코드의 성능을 측정한 결과 입니다.



브라우저별로 성능 차이가 크지는 않지만, 일반적으로 조건 판단 요소가 많아질수록 switch-case 구문이 성능이 좀 더 좋은 편입니다. 조건문 사이에 성능 차이가 크지 않더라도 여러 개의 조건문을 나열하는 형식으로 사용하는 것은 성능 문제 외에도 개발의 효율성이 떨어지므로 권장하지 않습니다. 그래도 부득이하게 조건문을 나열하는 방식으로 사용해야 할 때가 있습니다. 특정 값이 맞는지 확인하는 == 형식의 비교가 아닌 다른 형식(>, <, &&, ||)의 비교연산자를 사용할때는 if-else 구문이 아닌 다른 조건문을 이용하기가 어렵기 때문에 조건문을 나열하는 형식으로 기능을 구현해야 합니다.


하지만 이때도 조건문 구현 방식을 바꿔 성능을 향상시킬 수 있습니다. 


// Code #0 단순한 구조의 조건 비교
function number_range(value) {
    var range = "";
    if(value <= 10) {
        range = "~10";
    } else if(value <= 20) {
        range = "11~20";
    } else if(value <= 30) {
        range = "21~30";
    } else if(value <= 40) {
        range = "31~40";
    } else if(value <= 50) {
        range = "41~50";
    } else if(value <= 60) {
        range = "51~60";
    } else if(value <= 70) {
        range = "61~70";
    } else {
        range = "71~";
    }
    return range;
}
number_range(42);


// Code #1 계층 구조를 활용한 조건 비교
function number_range(value) {
    var range = "";

    if (value <= 40) {
        if (value <= 20) {
            if (value <= 10) {
                range = "~10";
            } else {
                range = "11~20";
            }
        } else {
            if (value <= 30) {
                range = "21~30";
            } else {
                range = "31~40";
            }
        }
    } else {
        if (value <= 60) {
            if (value <= 50) {
                range = "41~50";
            } else {
                range = "51~60";
            }
        } else {
            if (value <= 70) {
                range = "61~70";
            } else {
                range = "71~";
            }
        }
    }

    return range;
}

number_range(42);

두 코드 가운데 어느 것이 다른 것보다 항상 성능이 더 좋다고 말할 수는 없습니다. 두 코드는 각각 다른 성능 관점에서 작성된 코드이며, 입력되는 데이터에 따라 둘 중 하나의 성능이 다른 것보다 좋을 수 있습니다. 입력값에 따라 두 코드의 비교 연산 횟수가 달라집니다.


어느 범위의 입력값이 주로 사용되느냐에 따라 두 코드의 실행 성능은 다를 것입니다. 입력값의 패턴을 알고 있다면 입력값 패턴에 따른 적절한 조건 분기 방식을 선택해 코드의 실행 성능을 높일 수 있습니다.



조건문 최적화

조건문 최적화는 조건 분기 처리 방식을 조정하는 방법 외에도 다른 방법이 있습니다. 조건문을 최소화하고 배열이나 해시 객체를 사용하는 방법입니다. 먼저 배열을 사용하는 예제를 보면 다음과 같습니다.


// Code #3 배열을 활용한 조건 비교
function number_range(value) {
    var arr_range = ["~10", "11~20", "21~30", "31~40", "41~50", "51~60", "61~70", "71~"];

    var arr_range_index = Math.ceil(value/10) -1;
    if (arr_range_index < 0) {
        arr_range_index = 0;
    } else if (arr_range_index >= (arr_range.length)) {
        arr_range_index = arr_range.length - 1;
    }
    return arr_range[arr_range_index];
}

number_range(42);


// Code #4 해시 객체를 활용한 조건 비교
function number_range(value) {
    var hash_range = {2:"11~20", 3:"21~30", 4:"31~40", 5:"41~50", 6:"51~60", 7:"61~70"};

    var hash_range_key = Math.ceil(value/10);
    if (hash_range[hash_range_key]) {
        return hash_range[hash_range_key];
    } 
    if (value <= 10) {
        return "~10";
    }
    return "71~";
}

number_range(42);


실행 성능 측정 결과는 다음과 같습니다. Code#1의 실행속도가 가장 빠른 것으로 나옵니다. 코드에서 조건문이 호출되는 횟수는 Code#2, Code#3이 Code#1보다 적습니다. 하지만 Code#2, Code#3에서는 조건문의 실행에 필수적인 배열과 해시 객체를 생성하고 탐색하는 시간과 자바스크립트 내장 함수인 Math.ceil() 함수를 호출하는 시간이 성능에 영향을 미쳤습니다. 그래서 추가적인 비용이 없는 Code#1이 상대적으로 더 좋은 성능을 보였습니다. 즉, 이 비교에서는 조건문의 호출 횟수가 성능 개선에서 중요한 요소가 아니었다고 할 수 있습니다.


이렇게 복잡한 로직을 구현할 때는 성능 최적화 작업을 코드의 유지 보수와 연관해서 생각하는 것도 좋습니다.



Code#0, Code#1이 Code#2, Code#3 보다 가독성이 더 좋게 때문에 쉽게 이해하고 수정할 수 있을 것입니다. 하지만 Code#2, Code#3은 입력값의 범위를 판별하는 조건이 추가, 삭제됐을 때 수정해야 하는 범위가 작기 때문에 조건의 추가, 삭제가 더 쉽다는 장점이 있습니다.


코드의 유지 보수에서 코드의 용량이 얼마나 많이 늘거나 줄지, 코드를 유지 보수할 때 로직을 이해하고 작업하기 쉬운 코드는 무엇인지를 고려해 상황에 적합한 형태의 코드를 사용하는 것이 복잡한 로직을 구현할 때 가장 적합한 최적화 방법일 것입니다.



코드별 장단점 비교

Code#0와 Code#1, Code#2, Code#3의 장단점을 성능 측면에서 몇가지 정리한 것은 아래와 같습니다.


 코드

 장점

 단점

 Code#0

 Code#1

- 코드가 직관적이며 알아보기 쉽다(Code #0의 경우)

- 단순한 값 비교의 조건문만 있을 뿐 자바스크립트 라이브러리나 배열과 같은 객체를 사용하지 않아 실행 성능이 좋다.

- Code #2와 Code #3에 비해 코드의 용량이 크다. 비교 조건이 추가될수록 용량 차이가 커져서 자바스크립트 파일의 다운로드 성능이 저하된다.

- 비교 조건이 추가되거나 변경될 때 코드의 수정 범위가 Code #2와 Code #3에 비해 넓다.

 Code#2

 Code#3

- 코드 용량이 작고, 비교 조건이 추가돼도 코드 용량의 변화가 Code #0과 Code #1에 비해 작기 때문에 자바스크립트의 다운로드 성능 저하에 영향을 덜 미친다.

- 조건을 추가할 때 코드의 변경 범위가 배열, 해시 객체로 한정되어 수정 범위가 넓지 않고, 그만큼 다른 코드에 미치는 영향도 작다.

- 수식이 포함돼 있기 때문에 코드가 직관적이지 않으며, 알고리즘을 이해하는 과정이 필요하다.

- 배열, 해시 객체를 생성, 초기화해야 하는 비용 때문에 실행 성능이 떨어진다.


각 코드의 장단점을 봤을 때 비교 조건의 개수가 많지 않고 추가, 삭제될 일도 거의 없으며, 실행 성능이 중요할 때는 Code #0과 Code #1의 방식으로 조건문을 작성하는 것이 좋을 것입니다. 조건의 개수가 많고 추가, 삭제가 빈면하며, 함께 다운로드하는 리소스가 많아 최대한 자바스크립트 파일의 용량을 줄이는 것이 중요할 때는 Code #2와 Code #3의 방식으로 조건문을 작성하는 것이 좋을 것입니다.


이렇게 애매모호한 결론을 제시한 이유는 특정 코드 형식이 항상 최고의 성능을 낸다는 변치 않는 믿음을 갖기보다는 여러 가지 성능 관점(실행 성능, 다운로드 속도 등)을 고려해 현재 작업에서 중요한 기준에 맞춰 개발하는 것이 중요하기 때문입니다. 자신이 생각했던 좋은 성능을 위한 코드가 여러가지 요소 때문에 실제로는 좋은 성능을 내지 못하는 경우도 적지 않습니다. 의심이 생긴다면 해당 부분의 성능을 직접 측정해 보는 습관을 드이는 것이 가장 중요합니다.



문자열 연산과 성능


자바스크립트에서 주로 하는 작업 가운데 하나가 바로 문자열에 대한 연산입니다. 자바스크립트로 개발하다 보면 문자열을 여러 가지 방식으로 조합, 편집해 브라우저에 출력하는 코드를 자주 작성하게 됩니다. 이러한 문자열 연산에서 실행 성능을 좀 더 높일 수 있는 방법이 존재합니다.


문자열 생성 성능 비교

자바스크립트가 제공하는 문자열 생성 방법에는 두 가지가 있습니다. String 객체를 이용한 방법과 문자열 리터럴(" ")을 이용하는 방법입니다. 먼저 단순히 문자열을 생성하는 코드의 성능을 비교해보면 다음과 같습니다.


// Code #1 - String 객체를 이용한 문자열 생성
var str = new String("abcdefghijklmnopqrstuvwxyz");

// Code #2 - 리터럴을 이용한 문자열 생성
var str = "abcdefghijklmnopqrstuvwxyz";

측정한 시간이 지극히 짧지만 String 객체를 이용한 방법과 리터럴을 이용한 방법에는 생각보다 성능 차이가 큽니다. 특히 리터럴을 이용해 문자열을 생성할 때는 브라우저 사이에 성능이 비슷하지만 String 객체로 문자열을 생성하면 브라우저마다 성능이 다르고 차이가 큽니다. 문자열을 생성할 때는 되도록 String 객체보다는 리터럴을 사용하는 것이 좋습니다.




문자열 연산 성능 비교

다음은 성능 측정을 위한 문자열 병합 예제 코드와 그 측정 결과 입니다. 대체로 Array.join() 메서드를 이용한 문자열 병합이 여러 브라우저에서 안정적으로 좋은 성능을 나타냅니다.


// Code #1 - += 연산자를 이용한 문자열 병합
str = "";
for(var i = 0; i < 100; i++) {
    str += "test";
}

// Code #2 - Array.join() 메서드를 이용한 문자열 병합
arr = [];
for(var i = 0; i < 100; i++) {
    arr[i] = "test";
}
arr.join("");


+= 연산자는 두 문자열을 합친 새로운 문자열(str += "test")을 만들고 새로운 메모리 위치에 저장함과 동시에 기존 문자열(str)에 대한 참조를 변경하는 연산을 반복적으로 실행해야 합니다. 하지만 Array.join() 메서드로 연산하면 비교적 메모리에 효율적으로 접근할 수 있는 배열을 사용합니다. 배열에 저장된 문자열을 모두 합쳐 하나의 문자열을 생성하고 저장하므로 문자열이 병합될수록 점점 더 큰 문자열을 생성하고 저장해야 하는 += 연산에 비해 불필요한 문자열 참조 변경과 재생성 작업이 없습니다.


보통 인터넷 익스플로러 8 버전 이하의 브라우저는 문자열 병합 연산 방식에 따른 성능 차이가 큽니다. 인터넷 익스플로러 8 이후에 출시된 브라우저부터는 브라우저 내부의 최적화 작업을 통해 두 연산자의 성능 차이가 점점 줄어들고 있습니다.


문자열 연산을 할 때 모든 조건에서 어떤 연산자를 사용하라고 말할 수는 없지만 위의 결과를 토대로 말하자면 아직까지는 += 연산자보다 Array.join() 메서드로 병합 연산을 하는 것이 전반적인 브라우저 환경에서 좀 더 좋은 성능을 얻을 수 있습니다. 하지만 브라우저 내부에서 최적화 작업을 실행하고 있으니, Array.join() 메서드와 += 연산 가운데 선택해야 한다면 최신 브라우저에서 테스트해 보고 두 연산자의 성능 차이를 살펴보며 개발할 것을 권장합니다.





정규 표현식과 성능


정규표현식 등 javascript 코딩 이전에는 최정화 방법의 요점만 기억하고 그때그때 jsMatch, dynaTrace, jsPerf 등과 같은 성능 측정 도구로 최적화 작업을 실행하는 것을 권장합니다.


trim 연산의 핵심은 문자열 앞뒤의 공백 문자를 탐색하고 이를 빈문자(" ")로 치환하는 것입니다. 제거 대상이 되는 공백을 탐색하기 위해서 문자열에 속한 문자를 하나하나 탐색하기도 하지만 보통은 정규 표현식으로 간단하게 해결합니다. 하지만 trim 연산에 사용하는 정규 표현식도 어떻게 식을 구성해 사용하는지에 따라 성능에 차이가 있습니다.


탐색 대상 축소를 통한 성능 향상

아래 코드는 문자열이 공백으로 시작하는지 보면서 공백 다음에 문자열 끝이 오는지도 확인합니다. 그리고 그리고 문자열 전체를 돌면서 항상 앞뒤에 위에서 말한 조건이 맞는지 살펴보는 정규식 표현했습니다.

str.replace(/^\s+|\s+$/g, "");


다음은 trim 연산에 최저화된 정규 표현식으로 자주 언급되는 예제입니다. 의미는 문자열이 공백으로 시작되는지 보고 공백이 끝나는 데까지 공백을 일단 제거한 후 공백이 제거된 문자열에서 공백 다음에 문자열 끝이 있는지 봅니다. 공백 다음에 문자열 끝이 있다면 문자열 끝 앞에 있는 공백을 제거한다는 의미를 지니고 있습니다.

str.replace(/^\s+, "").replace(/\s+$/, "");


앞뒤 공백을 분리된 정규 표현식으로 찾은 두번째 식은 첫번째 식보다 문자열 앞쪽의 공백을 찾아 내고 제거하는 데 시간적인 이득이 있습니다. 반면 첫 번째 식은 뒤쪽의 공백을 찾을 때도 항상 앞쪽의 공백을 함께 찾아야 하기 때문에 그만큼 성능이 저하됩니다. 문자열 앞쪽 공백은 정규 표현식으로 찾아서 제거하고, 뒤쪽 공백은 문자열 뒤로부터 반복문으로 공백을 제거하는 방법도 사용하지만, 이 방법은 성능상 이득은 있어도 코드의 양도 늘어나고 가독성이 떨어지는 문제가 있습니다.


정규 표현식 최적화의 핵심 내용은 '탐색 대상의 축소'입니다. 앞의 공백을 탐색하는 횟수를 줄여 성능 향상을 가져온 만큼, 정규 표현식을 사용할 때 불필요한 탐색 과정이 반복되지 않도록 주의해야 합니다.



컴파일 횟수 축소를 통한 성능 향상

정규 표현식은 브라우저에서 컴파일된 후 실행돼야 하는 기능입니다. 정의된 정규 표현식은 브라우저에서 컴파일된 후 사용됩니다. trim 연산에 사용하는 정규 표현식은 비교적 간단한 정규 표현식이긴 하지만 반복되는 컴파일 과정 또한 불필요한 작업임에는 틀림없습니다.


// Code #1 - 정규 표현식의 컴파일과 실행을 반복하는 경우
for (var i = 0; i < 100; i++) {
    str.replace(/^\s+/, "").replace(/\s+$/, "");
}

// Code #2 - 정규 표현식의 컴파일을 최초 1번만 실행하는 경우
var reg1 = /^\s+/;
var reg2 = /\s+$/;
for (var i = 0; i < 100; i++) {
    str.replace(reg1, "").replace(reg2, "");
}


정규 표현식의 두번째 최적화 규칙은 '컴파일 횟수 축소'입니다. 최적화 작업의 중요한 부분이 불필요한 작업을 줄이는 것인 만큼 정규 표현식을 사용할 때도 불필요한 작업을 줄인다면 좀 더 좋은 성능으로 정규 표현식을 사용할 수 있을 것입니다. 하지만 정규 표현식의 복잡도에 따라 컴파일 성능에 차이가 있고, 무한한 확장성을 가진 정규 표현식에 대한 성능 지표를 제공하긴 어렵기 때문에 사용할 정규 표현식과 문자열로 성능을 테스트하며 최적화하는 것을 가장 권장힙니다.