모던 JS 튜토리얼 필기

개인적으로 모던 JavaScript 튜토리얼 사이트를 보며 정리한 글 입니다.

Javascript logo

모던 JavaScript 튜토리얼 Part.1

2.5 자료형

1
2
let v = "modern javascript world";
v = 123;

자바스크립트(이하 JS)는 기본적으로 동적 타입언어이다. 따라서 위와 같은 코드가 허용된다.

  • 숫자형
    • 숫자형은 정수 및 부동소수점 숫자를 나타낸다. 사칙연산을 지원하며 일반적인 숫자 외에 Infinity, -Infinity, NaN 같은 특수한 숫자 값을 표현할 수 있다.
  • bigint
    • JS에서 숫자형은 내부 표현 방식으로 인해 제한된 범위의 숫자를 표현할 수 있다. 큰 정수를 다루기 위해서는 n prefix를 정수값 마지막에 붙여 bigint임을 표시하여 사용할 수 있다.
  • 문자형
    • JS에서 문자열은 따옴표로 묶어 표현한다. 여기서 작은 따옴표와 큰 따옴표에 대해선 차이를 두지 않지만, 역 따옴표에 대해선 내부적은 **${}**와 같은 식을 이용해 수식을 표현할 수 있다.
  • 불린형
  • null
    • JS에서 null은 다른 언어와 다른 성격을 가지는데 단순히 ‘존재하지 않는’, ‘null pointer’를 나타낸다면 JS은 다음과 같은 성격을 가진다.
      • 존재하지 않는(nothing) 값
      • 비어 있는 값
      • 알 수 없는 값
    • null의 경우 typeof를 통해 타입을 출력하면 ‘object’로 표현되지만 이는 하위 호환성을 위한 의도된 버그이며 null이 object가 아닌걸 유의해야한다.
  • undefined
    • undefined는 할당 되지 않은 상태를 의미한다. 개발자가 직접 이를 할당할 수 있지만 이는 권장되지 않으며 undefined를 할당하는 경우는 null로 대신 사용하며 undefined는 예약어로써 남겨두는걸 권장한다.
  • 객체형 (4.1 챕터)
    • 이전 까지 한 가지의 값을 표현할 수 있는 자료형을 원시 자료형이라 부른다. 객체형은 좀 더 복잡한 개체를 표현할 수 있다.
  • 심볼형 (4.7 챕터)

4.1 객체

객체형은 원시형 타입과 달린 다양한 형태의 데이터를 담을 수 있다. 객체는 중괄호{}를 통해서 만들 수 있고, 키와 값을 쌍으로 구성된 프로퍼티를 여러개 넣을 수 있다. 키는 문자열, 값은 자료형 이 허용된다.

in

객체에 존재하지 않는 프로퍼티에 접근하려면 다른 언어와 다르게 JS는 오류가 발생하지 않고 undefined 를 반환한다. 이를 통해 개발자는 객체에 해당 프로퍼티가 존재하는지 확인할 수 있다. 또는 in 키워드를 사용해 프로퍼티 유무를 확인할 수 있는데 이때 in 키워드의 좌측은 찾으려는 프로퍼티 이름을 따옴표로 표현해야 한다.

그렇다면 undefined와 in을 구별해서 사용할 필요가 있는가?에 대한 의문이 들수있다. 하지만 검사하려는 프로퍼티 값이 undefined인 경우 의도하지 않는 동작을 할 수 있기 때문에 프로퍼티 유무는 in 키워드를 사용할 필요가 있다.

for…in

for…in은 객체의 모든 키를 순회할 수 있다. 순회하는 순서는 객체의 프로퍼티가 정수 프로터피의 경우 오름차순으로 정렬되어 순회하며 그 외의 경우는 추가된 순서로 순회하게 된다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
let obj = new Object();
let obj = {};

let obj = {
0: "test",
for: 0,
return: 0,
__proto__: 0,
};

console.log(obj.return);
console.log(obj[0]);
console.log(obj["0"]);
console.log(obj.__proto__); // [object Object]

프로퍼티 이름에 제약사항은 없지만 특별한 경우가 있다. ___proto___ 는 할당한 0이 아닌 Object로 나오는데 이와 관련된 내용은 8.1 프로토타입 상속에서 설명한다.

4.3 가비지 컬렉션

JS의 가비지 컬렉터(이하 GC)는 reachability(도달 가능성) 이라는 개념을 사용해 메모리 관리를 수행한다. 몇 가지 값들은 그 의미가 분명하므로 명백한 이유가 없이는 삭제되지 않는다.

  • 현재 함수의 지역 변수와 매개변수
  • 중첩 함수의 체인에 있는 함수에서 사용되는 변수와 매개변수
  • 전역 변수
  • 기타 등등

이런 값을 특별하게 루트(root)라고 부른다. 루트가 참조 또는 체이닝으로 참조할 수 있는 값은 도달 가능한 값으로 판단하고 GC에 의해 제거되지 않는다.

1
2
3
4
// user엔 객체 참조 값이 저장됩니다.
let user = {
name: "John",
};

user는 Object 객체를 참조하고 있으며 이 경우 GC에 의해 제거되지 않는다. 하지만 user에 null을 대입하면 Object에 대한 도달 가능성이 없으므로 GC에 의해 제거된다.

1
2
3
4
5
6
// user엔 객체 참조 값이 저장됩니다.
let user = {
name: "John",
};

let admin = user;

이 경우 user와 admin은 같은 Object를 참조하고 있으며 위와 같이 user에 null을 덮어쓴다고 해도 admin과의 도달 가능성이 존재하므로 GC에 의해 제거되지 않는다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
function marry(man, woman) {
woman.husband = man;
man.wife = woman;

return {
father: man,
mother: woman,
};
}

