Engineering
100,000개의 아이템도 거뜬한 셀렉트박스 만들기 (1/2)
2018년 8월 3일
원문에서 보기 ↗안녕하세요. 분석서비스팀 심준식입니다.
거의 모든 웹사이트에 있다시피 한 셀렉트박스는 매우 쉽게 구현할 수 있는 간단한 컴포넌트이지만, '잘' 만들기는 어려운 컴포넌트이기도 합니다. 아이템의 갯수가 10~100개 정도일 때에는 아무렇게나 만들어도 별 문제가 없지만, 아이템의 수가 수만 개를 넘어갈 경우(페이코 가맹점 수, 코미코 작품 수 등)에도 느려지지 않도록 만드는 데는 상당한 노력이 필요합니다.
우리는 가장 간단한 형태의 셀렉트박스부터 시작하여 단계적으로 성능을 향상시켜 나갈 겁니다. 내용이 상당히 길기 때문에 이 글에선 일차적으로 셀렉트박스를 생성하는 부분만 설명하도록 하겠습니다.
그리고, 되도록 많은 분들께 도움이 될 수 있도록 ES6는 최소한으로만 사용하도록 하겠습니다.
사전 준비
먼저 간단한 HTML 파일을 만들어봅시다. 간단한 CSS와 jQuery를 포함시켜 줍니다. jQuery는 공식 사이트(jquery.com)에서 다운받아서 사용하시면 됩니다.
<html>
<head>
<meta charset="UTF-8">
<style>
#popup {
position: absolute;
overflow: auto;
width: 300px;
height: 200px;
display: none;
}
</style>
</head>
<body>
<span id="counter" style="font-size: 24px">0</span>
<div id="selectbox">
<span id="title">이건 셀렉트박스</span>
<div id="popup">
<ul id="list"></ul>
</div>
</div>
<script src="jquery-3.3.1.min.js"></script>
<script src="scripts.js"></script>
</body>
</html>
이 파일은 완성되고 나면 다음과 같은 화면을 나타내게 됩니다.

그림 상단의 18이란 숫자는 0부터 시작하는 카운터로서 매 초마다 1씩 증가합니다. 이 카운터는 셀렉트박스와 직접적인 연관이 있진 않지만, 과도한 스크립트 실행으로 인해 브라우저가 멈추는 현상을 보기 위해 존재합니다.
그 아래에 있는 '이건 셀렉트박스'가 우리가 만들 셀렉트박스입니다. 간단한 예제를 위해 스타일링을 전혀 하지 않았지만, 글씨를 클릭하면 하단에 아이템 목록이 나타나고 원하는 아이템을 클릭하면 '이건 셀렉트박스' 문구가 해당 아이템의 문구로 바뀌게 됩니다.
최초 구현
그럼 scripts.js를 만들어 보겠습니다.
// 랙을 보기 위한 카운터
var counter = 0;
$(function() {
// 매 초마다 카운터 갱신
setInterval(function() {
counter++;
$('#counter').html(counter);
}, 1000);
// 데이터 생성
var data = [];
for (var i = 0; i < 100; i++) {
data.push('아이템' + i);
}
// 클릭 이벤트 핸들러
$('#title').click(function(e) {
$('#popup').toggle();
});
// 데이터로 셀렉트박스 항목 만들기
for (var i = 0; i < data.length; i++) {
var elem = createItem(data[i]);
$('#list').append(elem);
}
});
function createItem(d) {
var elem = $('<li>' + d + '</li>');
elem.addClass('item');
// 아이템 클릭 시 선택되도록 함
elem.click(function() {
$('#title').html(d);
$('#popup').hide();
});
return elem;
}
간단한 셀렉트박스 구현체입니다.
여기선 data란 배열에 룹을 돌면서 데이터를 생성하였지만, 실무에선 Ajax 호출 등으로 대체되겠죠. 일단은 구현을 위해 데이터 100개만 가지고 진행해 볼 겁니다.
그 다음 부분은 클릭 이벤트 핸들러입니다. '이건 셀렉트박스'를 클릭했을 때 #popup 레이어가 펼쳐지도록 해주는 부분이죠.
이어지는 부분이 이 글에서 가장 중요한 부분입니다. 바로, 데이터를 가지고 셀렉트박스에 넣을 아이템들을 생성하는 부분이죠. for문으로 룹을 돌면서 createItem()을 호출해 DOM 엘리먼트를 생성하고 그걸 #list에 넣어주고 있습니다.
가장 하단에는 아이템 생성을 도와주는 createItem() 함수가 있습니다. 주어진 데이터로 <li>태그를 생성하고, 선택될 수 있도록 클릭 이벤트 핸들러를 붙여주고 있죠.
구현을 마쳤으니 브라우저에서 확인해보겠습니다.

