2. let - 반복문 & debounce


반복문

일반적으로 재할당 가능 변수(let)을 선언하는 또다른 경우로 반복문이 있습니다.
이번에는 동적으로 과일 목록 html 엘리먼트를 생성하는 예제를 살펴봅시다.

1. 기본 코드

1
2
3
4
5
6
7
8
9
10
11
const buildListElem = list => {
let elem = '<ul>'
for (let i = 0; i < list.length; i++) {
elem += '<li>' + list[i] + '</li>'
}
elem += '</ul>'
return elem
}
const fruits = ['바나나', '사과', '배', '딸기', '귤']
const listElem = buildListElem(fruits)
document.body.innerHTML += listElem

9행에서 buildListElem 함수를 호출하였습니다. buildListElem 함수는 list 라는 배열을 받습니다.
2행에서 변경 가능한 변수 elem을 선언하고, 여기에 ‘<ul>‘을 저장했습니다.
3행부터 5행까지는 for 반복문 내에서 인덱싱을 목적으로 하는 변경 가능한 변수 i를 선언하여 i의 값을 1씩 증가시키면서 elem에 문자열을 추가해 나갑니다.
for 반복문을 마친 후인 6행에서는 ul 마침태그를 추가해주고, 7행에서 최종 elem을 반환해줍니다.
반환된 결과는 9행의 listElem 변수에 저장됩니다.
10행에서는 innerHTML에 직접 접근하여 listElem에 저장된 내용을 HTML로써 삽입합니다.

이번 예제에서는 let이 총 두 번 등장했습니다. 리스트 정보를 생성하기 위한 문자열 변수 elem과, list의 인덱싱을 처리하기 위한 숫자형 변수 i입니다.
이것만으로도 문제 없이 동작하긴 하지만, 더 나은 방법들을 계속 살펴봅시다.

2. document.createElement

우선 innerHTML에 직접 접근하여 HTML 엘리먼트를 제어하는 것은 위험하고 바람직하지 않습니다. 여는 태그와 닫는 태그를 정확히 매칭시키지 못하는 실수를 일으킬 가능성이 높을 뿐 아니라, 문자열로 이루어진 형태를 강제로 HTML로 여기도록 하는 방식은 브라우저에 생각보다 큰 부담을 줍니다.
이런 위험을 제거하기 위해, 코드가 조금 길어지긴 하지만 innerHTML 대신 다른 기법을 사용해 봅시다.

1
2
3
4
5
6
7
8
9
10
11
12
const buildListElem = list => {
const ul = document.createElement('ul')
for (let i = 0; i < list.length; i++) {
const li = document.createElement('li')
li.innerText = list[i]
ul.append(li)
}
return ul
}
const fruits = ['바나나', '사과', '배', '딸기', '귤']
const listElem = buildListElem(fruits)
document.body.append(listElem)

이렇게 바꾸고 보니 여는 태그와 닫는 태그에 대해 고려할 필요가 없어져서 마지막까지 문자열을 조합하는 방식에 비해 실수할 가능성이 많이 줄어들었습니다. innerHTML 대신 append를 활용함으로써 브라우저에 주는 부담도 줄였습니다. 그렇지만 반복문에서는 여전히 실수할 가능성이 남아있습니다. 예를 들어 for문의 범위를 설정하는 부분에서 < 대신 <=를 쓴다거나, 변화를 설정하는 부분에서 i++ 대신 ++i를 쓴다면 그 결과는 완전히 달라지게 될 것입니다. 범위를 list.length까지로 설정해야 하는지, 혹은 list.length - 1까지로 설정해야 하는지도 혼란스럽습니다. 이처럼 for문은 개발자로 하여금 실수할 수 있는 여지를 많이 내포하고 있습니다.

위 방식보다 insertAdjacentHTML을 사용하는 것이 성능 면에서 더 낫긴 하지만 작성해야 하는 코드가 전반적으로 innerHTML과 크게 다르지 않기 때문에 생략합니다.

3. forEach

반복문 대신 배열의 메서드인 forEach를 활용해 봅시다.

1
2
3
4
5
6
7
8
9
10
11
12
const buildListElem = list => {
const ul = document.createElement('ul')
list.forEach(value => {
const li = document.createElement('li')
li.innerText = value
ul.append(li)
})
return ul
}
const fruits = ['바나나', '사과', '배', '딸기', '귤']
const listElem = buildListElem(fruits)
document.body.append(listElem)

forEach는 배열의 첫번째 값부터 마지막 값까지를 차례로 순회하면서 콜백함수를 실행합니다. list[i] 대신 forEach가 콜백함수를 호출할 때 자동으로 넘겨주는 인자를 그대로 활용하였습니다(item). forEach 메서드는 모든 개발자가 반드시 알고 있어야 하는 메서드이므로 첫번째 인자에 어떤 값이 올 것인지도 정확히 인지할 수 있습니다. 또한 i값을 증가시키고, 범위를 설정하는 등의 부담을 지지 않아도 되므로 실수할 여지가 현저히 줄어듭니다.

4. reduce

이번에는 3.을 바탕으로 배열 메서드인 reduce를 활용해 보겠습니다. reduce는 처음 접할 때엔 다소 난이도가 있습니다. 추후 배열 챕터에서 제대로 다루기 전에 먼저 대략적으로나마 감을 잡아보자는 차원에서 소개합니다.

1
2
3
4
5
6
7
8
9
10
11
const buildListElem = list => {
return list.reduce((ul, value) => {
const li = document.createElement('li')
li.innerText = value
ul.append(li)
return ul
}, document.createElement('ul'))
}
const fruits = ['바나나', '사과', '배', '딸기', '귤']
const listElem = buildListElem(fruits)
document.body.append(listElem)