let family = marry(
{
name: "John",
},
{
name: "Ann",
}
);

//
delete family.father;
delete family.mother.husband;

조금 더 복잡한 경우를 예로 들어서 위 코드는 부부 관계를 구성하며 가족(family)로 묶여있다. 이후 family와 mother에서 husband(=father)를 제거하면 husband(=father)는 도달 가능성이 사라지며 GC에 의해 사라지게 된다. 요약하자면 만약 내가 해당 객체에 접근할 수 있는 상황이면 GC는 당연하게도 객체를 제거하지 않는다.

GC 알고리즘

객체가 도달 가능한지 판단하기 위해서 GC에서 사용하는 기본 알고리즘 'mark-and-sweep'는 몇가지 단계를 수행한다.

  • GC는 루트 정보를 수집하고 이를 mark(기억)한다.
  • 루트가 참조하는 모든 객체를 방문하며 mark한다.
  • 이미 mark된 객체는 다시 방문하지 않는다.
  • 도달 가능한 모든 객체를 방문할 때까지 위 과정을 반복한다.
  • 도달 가능한 모든 객체를 방문한 뒤 mark가 되지 않은 객체를 메모리에서 제거한다.

과정에서 알 수 있듯이 GC가 삭제하기 위해서는 객체를 순회하며 검사하기 때문에 상황에 따라 많은 시간이 필요한 작업이다. 따라서 몇가지 최적화 기법을 사용해 문제를 해결하려고 노력하였다.

  • 세대별 수집
    • 세대별 수집은 새로 생성된 객체는 비교적 짧은 기간 사용된 후 불필요해지는 특성을 이용해 오래된 객체에 대해선 비교적 짧은 주기로 검사하고 새로 생성된 객체에 대해선 공격적인 제거 작업을 수행한다.
  • 점진적 수집
    • 점진적 수집은 객체가 많은 경우 모든 객체에 방문하여 mark하는 과정은 많은 리소스가 사용되는 부분을 해결하기 위해서 GC를 여러 부분으로 분리한 뒤 각 부분을 별도로 수행하여 큰 문제를 작은 문제로 분활하여 점진적으로 해결하는 방식이다.
  • 유휴 시간 수집
    • GC가 동작에 영향을 최소한으로 주기 위해서 별다른 동작을 하지 않는 시간을 이용해 GC를 수행하는 방식이다.

4.4 메서드와 ‘this’

JS에서는 실제 존재하는 개체를 표현할때 객체를 생성한다. 객체의 프로퍼티에는 단일값(숫자, 문자열, etc…) 외에도 객체의 행동을 나타내는 함수를 프로퍼티로 할당할 수 있다.

1
2
3
4
5
6
7
8
9
10
let user = {
name: "John",
age: 30,
};

user.sayHi = function () {
alert("안녕하세요!");
};

user.sayHi(); // 안녕하세요!

6번 라인에서 객체 user에 함수를 할당해서 객체의 동작을 넣어주었다. 이렇게 객체에 할당된 함수를 메소드라고 표현된다. 이런 메소드는 자체 연산을 수행하며 외부의 데이터를 사용하지 않는 경우도 있지만, 대부분의 메소드는 객체가 가지고 있는 값을 활용하여 연산한다. 이때 객체 내부의 값에 접근하기 위해서 this 키워드를 사용해 접근하게 된다.

1
2
3
4
5
6
7
8
9
10
11
let user = {
name: "John",
age: 30,

sayHi() {
// 'this'는 '현재 객체'를 나타냅니다.
alert(this.name);
},
};

user.sayHi(); // John

this 를 사용하지 않고 외부 변수(user)를 통해서도 접근이 가능한데 외부에서 해당 변수의 내용이 변경된 경우 원하지 않는 동작이 이뤄질 수 있으므로 내부 프로퍼티를 이용하고 싶다면 this 를 이용하는게 적절하다.

자유로운 ‘this’

JS는 다른 언어와 다르게 this 키워드 방식이 다르다. 예를 들어 JS에선 모든 함수에서도 this가 사용이 가능하다. this 값은 런타임에 결정되며 컨텍스트에 따라서 달라지게 된다. 따라서 동일한 함수라도 다른 객체에 대해서 호출했다면 this는 각각 객체에 대한 값을 가지게 된다. 이처럼 JS에서의 this는 유연함을 가지고 있지만 사용자의 숙지가 없다면 찾기 어려운 실수를 범할 우려가 있다.

‘this’가 없는 화살표 함수

() => {}*로 표현되는 화살표 함수는 자기의 고유한 this가 아니라 *외부 컨텍스트의 this**가 되며, 만약 외부 컨텍스트의 this를 이용하고 싶다면 좋은 해결책이 될 것이다.

5.6 iterable 객체

iterable 객체는 컬렉션 순회, for…of 문법등을 적용할 수 있도록 배열을 일반화한 객체이다. iterable 객체를 만들기 위해선 iterable 객체로 만들기 위한 객체가 아래와 같은 일이 벌어질 수 있도록 만들어야 한다.

  • for…of는 내부적으로 Symbol.iterator를 호출한다. (Symbol.iterator가 없으면 에러가 발생)
  • 이후 반환된 객체를 대상으로 for…of가 동작한다.
  • for…of는 이터레이터의 next() 메서드를 호출하며 다음 값으로 이동한다.
  • next() 메소드의 반환값은 {done: Boolean, value: any} 형태이며 done이 true일 경우 반복이 종료된다. done이 false일 경우 value의 값이 다음 스텝의 값이 된다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