볼품은 없지만 기능은 잘 동작합니다.
그럼 이제 데이터의 갯수를 100개가 아닌 100,000개로 늘려보겠습니다.
...
for (var i = 0; i < 100000; i++) {
data.push('아이템' + i);
}
...
다시 한번 브라우저에서 확인해보죠.

데이터가 많아지니 새로고침 후 약 10초간 소위 말하는 '먹통' 상태가 되는 걸 알 수 있습니다. 저 상태에선 링크나 버튼 같은 것들도 클릭되지 않으므로 사용자는 멍하니 화면만 쳐다보고 있어야 하죠. 당연히 꼭 피해야만 하는 상황입니다.
이런 현상은 브라우저의 거의 모든 것이 이벤트룹(Event Loop)이라는 일종의 싱글 스레드(Thread)를 통해 동작하기 때문에 발생합니다. 병렬 처리가 되지 않기 때문에 긴 작업이 하나 들어오면 그 이후의 작업들은 뒤로 쭉 밀리게 되는 것이죠.
그림으로 살펴보면 다음과 같습니다.

웹페이지가 반응성이 좋으려면 위 그림의 '일반 상황'과 같이 매 프레임에 적당한 양의 일을 해야 합니다. 스타일 계산, 렌더링, 스크립트 실행까지 모두 각 프레임에 할당된 시간을 초과해서는 안 되죠. 일반적인 디스플레이가 60fps라고 가정했을 때, 하나의 프레임은 1/60초, 즉 16.66ms 안에 모든 작업이 끝나야 합니다.
하지만 그림의 '대량 스크립트 실행 시'를 보면, 엄청나게 큰 스크립트 덩어리가 모든 시간을 잡아먹고 있는 걸 알 수 있죠. setInterval()이나 마우스 클릭 이벤트 등이 처리되기 위해서는 이벤트룹이 해당 스크립트를 처리해줘야 하는데, 이벤트룹은 들어온 일을 순서대로 처리하기 때문에 100,000번의 createItem()이 끝날 때 까지 다른 일은 모두 미뤄놓습니다. 단지 스크립트 실행 뿐만이 아닌 렌더링까지도 미뤄버리게 되므로 화면 갱신도 이뤄지지 않게 됩니다.
그럼 이제 원인은 알았으니 하나씩 개선해 나가면서 쓸만한 성능이 나오도록 고쳐봅시다.
작업 쪼개기
이 문제를 해결하기 위해서 시도할 방식은 모든 createItem() 호출을 큐에 넣어놓은 후 매 프레임마다 조금씩 빼서 실행하는 것입니다.