reduce는 배열의 첫번째 값부터 마지막 값까지를 차례로 순회하면서 콜백함수를 실행하는데, 콜백함수의 첫번째 인자에는 바로 직전 콜백함수에서 반환한 결과가 담겨 있습니다. 콜백함수가 처음 호출될 때에는 함수 뒤에 지정해준 값이 첫번째 인자가 됩니다. 따라서 ul이라는 변수를 선언하지 않고도 배열 순회만으로 원하는 결과를 바로 도출해낼 수 있습니다.

간단한 debounce 구현

이번엔 난이도 높은 다른 예제를 소개하겠습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
const simpleDebounce = (callback, delay) => {
let timeoutId = null
return () => {
if (timeoutId) clearTimeout(timeoutId)
timeoutId = setTimeout(callback, delay)
}
}
const resizeHandler = () => {
const pElem = document.createElement('p')
pElem.innerText = `w: ${window.innerWidth}, h: ${window.innerHeight}`
document.body.appendChild(pElem)
}
const onResize = simpleDebounce(resizeHandler, 300)

window.addEventListener('resize', onResize)

말 그대로 간단한 debounce 함수입니다. debounce란 같은 형태의 입력이 일정 시간 간격 내에 연속적으로 발생할 경우 그 중 하나의 입력만을 처리하고 나머지는 무시하는 기법입니다. 비슷한 개념으로 throttle이 있는데, throttle은 연속적으로 발생하는 입력값들 중 일정 시간 간격마다 하나씩만 취하는 기법입니다. 같은 말인 것 같지만 엄연히 동작 방식과 사용 목적이 다릅니다.

예를 들어 위 예제에서처럼 시간 간격을 300ms(0.3초)로 한 상태에서 2.1초 동안 균일한 속도로 창크기를 조절할 경우, debounce에 의하면 마지막 시점으로부터 0.3초 뒤에 결과값이 딱 한 번만 출력됩니다. 반면 throttle에 의할 경우 0.3초마다 한 번씩 결과값이 출력되어 총 7번의 출력물을 확인하게 됩니다. 따라서 값의 변화를 주기적으로 체크하여 처리할 필요가 있는 경우에는 throttle을, 마지막(또는 첫번째) 결과만을 활용하고자 할 때엔 debounce를 씁니다. 드래그 이벤트처럼 마우스의 위치를 주기적으로 파악하여 꾸준히 어떤 대상의 위치를 조정해줘야 할 경우에는 throttle이 적합하겠죠. 반면 가로폭의 길이 변화에 따라 보여줄 내용을 달리해야 할 경우에는 마지막 한 번만 체크하는 것으로 충분하므로 debounce가 적합할 것입니다.

두 기법 모두 짧은 시간 간격으로 연달아 발생하는 사용자 이벤트 등에 대한 처리 효율을 높이기 위해 활용합니다. 이 둘은 프론트엔드의 성능 최적화를 위해 빼놓을 수 없는 중요한 기법입니다.

simpleDebounce는 클로저를 활용하였습니다. 변경가능한 timeoutId 변수를 선언하고 함수를 반환합니다. 반환되는 함수는 timeoutId에 값이 설정되어 있는 경우(setTimeout이 이미 실행되었으나 delay 만큼의 시간이 경과되지는 않은 상태인 경우) 이를 취소시키고(clearTimeout), 새롭게 setTimeout을 설정하여 그 때 생성된 id(콜백 함수를 실행시키거나 취소시키기 위한 식별자)를 timeoutId 변수에 재할당합니다.

resize 이벤트가 발생한 이후 0.3초 이내에 다시 resize 이벤트가 발생한 경우, 앞서 발생한 이벤트에 대한 처리를 취소하고 다시 delay 시간 후에 콜백을 실행하도록 등록합니다. 사용자가 브라우저 크기를 조절할 경우 resize 이벤트는 브라우저 환경에 따라 약 1ms~20ms에 한 번씩 연속해서 발생하게 되는데, 바로 직전의 이벤트와 다음 이벤트 간격이 0.3초 이내에 있으므로 마지막 바로 앞까지의 이벤트는 모두 취소되고, 마지막 이벤트로부터 0.3초 후에야 비로소 콜백함수를 호출하게 됩니다. 일반적으로 사람은 마우스를 완전히 균일한 속도로 움직이게 할 수는 없기 때문에 순간 순간 멈추는 경우가 발생하곤 하는데, 이러한 경우에도 0.3초 이내에 다시 움직이기만 한다면 무시할 수 있도록 설정한 것입니다. 시간 간격을 0.3초보다 줄일 경우 변경 완료 시점과 resizeHandler 함수가 실행되는 시간 사이의 간격이 줄어드는 대신, 경우에 따라 resizeHandler 함수가 실행되는 횟수가 더 많아질 수 있습니다.반면 시간 간격을 0.3초보다 늘릴 경우 변경 완료 시점으로부터 resizeHandler 함수가 실행되는 시간은 더 늦어지겠지만, resizeHandler가 창 크기를 조절하는 중간에 원치 않게 실행되는 경우는 줄어들겠죠.

어쨌든 위 예제에서는 timeoutId라는 재할당 가능한 변수를 이용하여 간단하게(?) debounce를 구현하였습니다. lodash, underscore 등의 라이브러리에서 제공하는 debounce는 제가 소개한 간단한 debounce 함수에 비해 훨씬 복잡하고 편리한 기능을 담고 있습니다. 그렇지만 상황에 따라 앞서 구현한 기능 만으로 충분한 경우도 많이 있습니다.