let range = {
from: 1,
to: 5,
};

// 1. for..of 최초 호출 시, Symbol.iterator가 호출됩니다.
range[Symbol.iterator] = function () {
// Symbol.iterator는 이터레이터 객체를 반환합니다.
// 2. 이후 for..of는 반환된 이터레이터 객체만을 대상으로 동작하는데, 이때 다음 값도 정해집니다.
return {
current: this.from,
last: this.to,

// 3. for..of 반복문에 의해 반복마다 next()가 호출됩니다.
next() {
// 4. next()는 값을 객체 {done:.., value :...}형태로 반환해야 합니다.
if (this.current <= this.last) {
return { done: false, value: this.current++ };
} else {
return { done: true };
}
},
};
};

// 이제 의도한 대로 동작합니다!
for (let num of range) {
alert(num); // 1, then 2, 3, 4, 5
}

추가적으로 문자열은 iterable한 객체이다.

이터러블과 유사 배열

  • Iterable은 위에서 설명한 바와 같이 메서드 Symbol.iterator 가 구현된 객체
  • 유사 배열(array-like)은 인덱스와 length 프로퍼티가 있어서 배열처럼 보이는 객체

두 타입은 데이터를 다수게 묶어둔 형태로 존재한다는 면에서 비슷해 보일 수 있다. 하지만 유사 배열은 for…of를 수행할 없다. 또 push, pop같은 메서드도 활용할 수 없다. 이런 특성 때문에 두 타입을 다루기에 불편한 상황이 발생할 수 있다.

Array.from

1
Array.from(obj[, mapFn, thisArg])

범용 메서드 Array.from 은 Iterable과 유사 배열을 진짜! 배열로 만들어준다. Array.from 는 첫번째 매개변수 obj에 들어온 값이 iterable인지 유사 배열인지 확인한 후 맞다면 복사 하여 새로운 배열을 만들게 된다.

6.4 오래된 ‘var’

  • let
  • const
  • var

JS에서 변수를 선언할 땐 위 3가지 키워드를 활용된다. 하지만 3가지 키워드 중 마지막 ‘var’는 ‘let’과 유사한 활용과 let으로 치환되어도 해도 특별한 경우가 아닌 경우 잘 동작하게 된다. 하지만 몇 가지 문제로 인해 현재 ‘var’는 사용되지 않고 let이 표준으로 권장되고 있다. 이 단원에서는

‘var’는 블록 스코프가 없다.

1
2
3
4
5
6
7
8
9
10
11
// (1) var을 사용한 예제
if (true) {
var test = true; // 'let' 대신 'var'를 사용했습니다.
}
alert(test); // true(if 문이 끝났어도 변수에 여전히 접근할 수 있음)

// (2) let을 사용한 예제
if (true) {
let test = true; // 'let'으로 변수를 선언함
}
alert(test); // Error: test is not defined

위에서 설명했다시피 ‘var’는 현재 권장되지 않고 있다. 그 중 이유는 ‘var’는 함수 또는 전역 스코프를 가지고 있으며, 블록 밖에서도 접근이 가능하게 된다.

1
2
3
4
5
6
var user = "Pete";

var user = "John"; // this "var" does nothing (already declared)
// ...it doesn't trigger an error

alert(user); // John

또한, var는 동일한 변수명을 중첩되게 선언할 수 있어 만약 개발 중 변수명이 겹치게 되면 찾기 어려운 버그를 만들게 된다.

1
2
3
4
5
6
7
8
function sayHi() {
phrase = "Hello"; // 변수가 선언되기 전에 사용된다.

alert(phrase);

var phrase;
}
sayHi();

또 이런 말도 안 되는 코드도 ‘var’를 사용하면 호이스팅으로 인해 가능하게 된다. 호이스팅은 구문 분석에서 var는 어느 위치에 있든 해당 스코프의 가장 처음으로 움기는 것을 말한다. 또 이 호이스팅은 절대로 수행되지 않는 블록 내에 선언된 var 일지라도 움기게 된다. 그렇다면 선언과 동시에 할당하는 코드에 대해선 어떻게 동작하게 될까? 라는 의문이 들지만, 이것 또한 상상을 벗어난 방법으로 수행하게 된다.

선언은 호이스팅에 따라 최상단으로 움기게 되지만, 할당은 그렇지 않다. 따라서 선언은 되었지만, 원하는 할당은 이뤄지지 않은 상태가 된다. 또한 이 과정은 오류가 아니기 개발 시 놓치게 되면 실행 도중에 발생하게 되어 더욱 골치가 아파 질 수 있다.

즉시 실행 함수 표현식

let이 없던 과거 JS에서 var을 블록 레벨 스코프를 가지기 위해 다양한 방안을 고려했다. 이때 만들어진 방법 중 하나가 ‘즉시 실행 함수 표현식’이다. 즉시 실행 함수 표현식은 IIFE로 불리며 다음과 같은 형태로 존재한다.

1
2
3
4
(function () {
let message = "Hello";
alert(message); // Hello
})();

독특한 부분으로 함수명이 없으며, 함수가 () 로 감싸진 형태다 또, 함수를 감싼 괄호 다음 바로 호출하는 형태의 괄호가 바로 뒤따라오게 된다.
JS에서는 괄호 안에 함수 선언문을 정의함으로써 표현 식으로 인식하게 한다. 또, 함수의 선언과 함께 동시에 호출할 수 없는 부분도 표현 식으로 인식된 함수를 바로 호출하는 형태로 만들어진다.
이런 표현 식은 과거 JS로 작성된 코드에서 볼 수 있으며 let이 있는 현재의 JS에선 이렇게 작성할 필요가 없다.