이런 식으로 매 프레임마다 아이템을 30개씩만 추가해준다면 적어도 멈춤 현상은 일어나지 않을 겁니다. 그 대신 여러 프레임에 걸쳐 추가를 해주게 되니 시간이 좀 더 오래 걸릴 수도 있겠죠.
결과는 해봐야 아니 곧바로 구현을 해봅시다. 먼저 매우 단순한 형태의 큐를 만들어 ChangeQueue.js란 파일로 저장해줍니다.
var changeQueue = (function() {
var list = [];
return {
enqueue: function(c) {
list.push(c);
},
dequeue: function() {
return list.shift();
},
isEmpty: function() {
return list.length === 0;
}
}
})();
이건 그저 배열을 감싸준 클래스라고 보면 됩니다. 제공하는 함수는 총 3개로, 항목을 추가하기 위한 enqueue(), 항목을 빼기 위한 dequeue(), 그리고 비어있는지 여부를 알기 위한 isEmpty() 함수가 있죠. 그리고 생성한 큐를 changeQueue란 전역 변수로 선언해주고 있습니다.
새로운 자바스크립트 파일을 생성했으니 HTML에도 추가해줍니다.
<script src="jquery-3.3.1.min.js"></script>
<script src="ChangeQueue.js"></script>
<script src="scripts.js"></script>
이제 scripts.js를 수정해봅시다.
// 데이터로 셀렉트박스 항목 만들기
for (var i = 0; i < data.length; i++) {
var elem = createItem(data[i]);
$('#list').append(elem);
}
기존의 이 부분을 다음과 같이 바꿔줍니다.
console.time();
// 데이터로 셀렉트박스 항목 만들기
for (let i = 0; i < data.length; i++) {
changeQueue.enqueue({
execute: function() {
const elem = createItem(data[i]);
$('#list').append(elem);
}
});
}
// 반복적으로 큐를 체크하여 30개씩 실행
setInterval(function() {
for (var i = 0; i < 30 && !changeQueue.isEmpty(); i++) {
var c = changeQueue.dequeue();
if (c)
c.execute();
if (changeQueue.isEmpty())
console.timeEnd();
}
}, 0);
보다 정확한 실행시간을 재기 위해 console.time()을 사용하겠습니다.
가장 달라진 부분은 for문에서 바로 createItem() 해서 append() 하지 않고, 해당 로직을 함수로 감싼 후에 changeQueue에 넣어주는 부분입니다. 이렇게 함으로써 100,000개를 한 번에 추가하지 않고 나중에 필요에 따라 큐에서 꺼내서 추가할 수가 있죠. (여기선 예제 코드를 간략하게 표현하기 위해 ES6의 let을 사용하였지만, 사용할 수 없는 상황이라면 IIFE로 감싸주어야 합니다.)
그 다음 부분은 setInterval()로 최대한 자주(0ms) 큐에서 30개씩 빼서 실행시키는 부분입니다. 큐가 빌 때 까지 계속 실행하다가 더 이상 처리할 게 없으면 console.timeEnd()로 전체 실행 시간을 표시하게 됩니다.
어느 정도 완성된 것 같으니 다시 브라우저에서 확인해봅시다.

카운터도 정상적으로 올라가고, 텍스트 선택도 잘 되는 등 먹통 현상이 보이지 않는 걸 알 수 있죠.

수행 시간은 약 65초가 걸리는 걸 볼 수 있습니다. (컴퓨터 환경에 따라 다를 수 있습니다.)
먹통이 되진 않으니 훨씬 나아졌다고 볼 수 있지만, 페이지 로드 후 65초간 전체 목록을 볼 수 없다는 건 좀 문제가 있어 보입니다. 코드를 좀 더 개선해서 저 시간을 줄여봅시다.
requestIdleCallback() 사용하기
우리는 setInterval()을 사용하여 하나의 커다란 일을 여러 개의 작은 단위의 일로 성공적으로 분배하였죠. 그런데 그 효율성에 대해서는 좀 의문이 생깁니다.

