지난 번에 설명한 적 있듯이, 다른 코드(함수)의 인자로 들어가는 함수를 '콜백 함수' 라고 부른다.
setTimeout(function() {
console.log("hello");
})
const numbers = [1,2,3,4,5];
numbers.forEach(function(number) {
console.log(number);
});
이 코드들에서의 매개변수 자리에 위치한 function 들처럼 이러한 방식으로 사용하곤 한다.
setTimeOut 과 forEach 함수들은 이 콜백 함수를 필요에 따라 호출하는, '제어권'을 넘겨받아서 동작한다.
콜백 함수의 원리는 이 '제어권'을 setTimeOut, forEach 와 같은 주체에 넘겨줄테니, 각자의 로직으로 처리해라 라고 위임하는 것과 같다.
여기서 말하는 제어권은 어떤 제어를 말하는 것인지를 먼저 보자.
let count = 0;
//각 호출 사이에 시간 지연을 설정하는 함수
// -> 반복해서 매개변수로 받은 콜백함수를 호출
let timer = setInterval(function() {
console.log(count);
if(++count > 4) clearInterval(timer);
} , 300);
//출력 : 0 -> 0.3초(300) -> 1 -> .... -> 0.3초 -> 4
이 0.3초 (코드상으로는 300) 의 딜레이에 대한 제어권, 즉 호출 시점에 대한 제어권을 setInterval 함수가 갖고 있는 것이다.
코드를 다르게 써보겠다.
var count = 0;
var cbFunc = function () {
console.log(count);
if (++count > 4) clearInterval(timer);
};
var timer = setInterval(cbFunc, 300);
마지막 줄의 코드 var timer = setInterval(cbFunc, 300); 로 동작하면 이 0.3초에 대한 제어권이 setInterval 함수에 들어간다.
하지만 이 함수 대입 방식이 아니라 단순 호출 ( cbFunc(); ) 로 시행하면 제어권은 호출 주체, 사용자에게 돌아간다.
결과를 시행하면 출력이 0 하나만 나오는 것을 알 수 있다.
이 단순 호출 방식에서 사용자가 제어하는 것은 cbFunc() 의 단순 한번의 호출이고, 그래서 출력이 한번만 진행된다.
이 호출의 제어권을 setInterval 함수로 넘길 경우 호출 시점을 이 함수가 제어하기 때문에 300이라는 딜레이 후에 재호출 하는 과정을 거쳐 [ 0, 1, 2, 3, 4 ] 라는 정상적 출력이 이루어지는 것이다.
map 함수를 보면서 설명할 수 있다.
var newArr = [10, 20, 30].map(function(currentValue, index)
{
console.log(currentValue, index);
});
//출력 : 10, 0 / 20, 1 / 30, 2
겉으로 보기엔 문제없는 코드지만 여기서 추가로 console.log(newArr) 을 작성하면 undefined 에러가 나타난다.
왜그럴까? 이는 map 함수의 구조 때문인데, map 함수는 반드시 무언가를 return 해야 한다.
var newArr = [10, 20, 30].map(function(currentValue, index)
{
console.log(currentValue, index);
return currentValue +5;
});
이렇게 return 을 추가할 경우 정상적으로 출력된다.
다만 여기서 매개변수 currentValue 와 index 의 순서를 바꿔보자
var newArr = [10, 20, 30].map(function(index, currentValue)
{
console.log(index, currentValue);
return currentValue +5;
});
console.log(newArr);
의도한 변수들의 위치가 바뀌었기 때문에 값에도 변화가 있을 것으로 예상되지만 실제 결과는 다르다.
newArr 의 출력이 [5, 6, 7] 로 나오는 모습이다. 이 값은 currentValue 가 아니라 index 에 5가 더해진 값이다.
이 결과를 통해 매개변수의 index 와 currentValue 는 그저 사람이 이해할 수 있는 변수명일 뿐이고, map 함수는 이 매개변수의 '순서' 만 기억한다는 것을 알 수 있다.
결론적으로 이 매개변수들의 제어권은 map 함수에 있으니, 이에 요구하는 순서대로 매개변수를 넣어줘야 한다는 것이다.
참고자료를 보면 다음과 같이 나타나있는 것을 볼 수 있다.
이전에 '콜백함수도 함수이기 때문에 기본적으로 this 가 전역객체를 참조한다' 라고 한 바 있다.
다만 여기에 예외가 있는데, '제어권을 넘겨받을 코드에서 콜백 함수에 별도로 this 가 될 대상을 지정한 경우에는 그 대상을 참조한다' 라는 것이다.
내용이 길어 다시 작성한다.
제어권을 넘겨받을 코드에서, 콜백 함수에 별도로 this 가 될 대상을 지정한 경우에는, 그 대상을 참조한다.
핵심은 두개, call 과 apply 이다.
이 예외사항을 map 함수를 통해 구현해본다.
Array.prototype.map123 = function(callback, thisArg)
{
//map 함수에서 return 할 결과 배열
var mappedArr = [];
//여기서의 this는 map123의 호출 주체[1,2,3]
for(var i = 0; i<this.length; i++)
{
var mappedValue = callback.call(thisArg || global, this[i]);
mappedArr[i] = mappedValue;
}
return mappedArr;
};
var newArray = [1,2,3].map123(function(number){
return number * 2;
});
console.log(newArray);
출력 : [2, 4, 6]
제어권을 넘겨받을 코드는 map123, 그 안에서 callback.call() 함수가 사용되었다. 또한 this 를 thisArg 매개변수를 통해 명시적으로 할당해주었다.
따라서, 제어권을 넘겨받을 코드에서 call / apply 메서드의 첫번째 인자에서 이 콜백 함수 내부에서 사용될 this 를 명시적으로 바인딩하기 때문에, this 가 전역객체가 아닌 다른 값이 담길 수 있게 되는 것이다.
다른 예시를 한번 들어본다.
var obj = {
vals : [1,2,3],
logValues : function(v, i){
console.log(this, v, i);
}
};
obj.logValues();
여기서의 obj.logValues() 는 obj 객체의 메서드로 호출하기 때문에 this 는 호출의 주체, obj 로 잘 자리잡고 있다.
그런데 콜백 함수를 사용하면 어떻게 될까?
var obj = {
vals : [1,2,3],
logValues : function(v, i){
if(this===global)
{
console.log("this = global");
}
else console.log(this, v, i);
}
};
obj.logValues();
//forEach의 특성 : logValues 의 매개변수 v, i 가 있는데
//v 에는 이 4,5,6 이 들어가면서
//i에는 forEach 문법에 따라 그 인덱스가 들어감
[4,5,6].forEach(obj.logValues);
obj.logValues 를 콜백함수로 사용하였다.
다음처럼 this 가 전역객체로 잡히는 것을 보고 메서드를 호출로서 사용해야지, 매개변수로 사용하면 안된다는 것을 알 수 있다.
var obj1 = {
name: 'obj1',
func: function() {
var self = this; //이 부분!
return function () {
console.log(self.name);
};
}
};
// 단순히 함수만 전달한 것이기 때문에, obj1 객체와는 상관이 없다
// 메서드가 아닌 함수로서 호출한 것과 동일
var callback = obj1.func();
setTimeout(callback, 1000);
이 밑의 callback 에는 obj1.func() 의 실행 결과, 즉 return function() { ~ } 부분이 할당된 것이다.
var callback = function () {
console.log(self.name);
};
이런 모습이다.
해서 이 self.name 을 출력하면 obj1 이 나타난다.
다만 이 this 를 바인딩하는 과정이 너무 번거로운데, 해서 콜백 함수 내부에서 this 를 아예 없애버리면 어떠냐 해서 나온 방법이 있긴 있다.
var obj1 = {
name: 'obj1',
func: function () {
console.log(obj1.name);
}
};
setTimeout(obj1.func, 1000);
다만 이 방식은 너무 원하는 결과만을 얻기 위한 하드 코딩, 사실상 쓰지 못하는 방식이다.
우리가 this 를 사용하고자 하는 목적은 결국 외부에서 이 this 를 통해 더 많은 것들을 시행하고자 하는 것이다.
this 를 없애버려서야 목적을 잃게 된다.
다시 이전 코드로 돌아가서
var obj1 = {
name: 'obj1',
func: function() {
var self = this; //이 부분!
return function () {
console.log(self.name);
};
}
};
var obj2 = {
name: 'obj2',
func: obj1.func
};
var callback2 = obj2.func();
setTimeout(callback2, 1500);
var obj3 = { name: 'obj3' };
var callback3 = obj1.func.call(obj3);
setTimeout(callback3, 2000);
여기서 obj2 의 func() 가 이해가 잘 안된다면 직접 코드를 대입하면서 확인해볼 수도 있다.
var obj2 = {
name: 'obj2',
func: function() {
var self = this;
return function () {
console.log(self.name);
};
}
};
그럼 callback2 는 어떻게 처리되는 것일까?
저 코드에서 self 에 할당되는 this 는 obj2 이다. self.name 은 결국 obj2 로 찍히게 되는데 callback 은 결국
var callback = function () {
console.log('obj2');
};
다음과 같이 정리된다.
obj3 의 경우는 { name : 'obj3' } 객체가 obj3 로 할당되고, call 함수를 통해 this 바인딩되는, obj2 보다 조금 더 간편한 방법으로 처리된다.
사실 가장 좋은 방법은 bind 메서드를 직접 활용하는 것이다.
var obj1 = {
name: 'obj1',
func: function () {
console.log(this.name);
}
};
//함수 자체를 obj1에 바인딩
//obj1.func를 실행할 때 무조건 this는 obj1로 고정해줘!
setTimeout(obj1.func.bind(obj1), 1000);
var obj2 = { name: 'obj2' };
//함수 자체를 obj2에 바인딩
//obj1.func를 실행할 때 무조건 this는 obj2로 고정해줘!
setTimeout(obj1.func.bind(obj2), 1500);
bind 함수에 this 로 쓰길 원하는 객체, 코드에서는 obj1 이 들어간 것을 볼 수 있는데 이처럼 객체를 직접적으로 할당하면 저 코드 안에서의 this 는 무조건 할당된 객체로 동작한다.
이를 우리는 '명시적 바인딩' 이라고 부를 수 있다.
콜백 함수 안의 콜백 함수, 그 안에 콜백 함수... 가 반복되다 보면 사진과 같이 일어나는 현상을 '콜백 지옥' 이라고 부른다.
지옥이라고 부르는 이유는 눈에 보이는 것처럼 가독성이 상당히 떨어지고, 그만큼 유지보수의 난이도가 상승한다는 것이다.
이는 주로 비동기 코드에서 많이 일어나는데, 먼저 비동기와 동기가 무엇인지 살펴보자.
위의 경우를 동기, 밑의 경우를 비동기 처리라고 볼 수 있다.
동기
현재 실행중인 코드가 끝나야 다음 코드를 실행하는 방식이다.
1. 즉시처리가 가능한 대부분의 코드는 동기 방식을 채택한다.
2. 계산이 오래 걸리는 코드 또한 동기 방식을 채택한다.
비동기
현재 실행 중인 코드의 완료 여부와 상관없이 다음 코드로 넘어가는 방식을 말한다.
1. 예시로 위에서 사용했던 setTimeout 이나, addEventListener 등이 있다.
2. 별도의 요청, 실행 대기, 보류 등과 관련된 코드는 모두 비동기 방식을 사용한다. 특히 통신 코드가 이에 해당한다.
setTimeout(function()
{
console.log('실행1');
}, 1000);
console.log('실행2');
동기 방식의 코드라면 실행1 이 다 출력되고 나야 실행2가 출력된다.
하지만 실체 출력은 실행2 가 나오고 실행1 이 나타나는 것으로 비동기적 코드라는 것을 알 수 있다.
다음과 같은 코드 진행 양상을 보이며, 웹의 복잡도가 올라갈 수록 비동기 코드의 비중이 늘어난다.
다시 본론으로 돌아와서, 콜백 지옥의 예시 코드를 먼저 살펴보자.
setTimeout(
function (name) {
var coffeeList = name;
console.log(coffeeList);
setTimeout(
function (name) {
coffeeList += ", " + name;
console.log(coffeeList);
setTimeout(
function (name) {
coffeeList += ", " + name;
console.log(coffeeList);
setTimeout(
function (name) {
coffeeList += ", " + name;
console.log(coffeeList);
},
500,
"카페라떼"
);
},
500,
"카페모카"
);
},
500,
"아메리카노"
);
},
500,
"에스프레소"
);
매 출력마다 0.5(코드 상으로 500) 초씩 딜레이가 걸리며 출력된다.
이 코드처럼 한눈에 파악이 어려운, 가독성이 떨어지는 코드를 '콜백 지옥' 이라고 부른다.
콜백 함수는 기본적으로 함수의 이름이 붙지 않는 익명 함수로 처리되어 왔는데, 이를 기명 함수로 사용하면 어떨까?
var coffeeList = '';
var addEspresso = function (name) {
coffeeList = name;
console.log(coffeeList);
setTimeout(addAmericano, 500, '아메리카노');
};
var addAmericano = function (name) {
coffeeList += ', ' + name;
console.log(coffeeList);
setTimeout(addMocha, 500, '카페모카');
};
var addMocha = function (name) {
coffeeList += ', ' + name;
console.log(coffeeList);
setTimeout(addLatte, 500, '카페라떼');
};
var addLatte = function (name) {
coffeeList += ', ' + name;
console.log(coffeeList);
};
setTimeout(addEspresso, 500, '에스프레소');
다음과 같이 작성할 수 있지만, 일일이 이름을 붙이는 과정이 번거롭다.
콜백 함수는 의도 상 한번만 사용될건데 이름을 붙여주면서 메모리를 할당해주면 낭비가 생길 수 있다.
비동기 작업의 특징으로 순서를 보장하지 않는다, 즉 제어권이 언제 돌아올지 모른다는 점이 있다.
예시로 그림처럼 코드에서 1) 네이버에서 정보를 얻어오고, 2) 그 정보를 토대로 다음에서 처리를 하려고 한다고 해보자.
그러려면 1번과 2번의 실행 순서가 보장되어야 한다.
다만 이는 통신 과정이라 비동기적 코드로 작성을 했을텐데 만약에 네이버 서버에서 딜레이가 생겨 1번의 처리가 늦어지면 2번의 과정에도 버그가 발생한다.
이는 비동기적 코드의 '동기적 표현' 이 필요한 상황이라고 볼 수가 있다.
이러한 일의 순서를 보장받기 위함의 이유로 콜백 지옥이 나타난다.
JS의 ES6에서는 이 동기적 표현의 방법으로 'Promise' 와 'Generator' 를 소개한다.
사실 이 과정을 완벽하게 이해하기는 어렵지만, 무엇인지부터 설명하자면 비동기 처리에 대해 처리가 끝나면 알려달라 의 개념이다.
1. new 연산자로 호출한 promise 의 인자로 넘어가는 콜백은 바로 실행한다.
2. resolve(성공 시) / reject(실패 시) 의 두가지 개념으로 동작한다.
new Promise(function(reslove) {
setTimeout(function() {
var name = "에스프레소";
console.log(name);
reslove(name);
}, 500);
}).then(function(prevName){
return new Promise(function(reslove) {
setTimeout(function() {
var name = prevName + ", 아메리카노";
console.log(name);
reslove(name);
}, 500);
})
});
첫 promise 함수의 뒤에 then 메서드를 호출하여 그 안에 다음 동작을 이어가는 코드의 연계 과정을 만들어나간다 라고 이해할 수 있다.
코드 설명
1. 첫번째 new Promise 에서 에스프레소 출력을 시행
- 잘 동작했을 경우의 resolve 를 콜백함수의 매개변수로 할당
- 이후 실행할 코드 setTimeout 을 본문에서 실행
- resolve() 의 매개변수로 name 입력
2. .then() 메서드에서 첫번째 promise 가 해결됐을 경우 그 다음에 실행할 코드 할당
- 이전에 사용했던 name 이 prevName으로 할당
- 이후에 작성한 코드 아메리카노 출력 시행
이 과정을 반복한다.
다만 코드를 자세히 보다보면 반복되는 코드가 있음을 알 수 있다. 이를 리팩토링(Refactoring) 해보자.
Refactoring
반복되는 코드가 있으니 이를 return 하는 함수를 만들어서 그 함수를 반복호출 하는 방식으로 사용한다의 과정이다.
먼저 반복되는 코드는 다음과 같다.
function(prevName){
return new Promise(function(reslove) {
setTimeout(function() {
var name = prevName + ", 아메리카노";
console.log(name);
reslove(name);
}, 500);
})
이 안에서 변수로 사용할 만한 부분은 출력 대상인 '아메리카노' 이므로, 이를 매개변수로 치환할 것이다.
var addCoffe = function(coffeName)
{
return function(prevName){
return new Promise(function(reslove) {
setTimeout(function() {
//var name = prevName + ", " + coffeName;
var name = `${prevName}, ${coffeName}`;
console.log(name);
reslove(name);
}, 500);
})
}
}
addCoffe("에스프레소");
결과적으로 다음과 같이 작성할 수 있다.
다만 기존의 new Promise ~ .then( ~ ) 의 코드에는 두 가지 구조가 있다. 이를 포함하여 다시 수정한다.
var addCoffe = function(coffeName)
{
return function(prevName){
return new Promise(function(reslove) {
setTimeout(function() {
//var name = prevName + ", " + coffeName;
var newName = prevName ? `${prevName}, ${coffeName}` : coffeName;
console.log(newName);
reslove(newName);
}, 500);
})
}
}
addCoffe("에스프레소")().then(addCoffe("아메리카노"));
결론적으로 코드를 추가하고 싶으면 .then(addCoffe ( 매개변수 ) ) 의 과정만 거치면 되는, 재사용성이 뛰어난 구조가 만들어진다.
코드 해석
1. addCoffe(에스프레소) 를 작성 후 뒤에 () 를 붙인 이유로 맨 처음의 실행에는 addCoffe() 함수의 반환값이 필요한 것이 아니라 그 실행 과정이 필요하기 때문이다.
- 반환값 : function(prevName) { ~ }
- 실행값 : 반환값 안의 new Promise() ~
2. 해서 첫번째 호출에는 애초에 prevName 값도 없을 뿐더러 저 실행값이 필요한 것이니 () 를 통해서 실행하고, 이후에는 .then 을 통해서 붙여주는 과정이다.
var addCoffee = function (name) {
return new Promise(function (resolve) {
setTimeout(function(){
resolve(name);
}, 500);
});
};
var coffeeMaker = async function () {
var coffeeList = '';
var _addCoffee = async function (name) {
coffeeList += (coffeeList ? ', ' : '') + await addCoffee(name);
};
await _addCoffee('에스프레소');
console.log(coffeeList);
await _addCoffee('아메리카노');
console.log(coffeeList);
await _addCoffee('카페모카');
console.log(coffeeList);
await _addCoffee('카페라떼');
console.log(coffeeList);
};
coffeeMaker();
코드 해석
1. addCoffee() : coffeeMaker() 함수에서 호출할 함수를 선언하며, new Promise() ~ 를 반환한다.
2. Promise 를 반환하는 함수인 경우, await 를 만나면 무조건 끝날 때까지 대기한다.
ex) 만약 에스프레소를 시행하는데 10초가 걸렸을 경우, 그 다음 시행인 아메리카노는 무조건 10초 이후에 실행된다.
이 과정을 통해 동기적 표현이 가능해진다.
이 키워드도 비동기 작업의 동기적 표현 과정이다.
반복할 수 있는 객체 iterator 를 생성하여 이루어지는데, 이 iterator 는 '반복자' 라는 뜻으로 next() 메서드를 가지고 있다.
즉 비동기 작업이 완료되는 시점마다 next() 메서드를 호출하여 동기적 표현이 가능하다는 것이다.
var addCoffee = function (prevName, name) {
setTimeout(function () {
coffeeMaker.next(prevName ? prevName + ', ' + name : name);
}, 500);
};
var coffeeGenerator = function* () {
var espresso = yield addCoffee('', '에스프레소');
console.log(espresso);
var americano = yield addCoffee(espresso, '아메리카노');
console.log(americano);
var mocha = yield addCoffee(americano, '카페모카');
console.log(mocha);
var latte = yield addCoffee(mocha, '카페라떼');
console.log(latte);
};
var coffeeMaker = coffeeGenerator();
coffeeMaker.next();
var coffeeGenerator = function* () 를 보면 * 별표가 붙은 것을 알 수 있는데, 이 함수가 generator 함수이다.
코드 시행 중 yield 키워드를 만나면 동작을 멈추고 뒤의 코드가 끝날 때까지 대기한다.
코드에서는 console..log( ~ ) 부분이라고 볼 수 있을 것이다.
간단히, 매 실행마다 stop을 건다! 라고 이해하면 된다.
출력은 동일하게 나타난다.
두가지로 정리하자면
1. addCoffee : generator 함수 안에서 쓸 addCoffee 함수 선언
2. coffeeGenerator : yield 키워드를 통한 순서 제어
라고 결론내릴 수 있다.
#13. Closure (0) | 2024.11.19 |
---|---|
#12. DOM + Class (1) | 2024.11.18 |
#10. 3주차 숙제 (1) | 2024.11.08 |
#9. This (0) | 2024.11.08 |
#8. 실행 컨텍스트 (3) | 2024.11.07 |