6.8 setTimeout과 setInterval을 이용한 호출 스케줄링

JS에서는 일정 시간이 지난 후에 원하는 함수가 호출되도록 예약할 수 있는 2가지 방법의 호출 스케줄링을 지원한다.

  • setTimeout - 일정 시간이 지난 후에 함수가 실행 (한번)
  • setInterval - 일정 시간 간격을 두고 함수가 실행 (여러번)

이 글에선 setTimeout, setInterval에 대한 일반적인 사용 방법 설명은 생략합니다.

중첩 setTimeout

일정 간격을 두고 함수를 실행하는 방법은 setInterval을 사용하는 방법과 setTimeout을 중첩되게 사용하는 방법 두 가지가 있다.
여기서 setTimeout을 중첩되게 사용하면 시간 간격을 상황에 따라 유동적으로 조절할 수 있어 setInterval을 사용하는 방법보다 더욱 유연하게 사용될 수 있다.

1
2
3
4
5
6
7
8
9
10
11
12
13
let delay = 5000;

let timerId = setTimeout(function request() {
...요청 보내기...

if (서버 과부하로 인한 요청 실패) {
// 요청 간격을 늘립니다.
delay *= 2;
}

timerId = setTimeout(request, delay);

}, delay);

또, setInterval의 경우 지연 간격을 보장하지 못하는데 이것은 함수가 실행된 후 즉시 다음 호출에 대해 타이머가 시작되기 때문이다. 때문에 시간 간격보다 함수 실행 시간이 길어지면 함수간의 호출이 중첩될 수 있다.
하지만 setTimeout은 재귀 호출적인 방법을 통해서 함수가 수행된 마지막 시점에 호출되기 때문에 지연 간격이 보장된다.

대기 시간이 0인 setTimeout

setTimeout의 두번째 인자로, 0 또는 생략할 경우 대기 시간을 0으로 설정할 수 있다. 대기 시간이 0인 경우 즉시 실행이 되는게 아니라 '가능한 빨리' 실행할 수 있다. 이때 스케줄러에 실행 중인 스크립트가 종료된 이후에 스케줄링한 함수를 실행한다.

1
2
3
4
5
6
setTimeout(() => alert("World"));

alert("Hello");

// Output
Hello, World

대기 시간이 0인 setTimeout은 즉시 실행이 아니라, 계획표에 할 일을 기록하고 스케줄링이 빌때 까지 대기한 후 실행된다고 생각하면 될 것 같다.

6.9 call/apply와 데코레이터, 포워딩

JS에서는 함수를 전달할 수 있고, 객체로써 다루는 등 높은 유연성을 제공한다. 이 챕터에서는 함수를 대상으로 포워딩(forwarding), 데코레이딩(decorating)을 설명한다.

코드 변경 없이 캐싱 기능 추가하기

수행 시간은 오래걸리지만 입력값에 따라 결과값이 같은 함수가 있다고 가정했을 때, 함수의 수행 속도를 높이기 위해 캐싱 기능을 구현하고 싶다. 간단한 방법으로 해당 함수 내에 캐싱 기능을 직접 구현할 수 있지만, 래퍼 함수를 만들면 독립된 형태로 캐싱 기능을 구현할 수 있다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
function slow(x) {
// CPU 집약적인 작업이 여기에 올 수 있습니다.
alert(`slow(${x})을/를 호출함`);
return x;
}

function cachingDecorator(func) {
let cache = new Map();

return function (x) {
if (cache.has(x)) {
// cache에 해당 키가 있으면
return cache.get(x); // 대응하는 값을 cache에서 읽어옵니다.
}

let result = func(x); // 그렇지 않은 경우엔 func를 호출하고,

cache.set(x, result); // 그 결과를 캐싱(저장)합니다.
return result;
};
}

slow = cachingDecorator(slow);

alert(slow(1)); // slow(1)이 저장되었습니다.
alert("다시 호출: " + slow(1)); // 동일한 결과

alert(slow(2)); // slow(2)가 저장되었습니다.
alert("다시 호출: " + slow(2)); // 윗줄과 동일한 결과

코드에서 cachingDecorator 와 같은 함수를 데코레이터 라고 부른다. 이런식으로 데코레이터를 활용하면 대상 함수의 코드가 복잡해지지 않고 (캐싱에 필요한 코드가 걷어짐), 재활용 가능하며, 유지 보수에 효과적이며 필요에 따라선 여러개의 데코레이터와 조합해서도 사용이 가능하다. 추가적으로 위 코드에서 cachingDecorator의 지역 변수 cache는 래핑된 함수 객체로 인해 도달 가능한 상태이므로 GC에 의해 제거되지 않아 정상적인 수행이 가능하게 된다.

위에서 소개된 코드는 객체 메서드에 사용하기엔 문제가 있다. 결론적으로 말하자면 내부 context가 유지되지 못하기 때문에 만약 데코레이터로 감싼 메서드가 내부의 다른 메서드를 호출하는 모양이되면 Error: Cannot read property 'xxx' of undefined 오류가 발생할 수 있다.

1
2
3
4
5
6
7
8
9
10
11
12
13
func.call(context, arg1, arg2, ...)

// example
function sayHi() {
alert(this.name);
}

let user = { name: "John" };
let admin = { name: "Admin" };

// call을 사용해 원하는 객체가 'this'가 되도록 합니다.
sayHi.call( user ); // this = John
sayHi.call( admin ); // this = Admin