우리가 추구하는 모습은 위쪽 그림과 같이 버려지는 시스템 리소스가 없으면서도 프레임이 지연되지 않는 완벽한 상황입니다. 하지만 현실이 이렇게 아름다울리는 없겠죠. 실제로 동작하는 걸 프로파일링 해보면 아래쪽 그림에 가깝게 나옵니다. 어떤 프레임에서는 30번을 실행했을 때 실행시간이 다음 프레임까지 침범하기도 하고, 어떤 프레임에선 아예 실행조차 하지 않은 채 다음 프레임으로 넘어가기도 합니다.
이 두 가지 이슈에 대해 좀 더 알아봅시다.
실행시간이 너무 길어지는 문제는 이미 겪었던 문제이죠. 해당 프레임에 남은 시간이 30번을 수행하기에 너무 짧았기 때문에 발생하는 이슈입니다. 그런데, 이 '30'이라는 숫자는 어떻게 나온 걸까요? 사실 아무 의미 없습니다. 그냥 제가 적당해 보이는 숫자를 적어 놓은 거니까요.... 사실 각 프레임마다 남아 있는 여유시간은 전혀 일정하지 않기 때문에 저렇게 같은 숫자를 사용한다는 것 자체가 말이 안 됩니다. 또한, CPU의 성능에 따라서도 큰 차이가 있을 겁니다. 따라서, 남은 시간이 얼마 없을 땐 적은 양을, 많을 땐 많은 양을 처리할 수 있어야 보다 효율적인 코드가 되겠죠.
또한, 두 번째 문제는 setInterval()과 setTimeout()의 동작 원리에 의해 발생하는 이슈입니다. 두 번째 인자로 넣는 delay 인자는 정확함과는 거리가 멉니다. 스펙에서 보장해주는 건 주어진 딜레이 이내로는 실행하지 않는다는 것 딱 하나 뿐입니다. 우리처럼 0을 넣은 경우에는 더더욱 의미가 없는 내용이죠. 따라서 어느 정도 동작은 하지만 최적의 솔루션은 아닌 걸 알 수 있습니다.
다행스럽게도 크롬과 파이어폭스에서는 requestIdleCallback()이란 함수를 제공합니다. (Edge와 사파리는 적용 검토 중)
이 함수는 우리가 미리 등록해놓은 함수를 브라우저가 할 일이 없을 때 실행해줍니다. 즉, 매 프레임마다 브라우저가 판단해서 남은 시간이 충분하다 싶으면 동록되어 있는 함수를 호출하는 것이죠.
다음 코드를 보겠습니다.
// 데이터로 셀렉트박스 항목 만들기
for (let i = 0; i < data.length; i++) {
changeQueue.enqueue({
execute: function() {
const elem = createItem(data[i]);
$('#list').append(elem);
}
});
}
requestIdleCallback(processChanges);
});
function processChanges(deadline) {
while (deadline.timeRemaining() > 0 && !changeQueue.isEmpty()) {
var c = changeQueue.dequeue();
if (c)
c.execute();
}
if (!changeQueue.isEmpty())
requestIdleCallback(processChanges);
else
console.timeEnd();
}
코드를 보면 changeQueue에 모두 추가한 후에 requestIdleCallback()을 호출하고 processChanges()라는 함수를 인자로 넘기는 걸 알 수 있습니다. 이 함수는 기존의 setInterval()을 사용한 코드와 크게 다르지는 않지만 한 가지 큰 차이점이 있죠. 바로 deadline.timeRemaining()이라는 함수입니다.
deadline.timeRemaining() 함수는 현재 예상되는 남은 시간을 리턴해줍니다. 만약 리턴값이 3이라면 남은 여유 시간의 추정치가 3ms라는 뜻이죠.
그러므로 우리는 리턴값이 0보다 큰 지를 체크하는 것 만으로도 시간을 효율적으로 사용할 수 있습니다. 시간이 아직 남아 있다면 큐에서 빼서 실행을 시켜주고, 시간이 없다면 다시 requestIdleCallback()을 호출해 다음 프레임에서 실행되기를 기다리면 되죠.
그럼 브라우저에서 결과를 확인해봅시다.

아까 약 65초 걸렸던 작업이 약 46초로 줄어든 걸 확인할 수 있습니다.
아직 멀었습니다. 좀 더 개선해보죠.
requestAnimationFrame() 사용하기
사실 requestIdleCallback()은 DOM을 제어하기 위해 만들어진 함수가 아닙니다. 이 목적으로 탄생한 건 흔히 rAF라 불리우는 requestAnimationFrame()이죠.
requestAnimationFrame()은 한 가지 큰 특징을 가지고 있습니다. 바로, 하나의 프레임에서 렌더링 전에 실행된다는 점입니다.
다음 그림을 보겠습니다.

