JavaScript 핵심 개념은 양이 많은 관계로 10문항씩 나눠서 정리하고 있습니다. 저번 시간에 이어서 이번 시간에도 나머지 JavaScript 핵심 개념에 대해 알아보도록 하겠습니다.
DOM 트리 상에 존재하는 DOM 요소 노드에서 발생한 이벤트는 DOM 트리를 통해 전파됩니다. 이를 이벤트 전파(event propagatoin)이라고 합니다. 이벤트 전파는 이벤트 객체가 전파되는 방향에 따라 다음과 같이 3단계로 구분할 수 있습니다.
예를 들어, 아래와 같은 DOM 요소가 있습니다. 여기서 li 중 하나를 클릭했다고 가정해보죠.
<body>
<ul id="fruits">
<li id="apple">Apple</li>
<li id="banana">Banana</li>
<li id="orange">Orange</li>
</ul>
</body>
다음과 같이 작성한 후 ul 요소에 이벤트 핸들러를 바인딩시켜봅니다. 이 때 이벤트 target은 li 요소이고, currentTarget은 ul 요소입니다.
const $fruits = document.getElementById('fruits');
// fruits의 하위 요소인 li를 클릭한 경우
$fruits.addEventListener('click, e => {
console.log(`이벤트 단계: ${e.eventPhase}`);
console.log(e.target); // [object HTMLLIElement]
console.log(e.currentTarget); // [object HTMLULElement]
});
이처럼 이벤트는 이벤트를 발생시킨 이벤트 타깃은 물론 상위 DOM 요소에서도 캐치할 수 있습니다. 이것을 이벤트 버블링이라고 합니다. 참고로, addEventListener 메서드 방식으로 등록한 이벤트 핸들러는 타깃 단계와 버블링 단계뿐만 아니라 캡처링 단계의 이벤트도 선별적으로 캐치할 수 있습니다. 캡처링 단계의 이벤트를 캐치하려면 3번째 인수로 true를 전달해야 합니다.
모든 자식 요소에 클릭 이벤트에 반응하도록 이벤트 핸들러를 등록하면, 이는 많은 DOM 요소에 이벤트 핸들러를 등록하므로 성능 저하의 원인이 될 뿐더러 유지보수에도 부적합한 코드를 생산하게 됩니다.
이벤트 위임은 여러 개의 하위 DOM 요소에 각각 이벤트 핸들러를 등록하는 대신 하나의 상위 DOM 요소에 이벤트 핸들러를 등록하는 방법을 말합니다. 이벤트 전파에서 살펴본 바와 같이 이벤트는 이벤트 타깃은 물론 상위 DOM 요소에서도 캐치할 수 있다는 것을 이용한 것입니다. 이벤트가 발생한 요소를 특정하기 위해 event.target
을 사용할 수 있습니다.
아래 예시는 모든 li 요소에 이벤트 핸들러를 등록하는 대신 하나의 상위 요소인 ul 에게만 이벤트 핸들러를 등록했습니다.
const $fruits = document.getElementById('fruits');
function activate({ target }) {
if (!target.matches('#fruits > li') return;
[...$fruits.children].forEach($fruit => {
$fruit.classList.toggle('active', $fruit === target);
})
}
$fruits.onclick = activate;
모든 식별자(변수 이름, 함수 이름, 클래스 이름 등)는 자신이 선언된 위치에 의해 다른 코드가 식별자 자신을 참조할 수 있는 유효 범위가 결정됩니다. 이를 스코프라고 합니다. 즉, 스코프는 식별자가 유효한 범위를 말합니다.
자바스크립트 엔진은 스코프를 통해 어떤 변수를 참조해야 할 것인지 결정합니다. 따라서 스코프란 자바스크립트 엔진이 식별자를 검색할 때 사용하는 규칙이라고 할 수 있습니다. 아래 예시 처럼 말이죠.
var x = 'global';
function foo() {
var x = 'local';
console.log(x); // 1) 'local'
}
foo();
console.log(x); // 2) ?
전역에서 선언된 변수는 전역 스코프를 갖는 전역 변수고, 지역에서 선언된 변수는 지역 스코프를 갖는 지역 변수입니다. 전역 변수는 어디서든지 참조할 수 있습니다. 심지어 함수 내부에서도 참조 가능합니다. 반면, 지역 변수는 자신의 지역 스코프와 하위 지역 스코프에서만 유효합니다.
함수는 전역에서도 정의할 수 있고 함수 몸체 내부에서도 정의할 수 있습니다. 함수 몸체 내부에서 정의한 함수를 중첩 함수 혹은 내부 함수라고 하며, 중첩 함수를 포함하는 함수를 외부 함수라고 합니다. 함수가 중첩될 수 있으므로 함수의 지역 스코프도 중첩될 수 있습니다. 이는 스코프가 함수의 중첩에 의해 계층적 구조를 갖는다는 것을 의미합니다. 모든 스코프는 하나의 계층적 구조로 연결되며, 모든 지역 스코프의 최상위 스코프는 전역 스코프입니다. 이렇게 스코프가 계층적으로 연결된 것을 스코프 체인이라 합니다.
변수를 참조할 때 자바스크립트 엔진은 스코프 체인을 통해 변수를 참조하는 코드의 스코프에서 시작하여 상위 스코프 방향으로 이동하며 선언된 변수를 검색합니다. 이를 통해 상위 스코프(outer)에서 선언한 변수를 하위 스코프(inner)에서도 참조할 수 있습니다.
스코프 체인은 물리적인 실체로 존재합니다. 코드의 문맥 정보를 실행 컨텍스트 렉시컬 환경을 통해 생성하며, 이를 통해 스코프 체인이 일어날 수 있는 것입니다.
아래 예시를 살펴보겠습니다. 솔직히 좀 헷갈립니다. ㅎㅎ
var x = 1;
function foo() {
var x = 10;
bar();
}
function bar() {
console.log(x)
}
foo() // ?
bar() // ?
위 예제는 중요한 개념을 포함하고 있습니다. 바로 bar 함수의 상위 스코프는 무엇에 의해 결정되는가 알 수 있습니다.
첫번째 방식으로 결정되는 것을 동적 스코프라고 합니다. 두번째 방식은 렉시컬 스코프 혹은 정적 스코프라고 합니다. 자바스크립트를 포함한 대부분의 언어는 렉시컬 스코프를 따릅니다.
위 예제의 bar 함수는 전역에서 정의된 함수입니다. 이 때 자신이 정의된 스코프, 즉 전역 스코프를 기억하며 bar 함수가 호출된 곳이 어디인지 관계없이 언제나 자신이 기억하고 있는 전역 스코프를 상위 스코프로 사용합니다. 그래서 위 정답은 1, 1 입니다.
코드가 실행되려면 스코프, 식별자, 코드 실행 순서 등의 관리가 필요합니다. 이 모든 것을 관리하는 것이 바로 실행 컨텍스트입니다. 실행 컨텍스트는 소스코드를 실행하는데 필요한 환경을 제공하고 코드의 실행 결과를 실제로 관리하는 영역입니다. 식별자와 스코프는 실행 컨텍스트의 렉시컬 환경으로 관리하고, 코드 실행 순서는 실행 컨텍스트 스택으로 관리합니다.
렉시컬 환경(Lexical Environment)은 식별자와 식별자에 바인딩된 값, 그리고 상위 스코프에 대한 참조를 기록하는 자료구조로 실행 컨텍스트를 구성하는 컴포넌트입니다. 렉시컬 환경은 다음과 같이 두개의 컴포넌트로 구성됩니다.
실행 컨텍스트는 스택 자료구조로 관리됩니다. 이를 실행 컨텍스트 스택이라 부릅니다.
실행 컨텍스트 스택은 코드의 실행 순서를 관리합니다. 실행 컨텍스트 스택의 최상위에 존재하는 실행 컨텍스트는 언제나 현재 실행 중인 코드의 실행 컨텍스트입니다.
객체에서 동작을 나타내는 메서드는 자신이 속한 객체의 상태, 즉 프로퍼티를 참조하고 변경할 수 있어야 합니다. 근데 그러려면 먼저 자신이 속한 객체를 가리키는 식별자를 참조할 수 있어야 합니다. 따라서 자신이 속한 객체 또는 자신이 생성할 인스턴스를 가리키는 특수한 식별자가 필요합니다. 이를 위해 자바스크립트는 this라는 특수한 식별자를 제공합니다. 즉, this는 자기 참조 변수입니다.
단, this가 가리키는 값, 즉 this 바인딩은 함수 호출 방식에 의해 동적으로 결정된다. (이게 또 은근 헷갈리게 만들죠)
// 객체 리터럴
const circle = {
radius: 5,
getDiameter() {
// this는 메서드를 호출한 객체를 가리킨다.
return 2 * this.radius;
}
}
console.log(circle.getDiameter()); // getDiameter는 circle로 호출되었으므로 this는 circle 인스턴스이다.
// 생성자 함수
function Circle(radius) {
// this는 생성자 함수가 생성할 인스턴스를 가리킨다
this.radius = radius;
}
Circle.prototype.getDiameter = function () {
// this는 생성자 함수가 생성할 인스턴스를 가리킨다
return 2 * this.radius;
}
const circle = new Circle(5);
console.log(circle.getDiameter()); // getDiameter는 circle로 호출되었으므로 this는 circle 인스턴스이다.
this는 코드 어디에서든 참조가 가능합니다. 전역에서도, 함수 내부에서도 참조할 수 있습니다.
외부 함수인 메서드와 중첩 함수 또는 콜백 함수의 this가 일치하지 않는다는 것은 중첩 함수 또는 콜백 함수를 헬퍼 함수로 동작하기 어렵게 만듭니다. 이 문제를 해결할 수 있는 방법은 여러개가 있습니다. (this-that, bind, 화살표 함수)
apply, call, bind 메서드는 Function.prototype의 메서드입니다. 즉, 이들 메서드는 모든 함수가 상속받아 사용할 수 있습니다. 이 세가지 메서드는 강제로(명시적으로) this를 바꿉니다. 다만 사용법은 조금씩 다릅니다.
apply, call 메서드는 this로 사용할 객체와 인수 리스트를 인수로 전달받아 함수를 호출합니다. apply와 call 메서드의 본질적인 기능은 함수를 호출하는 것입니다. 이 둘은 호출할 함수에 인수를 전달하는 방식만 다를 뿐 동일하게 동작합니다.
function getThisBinding() {
return this;
}
// this로 사용할 객체
const thisArg = { a: 1 };
console.log(getThisBinding()); // window
// getThisBinding 함수를 호출하면서 인수로 전달한 객체를 getThisBinding 함수의 this에 바인딩한다.
console.log(getThisBinding.apply(thisArg)); // {a: 1}
console.log(getThisBinding.call(thisArg)); // {a: 1}
그에 비해 bind 메서드는 apply와 call 메서드와 달리 함수를 호출하지 않습니다. 다만 첫번째 인수로 전달한 값으로 this 바인딩이 교체된 함수를 새롭게 생성해 반환합니다. bind 메서드는 메서드의 this와 메서드 내부의 중첩 함수 또는 콜백 함수의 this가 불일치하는 문제를 해결하기 위해 유용하게 사용됩니다.
const person = {
name: 'Lee',
foo(callback) {
setTimeout(callback.bind(this), 100);
}
}
person.foo(function () {
// 일반 함수로 호출된 콜백 함수 내부의 this.name은 브라우정 환경에서 window.name과 같다.
console.log(`Hi! my name is ${this.name}.`);
});
위 예제에서 외부 함수 내부의 this와 콜백 함수 내부의 this 가 상이해 문맥상 문제가 발생했습니다. 따라서 콜백 함수 내부의 this를 외부 함수 내부의 this와 일치시키는 것이 좋습니다. 이때 bind를 사용할 수 있습니다.
화살표 함수는 function 키워드 대신 화살표(⇒)를 사용하여 기존의 함수 정의 방식보다 간략하게 함수를 정의할 수 있습니다. 화살표 함수는 표현만 간략한 것이 아니라 내부 동작도 기존의 함수보다 간략합니다. 특히 화살표 함수는 콜백 함수 내부에서 this가 전역 객체를 가리키는 문제를 해결하기 위한 대안으로 유용합니다.
화살표 함수의 this는 일반 함수의 this와 다르게 동작합니다. 이는 “콜백 함수 내부의 this 문제”, 즉, 콜백 함수 내부의 this가 외부 함수의 this와 다르기 때문에 발생하는 문제를 해결하기 위해 의도적으로 설계된 것입니다.
아래 예시를 보면 “콜백 함수 내부의 this 문제”를 bind를 사용하지 않고 화살표 함수를 사용해서 간단히 해결할 수 있습니다.
const obj = {
value: 100,
foo() {
// 화살표 함수 내부의 this는 상위 스코프의 this를 가리킨다.
setTimeout(() => console.log(this.value), 100)
}
}
obj.foo();
다음 예제를 먼저 살펴보겠습니다.
const x = 1;
function outer() {
const x = 10;
const inner = function () { console.log(x); };
return inner;
}
// outer 함수를 호출하면 중첩 함수 inner를 반환한다
// 그리고 outer 함수의 실행 컨텍스트는 실행 컨텍스트 스택에서 pop되어 제거된다.
const innerFunc = outer();
innerFunc(); // 10
outer 함수의 실행 컨텍스트는 실행 컨텍스트 스택에서 pop되어 제거되므로 outer 함수의 지역 변수 x는 더는 유효하지 않아보입니다. 하지만 출력 결과는 10으로 나오고 있습니다. 이처럼 외부 함수보다 중첩 함수가 더 오래 유지되는 경우 중첩 함수는 이미 생명 주기가 종료한 외부 함수의 변수를 참조할 수 있습니다. 이러한 중첩 함수를 클로저라고 부릅니다.
중첩 함수 inner는 outer 함수의 렉시컬 환경을 상위 스코프로서 저장합니다. outer 함수의 실행이 종료되면 실행 컨텍스트 스택에서 제거됩니다. 이때 outer 함수의 실행 컨텍스트는 실행 컨텍스트 스택에서 제거되지만 outer 함수의 렉시컬 환경까지 소멸하는 것은 아닙니다. 왜냐면 여전히 inner 함수의 [[Environment]] 내부 슬롯에 의해 참조되고 있으므로 가비지 컬렉션의 대상이 되지 않습니다. 가비지 컬렉터는 누군가가 참조하고 있는 메모리 공간을 함부로 해제하지 않습니다.
✍️ 클로저는 중첩 함수가 상위 스코프의 식별자를 참조하고 있고 중첩 함수가 외부 함수보다 더 오래 유지되는 경우에 한정하는 것이 일반적입니다.
클로저는 상태를 안전하게 변경하고 유지하기 위해 사용할 수 있습니다 다시 말해, 상태가 의도치 않게 변경되지 않도록 상태를 안전하게 은닉하고 특정 함수에게만 상태 변경을 허용하기 위해 사용합니다.
아래 예시를 볼까요?
const increase = (function () {
let num = 0; // 카운트 상태 변수
// 클로저
return function () {
return ++num;
}
}());
console.log(increase()); // 1
console.log(increase()); // 2
위 코드는 실행되면 즉시 실행 함수가 호출되고 반환한 함수가 increase 변수에 할당됩니다. increase 변수에 할당된 함수는 자신이 정의된 위치에 의해 결정된 상위 스코프인 즉시 실행 함수의 렉시컬 환경을 기억하는 클로저입니다. 따라서 카운트 상태를 유지하기 위한 변수 num을 언제 어디서 호출하든지 참조하고 변경할 수 있습니다. 또한, num 변수는 외부에서 직접 접근할 수 없는 은닉된 private 변수이므로 전역 변수를 사용했을 때와 같이 의도하지 않은 변경을 걱정할 필요가 없기 때문에 더 안정적인 프로그래밍이 가능합니다.
scroll, resize, input, mousemove 같은 이벤트는 짧은 시간 간격으로 연속해서 발생합니다. 이러한 이벤트에 바인딩한 이벤트 핸들러는 과도하게 호출되어 성능에 문제를 일으킬 수 있습니다. 디바운스와 스로틀은 짧은 시간 간격으로 연속해서 발생하는 이벤트를 그룹화해서 과도한 이벤트 핸들러의 호출을 방지하는 프로그래밍 기법입니다.
디바운스와 스토플은 이벤트를 처리할 때 매우 유용합니다. 둘 다 타이머 함수가 사용된다는 공통점이 있습니다.
디바운스는 짧은 시간 간격으로 이벤트가 연속해서 발생하면 이벤트를 호출하지 않다가 일정 시간이 경과한 이후에 이벤트 핸들러가 한 번만 호출되도록 합니다. 디바운스는 input 으로 자동완성 UI 구현, resize 이벤트 처리, 버튼 중복 클릭 방지 처리 등에 유용하게 사용됩니다.
const $input = document.querySelector('input');
const debounce = (callback, delay) => {
let timerId;
return (...args) => {
// delay가 경과하기 전에 이벤트가 발생하면 이전 타이머를 취소하고 새로운 타이머를 설정한다.
// 따라서 delay보다 짧은 간격으로 이벤트가 발생하면 callback은 호출되지 않는다.
if (timerId) clearTimeout(timerId);
timerId = setTimeout(callback, delay, ...args);
}
}
// debounce 함수가 반환하는 클로저가 이벤트 핸들러로 등록된다.
$input.oninput = debounce(e => {
$msg.textContent = e.target.value;
}, 300);
스로틀은 짧은 시간 간격으로 이벤트가 연속해서 발생하더라도 일정 시간 간격으로 이벤트 핸들러가 최대 한 번만 호출되도록 합니다. 짧은 시간 간격으로 연속해서 발생하는 이벤트를 그룹화해서 일정 시간 간격으로 이벤트 핸들러를 호출하는 스로틀은 scroll 이벤트 처리나 무한 스크롤 UI 구현 등에 유용합니다.
const throttle = (callback, delay) => {
let timerId;
return (...args) => {
// delay가 경과하기 이전에 이벤트가 발생하면 아무것도 하지 않다가
// delay가 경과했을때 이벤트가 발생하면 새로운 타이머를 재설정한다.
// 따라서 delay 간격으로 callback이 호출된다.
if (timerId) return;
timerId = setTimeout(() => {
callback(...args);
timerId = null;
}, delay);
}
}
둘 다 값이 없음을 의미하지만 사용목적에 따라 차이가 있습니다.
이번 시간에도 자바스크립트의 중요한 개념에 대해 배워봤습니다. 아직 프로토타입, 클래스 등 못다룬 부분이 있기 때문에 다음 시간이 마지막이 될 거 같네요.