이런 경우 context를 지정할 필요가 있다. .call 메서드는 첫번째 인수로 context로 지정할 값을 전달받는다. 그 뒤부터는 함수로 전달되는 인수 목록들이다. 위 코드에서 같은 sayHi 메서드를 호출하지만 서로 다른 context를 가지고 있다는걸 알 수 있다. context의 문제는 .call 메서드를 통해 해결할 수 있다. 하지만 유연한 데코레이터를 만들기 위해선 매개변수의 수가 고정된 형태는 활용에 제한되며 이를 해결하기 위해서 몇가지 사전 문제가 있습니다.

  • 정해지지 않은 인수의 개수
  • 복수개의 인수를 저장할 수 있는 자료구조 구현

가변적인 인수목록을 안전하게 구분하기 위해선 hash 를 통해 맵의 key로 구성하는 방법을 생각할 수 있다. 두번째는 데코레이터에서 변수명이 유동적인 환경에서 함수의 매개변수를 대상 함수에게 넘기는 방법이다. JS에서는 함수의 매개변수를 알 수 있는 argumens 키워드와 ...variable (spread 연산자)를 통해 넘겨주게 되면 배열 요소를 개별 요소로 개별 인수로 넘어가게 된다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
let worker = {
slow(min, max) {
alert(`slow(${min},${max})을/를 호출함`);
return min + max;
},
};

function cachingDecorator(func, hash) {
let cache = new Map();
return function () {
let key = hash(arguments); // (*)
if (cache.has(key)) {
return cache.get(key);
}

let result = func.call(this, ...arguments); // (**)

cache.set(key, result);
return result;
};
}

function hash(args) {
return args[0] + "," + args[1];
}

worker.slow = cachingDecorator(worker.slow, hash);

alert(worker.slow(3, 5)); // 제대로 동작합니다.
alert("다시 호출: " + worker.slow(3, 5)); // 동일한 결과 출력(캐시된 결과)

주의: 이 함수 구문은 apply()와 거의 동일하지만, call()인수 목록을, 반면에 apply()인수 배열 하나를 받는다는 점이 중요한 차이점입니다.

https://developer.mozilla.org/ko/docs/Web/JavaScript/Reference/Global_Objects/Function/call

.call(or .apply) 메서드를 통해 대상 함수의 가변적인 매개변수 개수에 대한 문제점을 해결했다. 하지만 아직 hash함수는 2개의 매개변수에 한해서 동작하게 되어있다. 하지만 이전 문제처럼 가변 인자를 담고 있는 arguments에 대해서 .join 메서드를 수행하면 쉽게 해결할 수 있을 것 같지만 arguments는 배열이 아니라 iterable 또는 유사 배열 객체 이라 원하는 동작이 되지 않는다.

메서드 빌리기

방금 언급한 문제를 해결하기 위해선 .join 메서드를 가지고 있는 객체에서 빌려올 수 있다.

1
2
3
4
5
function hash() {
alert([].join.call(arguments)); // 1,2
}

hash(1, 2);

이 코드의 경우 []에서 .join 메서드를 빌려와 .call 메서드를 호출하며 유사 배열로 arguments를 호출하게 된다. .join 메서드 내부에서는 this를 구분자와 연결하며 결과를 반환하는데 이때 .call 메서드를 통해 context를 arguments로 고정하여 수행한다. 어떤 유사 배열이던 this가 될 수 있기 때문에 이런 방식을 통해서도 잘 동작할 수 있게된다.

6.10 함수 바인딩

setTimeout 에 메서드를 전달할 때처럼, 객체 메서드가 콜백으로 전달되면 this 정보가 사라지는 일이 생긴다. setTimeout 을 사용한 아래 예시에서 this 가 어떻게 사라지는지 살펴보자.

1
2
3
4
5
6
7
8
9
10
11
12
let user = {
firstName: "John",
sayHi() {
alert(`Hello, ${this.firstName}!`);
},
};

setTimeout(user.sayHi, 1000); // Hello, undefined!
/* setTimeout(user.sayHi, 1000);
let f = user.sayHi;
setTimeout(f, 1000); // user 컨텍스트를 잃어버림
*/

코드를 보면 1초 뒤 user.sayHi가 호출되고 *Hello, John!*이 출력될 것을 기대했지만 undefined가 출력되게 된다. 브라우저 환경에서 setTimeout 메서드는 특별하게 동작하는데 전달받은 실행 함수(코드)를 호출할 때, this에 window를 할당한다(Node.js 환경에선 타이머 객체가 할당된다). 이렇게 객체 메서드가 스케쥴러에 의해 실행될 때, context가 유지되기 위해선 어떻게 해야 할까?

방법 1. 래퍼

1
2
3
4
5
6
7
8
9
let user = {
firstName: "John",
sayHi() {
alert(`Hello, ${this.firstName}!`);
},
};
setTimeout(function () {
user.sayHi(); // Hello, John!
}, 1000);

user.sayHi를 직접 넘겨주는 방법이 아닌 외부 렉시컬 환경에서 user를 받아 메서드를 호출했기 때문에 정상적으로 동작하게 된다. (이 부분 제대로 이해할 필요가 있으므로 일단 정지)

방법 2. bind

1
2
3
4
5
6
7
8
9
10
11
12
// func.bind(context, [arg1], [arg2], ...)

let user = {
firstName: "John",
};

function func() {
alert(this.firstName);
}

let funcUser = func.bind(user);
funcUser(); // John

JS에서 모든 함수는 this를 수정하는 내장 메서드 bind 를 제공한다. bind는 함수처럼 호출 가능한 ‘특수 객체’를 반환하는데 이 객체의 this는 bind의 첫번째 인수로 넘겨진 context로 고정되게 된다. 이렇게 bind로 context가 묶인 함수는 단독으로 호출할 수 있고, setTimeout에 전달하여 호출할 수도 있다. 주의할 점은 this가 묶인 것일 뿐, 다른 인수에 대해선 유동적으로 변경이 가능하다. 따라서 다음과 같은 코드도 잘 동작하게 된다.