setTimeout() 등이 언제 실행될 지 알 수 없는 반면에, requestAnimationFrame()은 무조건 각 프레임의 스타일 계산, 렌더링 전에 실행되도록 정의되어 있습니다. 이러한 특징으로 인해 얻을 수 있는 장점이 크게 두 가지가 있습니다.
일반 자바스크립트 코드 실행에 비해, DOM 트리를 접근하는 작업은 매우 느리고 소요 시간을 예측하기 어렵기 때문에, 아무리 requestIdleCallback()에서 남은 시간을 계산해서 실행했더라도 실행 시간이 프레임 범위를 넘어갈 가능성이 상당히 높습니다. 따라서 프레임 시작 시로 DOM 관련 코드를 옮겨 놓으면, 여유있게 DOM 트리를 갱신한 후, requestIdleCallback()이 나머지 시간에 작업을 수행할 수 있을 겁니다.
또, requestIdleCallback()은 스타일 계산이 끝난 후에 실행되므로 만약 DOM을 수정하고 clientWidth와 같은 속성을 읽으려하면 다시 스타일 계산을 수행해야만 하는 상황이 벌어집니다. 이런 불필요한 중복 실행을 최대한 피해야만 우리가 원하는 성능을 얻을 수 있겠죠.
그럼, DOM 관련 코드를 requestAnimationFrame()으로 넘겨봅시다. 딱 한 줄만 수정해주면 됩니다.
function processChanges(deadline) {
while (deadline.timeRemaining() > 0 && !changeQueue.isEmpty()) {
var c = changeQueue.dequeue();
if (c)
requestAnimationFrame(c.execute);
}
...
}
이제 결과를 브라우저에서 다시 확인해봅시다.

기존의 약 46초에서 34초 정도로 줄어든 걸 볼 수 있죠. 하지만 아직도 갈 길이 멉니다.
jQuery 들어내기
jQuery는 훌륭한 라이브러리이지만, 추가 라이브러리를 사용하는 것이 기본 자바스크립트만 사용하는 것에 비해서 성능이 더 좋을 수는 없습니다. 100개 정도의 아이템을 다룰 때는 아무 문제 없지만 지금처럼 성능을 극한으로 추구해야 하는 상황에선 손해를 볼 수 밖에 없죠.
그러므로 우린 100,000번의 룹을 도는 부분의 코드를 jQuery를 사용하지 않는 형태로 고쳐볼 겁니다.
// 데이터로 셀렉트박스 항목 만들기
for (let i = 0; i < data.length; i++) {
changeQueue.enqueue({
execute: function() {
const elem = createItem(data[i]);
document.getElementById('list').appendChild(elem);
}
});
}
...
function createItem(d) {
var elem = document.createElement('li');
elem.textContent = d;
elem.classList.add('item');
elem.addEventListener('click', function() {
$('#title').html(d);
$('#popup').hide();
});
return elem;
}
$() 셀렉터를 document.getElementById()로 바꾸고, $('<>')를 사용해서 태그를 생성하던 걸 document.createElement()로 만들도록 수정했습니다.
또한, 클릭 이벤트 핸들러도 addEventListener()로 붙여주고 있죠. 하지만, 핸들러 안의 코드는 수정하지 않았습니다. 이 코드는 100,000번을 수행하진 않을 것이므로 큰 상관이 없기 때문입니다.
다시 브라우저에서 확인해봅시다.

이제 약 14초 정도로 줄어들었습니다.
DocumentFragment 사용하기
아까도 언급했지만, DOM 트리를 접근하는 건 상당히 속도가 느립니다. 따라서 DOM 트리에 접근하는 걸 최대한 줄이는 게 빠른 속도를 위한 길이겠죠. 하지만 현재 한 프레임에서 30개의 <li>태그를 추가한다고 치면 적어도 30번의 접근은 필요하게 됩니다.
이런 경우에 DocumentFragment를 사용하면 좋은 성능을 기대할 수 있습니다. 코드에 적용해보도록 하죠.
// 데이터로 셀렉트박스 항목 만들기
for (let i = 0; i < data.length; i++) {
changeQueue.enqueue({
execute: function(fragment) {
const elem = createItem(data[i]);
// 입력받은 fragment에 추가
fragment.appendChild(elem);
}
});
}
...
function processChanges(deadline) {
// DocumentFragment 생성
var fragment = document.createDocumentFragment();
while (deadline.timeRemaining() > 0 && !changeQueue.isEmpty()) {
var c = changeQueue.dequeue();
if (c)
c.execute(fragment);
}
requestAnimationFrame(function() {
// 개별 <li>태그 대신 fragment를 추가
document.getElementById('list').appendChild(fragment);
});
if (!changeQueue.isEmpty())
requestIdleCallback(processChanges);
else
console.timeEnd();
}
30개의 <li>태그를 추가한다고 가정했을 때 지금까지 해왔던 방식과 DocumentFragment를 사용한 방식의 차이점을 알아봅시다.

DocumentFragment는 마치 하나의 가상 DOM 엘리먼트처럼 동작합니다. 따라서 fragment에 다른 엘리먼트를 추가하거나 제거할 수 있죠. 하지만 마지막에 DOM 트리에 fragment를 추가할 땐 좀 다른 결과가 나옵니다.
일반 DOM 엘리먼트와 동일하다면 #list 밑에 fragment가 들어가고 그 밑에 30개의 <li>태그가 생겼겠지만, DocumentFragment는 자신을 제외한 하위 엘리먼트들만 옮겨줍니다. 즉, #list 바로 밑에 30개의 <li>태그가 생기게 되는 것이죠. 결과는 동일하면서 속도는 개선되는 바람직한 방법이라 할 수 있습니다.
그럼 브라우저에서 결과를 확인해봅시다.

약 11초로 줄어든 걸 볼 수 있습니다.
dequeue() 개선하기
이 시점에서 개발자 도구로 프로파일링을 해보면, 다음과 비슷한 그림이 나올 겁니다.

다른 부분들의 성능이 상당히 좋아져서 ChangeQueue의 dequeue(), 즉, shift() 함수의 성능이 못 받쳐주는 상황이 된 거죠.
shift()는 배열의 0번째 항목을 제거하고 리턴해주는 함수이므로 배열의 모든 항목들을 다시 재인덱싱 하는 과정이 필요합니다. 당연히 배열의 크기가 커질 수록 느려질 수 밖에 없겠죠. 그러므로 좀 더 좋은 방법이 필요합니다.
큐를 구현하는 방법은 여러가지가 있겠지만, 여기선 단순한 방법으로 바꿔보겠습니다.
var changeQueue = (function() {
var list = [];
var index = 0;
return {
enqueue: function(c) {
list.push(c);
},
dequeue: function() {
var o = list[index];
index++;
return o;
},
isEmpty: function() {
return list.length - index === 0;
}
}
})();
이 코드에선 dequeue()할 때 현재 index에 있는 값을 리턴하고 index를 1만큼 증가시킵니다. 이렇게 하면 dequeue()를 하더라도 배열의 나머지 항목들이 건드려질 필요가 없으므로 속도가 상당히 빨라지겠죠. 물론 큐가 무한히 커질 수는 없을테니 어느 정도 한계선을 정해놓고 다시 index를 다시 처음으로 리셋해주는 로직이 들어가야 하겠지만, 예제의 간결함을 위해 생략했습니다.
isEmpty() 또한 그에 맞춰 변경되어야겠죠. 배열에서 실제로 제거되는 항목이 없는 만큼, 배열의 길이와 index값을 비교해서 빈 여부를 리턴해줍니다.
그럼 이제 최종 결과를 살펴보겠습니다.

약 1.4초로 줄어든 걸 확인할 수 있습니다.
마치며
상당히 긴 여정이었습니다.
우리는 셀렉트박스 생성 시간이 10초간 먹통에서 시작하여, 65초 > 46초 > 34초 > 14초 > 11초 > 1.4초로 줄어드는 과정을 같이 알아보았습니다.
사실 아직도 좀 더 튜닝할 수 있는 부분들이 있겠지만, 세부 튜닝은 구현이 모두 끝난 뒤에 수행하는 게 나을 것 같습니다. 아직 우린 셀렉트박스의 절반 밖에 구현하지 못했으니까요.
현재까지의 코드로는 생성하는 부분만 빨라졌을 뿐이지, 막상 셀렉트박스를 클릭하면 100,000개의 아이템을 다루느라 브라우저가 또 먹통이 되는 현상을 볼 수 있을 겁니다. 아이템 목록을 스크롤하는 것도 매우 버벅거리죠.
이 부분을 개선하는 과정은 다음 글에서 다루려고 합니다.
긴 글 읽어주셔서 감사합니다.