1
2
3
4
5
6
7
8
9
10
11
let user = {
firstName: "John",
say(phrase) {
alert(`${phrase}, ${this.firstName}!`);
},
};

let say = user.say.bind(user);

say("Hello"); // Hello, John (인수 "Hello"가 say로 전달되었습니다.)
say("Bye"); // Bye, John ("Bye"가 say로 전달되었습니다.)

부분 적용

지금까진 this binding을 주로 했다면 지금부턴 인수를 바인하는 방법을 소개한다. .bind 메서드는 원형에서 볼 수 있듯이 두번째 인수부턴 호출시 넣어줄 인수 목록이 들어온다. 이렇듯 인수 목록의 일부가 고정된 형태로 자주 사용되는 함수에 대해서는 인수를 고정시킬 수 있다.

1
2
3
4
5
6
7
8
9
function mul(a, b) {
return a * b;
}

let double = mul.bind(null, 2);

alert(double(3)); // = mul(2, 3) = 6
alert(double(4)); // = mul(2, 4) = 8
alert(double(5)); // = mul(2, 5) = 10

2의 곱셈을 계산하는 double은 mul에 인수 2가 고정된 형태로 호출되게 된다. 이때, .bind 메서드를 통해서 인수를 고정하여 double이라는 함수를 추가적으로 작성하지 않고 함수를 작성할 수 있다.

컨텍스트 없는 부분 적용

그러면 인수의 일부는 고정된 상태로 유동적인 this를 가지는 방법을 알아보자. .bind는 context를 생략하고 인수로 넘어가지 못한다. 하지만 이를 구현하는 방법은 이전 단원에서 배웠던 내용을 활용해 쉽게 구현할 수 있다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
function partial(func, ...argsBound) {
return function (...args) {
// (*)
return func.call(this, ...argsBound, ...args);
};
}

// 사용법:
let user = {
firstName: "John",
say(time, phrase) {
alert(`[${time}] ${this.firstName}: ${phrase}!`);
},
};

// 시간을 고정한 부분 메서드를 추가함
user.sayNow = partial(
user.say,
new Date().getHours() + ":" + new Date().getMinutes()
);

user.sayNow("Hello");
// 출력값 예시:
// [10:00] John: Hello!

partial 은 래퍼 함수를 만들게 된다. func 은 래핑할 메서드를, ...argsBound 는 고정할 인수를, ...args 는 메서드를 호출할 때 넘겨줄 인수 목록을 의미한다.

8.1 프로토타입 상속

상속은 공통된 속성을 부모로 둠으로써 코드의 중복과 개발의 효율성을 높인다. Class가 없던 JS에서는 __proto__ 를 통해서 프로토타입 상속을 실현할 수 있다. JS는 찾으려는 프로퍼티가 없는 경우 __proto__ 를 타고 넘어가 프로퍼티를 찾게된다.

1
2
3
4
5
6
7
8
let animal = {
eats: true,
};
let rabbit = {
jumps: true,
};

rabbit.__proto__ = animal;

위 코드와 같이 rabbit 객체에는 jumps, animal 객체에는 eats가 있다. 또 rabbit.__proto__ = animal; 를 통해서 상속하고 있다.

1
2
alert(rabbit.eats); // true (**)
alert(rabbit.jumps); // true

그러면 위와 같이 rabbit 객체는 animal 객체의 eats를 호출할 수 있게 된다. 이는 위에서 설명한 내용과 같이 rabbit 객체 내에서 eats를 찾을 수 없어 __proto__ 에 설정된 animal 객체로 올라가 eats를 찾아 호출하게 된다.

이처럼 프로토타입을 여러번 상속받게 되면 프로토타입 체이닝이라 하는데 프로토타입 체이닝엔 몇가지 제약사항이 있다.

  • 순환 참조는 허용하지 않는다.
  • __proto__의 값은 null 또는 객체만 허용한다. 그 외 다른 자료형은 허용하지 않는다.
  • 객체엔 오직 하나의 [[Prototype]]만 있을 수 있다.

‘this’가 나타내는 것

이런 프로토타입 상속이 이뤄지면 this에 무슨 값이 들어있는지 의문이 생길 수 있다. 결론적으로 this는 프로토타입에 영향을 받지 않는다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// animal엔 다양한 메서드가 있습니다.
let animal = {
walk() {
if (!this.isSleeping) {
alert(`동물이 걸어갑니다.`);
}
},
sleep() {
this.isSleeping = true;
},
};

let rabbit = {
name: "하얀 토끼",
__proto__: animal,
};

// rabbit의 프로퍼티 isSleeping을 true로 변경합니다.
rabbit.sleep();

alert(rabbit.isSleeping); // true
alert(animal.isSleeping); // undefined (프로토타입에는 isSleeping이라는 프로퍼티가 없습니다.)

코드에서 isSleepingsleep()에 의해 초기화가 되며, 따라서 rabbit.sleep()으로 인해 rabbitisSleeping이 초기화되었기 때문에 animal의 isSleeping은 undefined가 나오게 된다.

for…in 반복문

JS에서는 상속된 프로퍼티와 객체가 직접 소유한 프로퍼티는 연산 마다 다르게 접근하게 되는데 그 중 for…in 은 상속된 프로퍼티와 객체가 소유한 모든 프로퍼티에 대해서 순회하게 된다.

1
2
3
4
5
6
7
8
9
10
11
let animal = {
eats: true,
};

let rabbit = {
jumps: true,
__proto__: animal,
};

// for..in은 객체 자신의 키와 상속 프로퍼티의 키 모두를 순회합니다.
for (let prop in rabbit) alert(prop); // jumps, eats

만약 객체가 직접 소유한 프로퍼티를 구분하기 위해서는 hasOwnProperty를 이용해 구분할 수 있다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
let animal = {
eats: true,
};

let rabbit = {
jumps: true,
__proto__: animal,
};

for (let prop in rabbit) {
let isOwn = rabbit.hasOwnProperty(prop);

if (isOwn) {
alert(`객체 자신의 프로퍼티: ${prop}`); // 객체 자신의 프로퍼티: jumps
} else {
alert(`상속 프로퍼티: ${prop}`); // 상속 프로퍼티: eats
}
}

11.7 마이크로태스크

JS에서 프로미스 핸들러 .then/catch/finally 는 항상 비동기적으로 실행된다.

1
2
3
4
5
let promise = Promise.resolve();

promise.then(() => alert("프라미스 성공!"));

alert("코드 종료"); // 이 얼럿 창이 가장 먼저 나타납니다.

코드를 실행하면 코드 종료 이후에 프로미스 성공! 이 출력되는걸 볼 수 있습니다. 비동기 작업을 처리하려면 적절한 관리가 필요하다. 이를 위해 ECMA에선 PromiseJobs 또는 V8 엔진에서 마이크로태스크 큐 라고 부르는 내부 큐를 명시한다. 명세서는 아래와 같이 이를 설명하고 있다.

  • 마이크로태스크 큐는 먼저 들어온 작업을 먼저 실행한다. (FIFO)
  • 실행할 것이 아무것도 남아있지 않을 때만 마이크로태스크 큐에 있는 작업이 실행된다.

정리하면 프라미스가 준비되면 프라미스의 핸들러(.then/catch/finally)는 큐에 들어가고 대기하다 코드 수행이 끝난 뒤 자유로운 상태에가 되면 큐에 있던 작업을 꺼내 실행하게 된다.

여러 개의 프라미스 핸들러들로 체인을 만든 경우, 각 핸들러는 비동기적으로 실행된다. 각각의 핸들러는 현재 코드가 완료되고, 큐에 적체된 이전 핸들러의 실행이 완료되었을 때 실행된다.

처리되지 못한 거부

처리되지 못한 거부 는 마이크로태스크 큐 끝에서 프라미스 에러가 처리되지 못할 때 발생한다. 개발자는 에러가 생길 것을 대비하여 프라미스 체인에 .catch 를 추가해 에러를 핸들링한다. 하지만 .catch를 추가하지 않고 마이크로태스크 큐가 끝날 때까지 에러가 핸들링되지 못한 경우 unhandledrejction 이벤트가 발생하게 된다.

1
2
3
4
let promise = Promise.reject(new Error("프라미스 실패!"));
// promise.catch(err => alert('잡았다!'));

window.addEventListener('unhandledrejection', event => alert(event.reason));

만약 에러 핸들링이 setTimeout 과 같이 나중에 핸들링하도록 만들면 어떤일이 벌어지는지 알아보자.

1
2
3
4
5
let promise = Promise.reject(new Error("프라미스 실패!"));
setTimeout(() => promise.catch(err => alert('잡았다!')), 1000);

// Error: 프라미스 실패!
window.addEventListener('unhandledrejection', event => alert(event.reason));

이 경우 프라미스 실패! 가 출력되고 잡았다! 가 출력되는 걸 알 수 있다. 이유는 .catch 가 1초 후에 핸들링되기 마이크로태스크 큐가 비게 되고 엔진은 거부(rejcted) 상태 이면 unhandledrejection 핸들러를 트리거 하게 된다. 또 unhandledrejection이 트리거 되었어도 .catch에 의해 또 다시 트리거 되었기 때문에 잡았다! 가 출력되어진다.

모던 JavaScript 튜토리얼 Part.2

6.3 이벤트 루프와 매크로·마이크로태스크

브라우저 측 자바스크립트 실행 흐름은 Node.js와 마찬가지로 이벤트 루프 에 기반한다. 이 챕터에서는 이벤트 루프의 이론과 실습을 알아본다.

이벤트 루프

이벤트 루프는 task(이하 테스크)를 기다리다 테스크가 들어오면 이를 처리하는 단순한 정의를 가진다. 이벤트 루프를 처리하는 JS 엔진의 알고리즘은 다음과 같다.

  1. 처리해야 할 테스크가 있는 경우
    • 먼저 들어온 테스크부터 순차적으로 처리
  2. 처리해야 할 테스크가 없는 경우
    • 대기하다 새로운 태스크가 들어오면 1로 돌아감

이렇듯 JS 엔진은 대부분의 시간 동안 대기하다 JS와 관련된 행위가 발생할 때 동작하게 된다. 대표적인 관련 행위는 다음과 같다.

  • 외부 스크립트(<script src=”…”>)
  • 사용자가 마우스를 움직일 때 mousemove 이벤트와 이벤트 핸들러를 실행할 때
  • setTimeout에서 설정한 시간이 다 된 경우, 콜백 함수를 실행 때
  • 기타 등등

테스크는 하나의 집합을 이루며, JS 엔진은 집합을 이루고 있는 테스크들을 차례대로 처리하고, 모든 처리가 끝나면 새로운 테스크가 추가될 때까지 기다린다. 기다리는 동안엔 CPU 자원 소비는 0에 가까워지고 잠들게 된다.

만약 JS 엔진이 테스크 처리에 바쁘다면 새로운 테스크는 큐에 추가되며 이 큐는 V8 용어로 매크로테스크 큐(macrotask queue) 라고 부른다.

usecase 1: CPU 소모가 많은 테스크 쪼개기

만약 처리 중인 테스크의 처리가 오래 걸리면 DOM 변경을 화면에 반영하지 못해 사용자에게 하얀 화면을 보일 수 있다. 이런 경우 setTimeout을 통해서 작업을 쪼개 미뤄졌던 다른 테스크를 처리하는등 큐의 환기를 통해 해결할 수 있다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
let i = 0;

let start = Date.now();

function count() {

// 무거운 작업을 쪼갠 후 이를 수행 (*)
do {
i++;
} while (i % 1e6 != 0);

if (i == 1e9) {
alert("처리에 걸린 시간: " + (Date.now() - start) + "ms");
} else {
setTimeout(count); // 새로운 호출을 스케줄링 (**)
}

}

count();

위 코드를 보면 전체 작업을 일정하게 나누고 아직 수행을 완료하지 못했더라면 다시 큐에 테스크를 넣어 작업을 이어간다. 그러면 화면 멈춤 없이 CPU 소모가 많은 테스크를 수행할 수 있다. 하지만 이전과 비교했을 때 비해 많은 작업 시간이 생기는데 이를 해결하기 위해서는 무거운 작업을 하기 전에 스케줄링에 작업을 미리 넣어줌으로써 해결할 수 있다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
let i = 0;

let start = Date.now();

function count() {

// 스케줄링 코드를 함수 앞부분으로 옮김
if (i < 1e9 - 1e6) {
setTimeout(count); // 새로운 호출을 스케줄링함
}

do {
i++;
} while (i % 1e6 != 0);

if (i == 1e9) {
alert("처리에 걸린 시간: " + (Date.now() - start) + "ms");
}

}

count();

왜 이런 작업을 통해서 좀 더 빨라지는 이유는 이전 ‘setTimeout과 setInterval을 이용한 호출 스케줄링’ 챕터에서 setTimeout의 대기 시간을 0으로 줘도 실제로 0이 아닌 4ms 정도의 대기 시간을 가진다고 설명했다. 이 경우 4ms의 대기 시간을 무거운 작업을 하는 동안 소모할 수 있어 불필요한 대기 시간을 줄여 문제를 해결한 것 이다.

usecase 2: 프로그레스 바

브라우저는 스크립트 실행이 오래 걸리든 상관없이 대개 실행 중인 코드가 끝난 이후 렌더링 작업을 하게 된다. 이런 과정은 개발에 있어 요소를 생성 및 추가 그리고 스타일링하는 과정을 사용자는 이 과정을 보지 않아도 된다는 점에선 유용하지만 가끔 이런 중간 과정을 사용자에게 보여주고 싶은 경우가 있다. 대표적인 예로 프로그레스 바로 현재 작업의 진행률을 사용자에게 보여주기 위해선 작업 중 현재 상태를 업데이트할 필요가 있다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
<div id="progress"></div>

<script>
let i = 0;

function count() {

// 무거운 작업을 쪼갠 후 이를 수행
do {
i++;
progress.innerHTML = i;
} while (i % 1e3 != 0);

if (i < 1e7) {
setTimeout(count);
}

}

count();
</script>

위 코드는 setTimeout을 통해 작업을 쪼개서 중간중간 큐를 환기시킨다. 큐에는 렌더링 작업도 포함되기 때문에 새로운 데이터가 화면에 반영도기 때문에 사용자는 데이터의 갱신을 눈으로 확인할 수 있다.

usecase 3: 이벤트 처리가 끝난 이후에 작업하기

이벤트 핸들러를 만들다 보면 이벤트 버블링이 끝난 모든 DOM 트리 레벨에서 이벤트가 핸들링 될 때까지 액션을 연기시켜야 하는 경우가 생긴다. 이때 지연 시간이 0인 setTimeout으로 감싸면 원하는 동작을 구현할 수 있다.

1
2
3
4
5
6
7
8
9
10
11
menu.onclick = function() {
// ...

// 클릭한 메뉴 내 항목 정보가 담긴 커스텀 이벤트 생성
let customEvent = new CustomEvent("menu-open", {
bubbles: true
});

// 비동기로 커스텀 이벤트를 디스패칭
setTimeout(() => menu.dispatchEvent(customEvent));
};

매크로테스크와 마이크로테스크

테스크는 매크로테스크마이크로테스크 로 나뉜다. 먼저 마이크로테스크는 주로 프라미스를 사용해 만들어진다. .then/catch/finally 핸들러가 이에 해당한다. 추가적으로 프라미스를 핸들링하는 await 문법을 사용해 만들어지기도 한다.
그 외에 표준 API인 queueMicrotask(func) 를 사용하면 함수 func를 마이크로테스크 큐에 넣어 처리할 수 있다.

JS 엔진은 매크로테스크 하나를 처리한 직후, 다른 매크로테스크(or 렌더링 작업)을 하기 전에 마이크로테스크 큐에 있는 모든 마이크로테스크를 처리한다.

1
2
3
4
5
6
7
setTimeout(() => alert("timeout"));

Promise.resolve()
.then(() => alert("promise"));

alert("code");
// code -> promise -> timeout

이런 처리순서가 중요한 이유는 마이크테스크 간 동일한 애플리케이션 환경 보장 때문이다. 그런데 개발을 하다보면 직접 만든 함수를 현재 코드 실행이 끝난 후, 새로운 이벤트 핸들러가 처리되기 전이면서 렌더링이 실행되기 전에 비동기적으로 실행해야 하는 경우가 있다. 이럴 때 queueMicrotask API를 사용해 스케줄링하게 된다.

Share