코어 자바스크립트 4장 ( callback function )
포스트
취소

코어 자바스크립트 4장 ( callback function )

해당 포스트는 코어 자바스크립트 4장을 정리한 포스트입니다.
주관적으로 해석한 내용이 들어가 있어서 잘못된 내용이 포함될 수 있습니다.
또한 교재의 모든 내용을 정리하지 않고 주관적인 판단에 의해 필요한 내용만 작성했습니다.

📥 콜백 함수 📤

콜백 함수란 다른 함수의 인자로 함수를 넘겨주는 함수를 의미합니다.
그리고 전달한 함수를 특정 사건이 발생하면 실행하기 때문에 콜백 함수라고 부릅니다.
정의만 보면 쉽고 간단합니다.

1
2
3
4
5
6
7
8
9
const getFunc = (func) => {
  // 특정 사건 ( 여기서는 "getFunc()" 호출 )
  func();
};

const callback = () => console.log("콜백 함수");

// "getFunc()"의 인자로 넘겨진 "callback()"이 콜백 함수죠
getFunc(callback); // "콜백 함수"

이 콜백 함수에 대해 깊게 생각해보면 몇 가지 중요한 점이 있습니다.

🕹️ 콜백 함수의 제어권

콜백 함수를 직접적으로 호출하는 즉, 제어권을 가지고 있는 대상은 콜백 함수를 인자로 받는 함수입니다.
이 내용을 인지하면 생각보다 많은 게 눈에 보이기 시작합니다.

0️⃣ 콜백 함수의 인자

아래처럼 함수의 인자로 제어권을 갖고 있는 함수에서 결정할 수 있습니다.

1
2
3
4
5
6
7
const getFunc = (func) => {
  func(1, 2, 3);
};

const callback = (...args) => console.log("콜백 함수", args);

getFunc(callback); // "콜백 함수" [ 1, 2, 3 ]

저희가 대표적으로 사용하는 배열 메서드들의 인자도 모두 배열 메서드 자체가 정합니다.
아래의 콜백 함수의 인자들 value, index, self 모두 Array.prototype.forEach() 내부에서 결정하죠.
저희는 명세서를 보고 “아 콜백 함수의 인자로는 이렇게 세 가지가 들어오는구나”라고 인지하고 그대로 사용하는 것입니다.

1
2
3
[1, 2, 3].forEach((value, index, self) => {
  console.log(value, index, self);
});

1️⃣ 콜백 함수의 this

일반적으로 this는 호출되는 시점에 값이 바인딩됩니다.
그 의미를 인지하고 콜백 함수를 본다면 알 수 있는 점이 있습니다.
콜백 함수는 그 콜백 함수를 인자로 받는 함수에서 제어권을 갖고 실행을 합니다.
즉, 호출되는 시점을 저희가 결정할 수 없다는 의미이고, 따라서 this의 값을 알 수 없다는 의미입니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
const getFunc = (func) => {
  const person = {
    name: "alice",
    age: 26,
    func,
  };

  // (1) 둘 다 같은 결과
  person.func();
  // func.call(person);

  // // (2) 아래는 globalThis
  // func();
};

// (3) 화살표 함수는 this 값 자체를 갖지 않기 때문에 바인딩할 수 없어서 "function" 키워드를 이용해서 정의했습니다.
const callback = function () {
  console.log("콜백 함수", this);
};

getFunc(callback); // { name: 'alice', age: 26, func: [Function: callback] }

(1)의 경우에는 의도적으로 this 바인딩을 변경해줬습니다.
(2)의 경우에는 this를 변경하지 않고 그대로 나뒀습니다.
위의 예시로 콜백 함수의 this는 알 수 없다는 결론을 얻을 수 있습니다.

물론 Array.prototype의 메서드나 EventTarget.addEventListener()같은 유명한 메서드들은 this를 이미 알고 있거나 명세서를 찾아보면 알 수 있습니다.
제가 위에서 말했던 알 수 없다는 의미는 콜백 함수의 코드만 보고 “이 값이 this야!”라고 확신할 수 없다는 의미입니다.

추가적으로 (3)에서 화살표 함수를 사용하지 않은 이유는 화살표 함수는 this 자체를 갖고 있지 않기 때문에 Function.prototype.bind()같은 메서드로도 바인딩이 불가능하기 때문입니다.

2️⃣ 콜백 함수는 메서드가 아닌 함수

함수와 메서드의 정확한 차이에서 보면 알 수 있듯이 단순히 객체의 프로퍼티로 선언된 함수가 메서드가 아닙니다.
약간 말장난처럼 느껴질 수 있지만 엄밀히 말하면 객체의 프로퍼티로 호출된 함수가 메서드입니다.

그렇다면 이 말을 왜 여기서 할까요? 바로 콜백 함수로 객체의 프로퍼티인 함수를 전달해도 메서드가 아닌 함수로써 동작한다는 것을 강조하기 위함입니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
const person = {
  name: "alice",
  age: 26,
  // (1) 이 시점에 "say()"는 메서드라고 할 수 없음
  say() {
    console.log("hi", this);
  },
};

// (2) 메서드로서 동작
person.say(); // "hi" { name: 'alice', age: 26, say: [Function: say] }

const say = person.say;
// (3) 함수로서 동작
say(); // "hi" globalThis

const getFunc = (func) => {
  func();
};

// (4) 콜백 함수를 매개변수로 받는 함수(현재는 "getFunc()")에서 this 결정 ( 즉, 함수로서 호출 )
getFunc(person.say); // "hi" globalThis

3️⃣ 콜백 함수에서 this 바인딩하기

일반적인 예시는 재미 없으니까 Array.prototype.forEach()를 직접 구현해보면서 this 바인딩을 해보겠습니다.

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
31
32
33
34
35
Array.prototype.myForEach = function (callbackFn, thisArg) {
  if (typeof callbackFn !== "function") {
    throw new Error("콜백 함수를 전달해주세요!");
  }

  for (let index = 0; index < this.length; index++) {
    // (1) "Function.prototype.call()"을 이용해서 this 바인딩
    callbackFn.call(thisArg || globalThis, this[index], index, this);
  }
};

try {
  const myArr = ["apple", "blue", "color"];
  myArr.myForEach((v, i, s) => console.log(v, i, s));
  /**
   * apple 0 [ 'apple', 'blue', 'color' ]
   * blue 1 [ 'apple', 'blue', 'color' ]
   * color 2 [ 'apple', 'blue', 'color' ]
   */

  // this 바인딩을 위해서 "function" 키워드를 사용했습니다.
  myArr.myForEach(
    function (v, i, s) {
      console.log(v, i, s, this);
    },
    ["1", "2", "3"]
  );
  /**
   * apple 0 [ 'apple', 'blue', 'color' ] [ '1', '2', '3' ]
   * blue 1 [ 'apple', 'blue', 'color' ] [ '1', '2', '3' ]
   * color 2 [ 'apple', 'blue', 'color' ] [ '1', '2', '3' ]
   */
} catch (error) {
  console.error("error >> ", error);
}

bind(), call(), apply()를 이용해서 this를 바인딩 할 수 있습니다.((1))

⏰ 비동기 콜백

조금 의도적이긴 하지만 아래처럼 콜백은 콜백 지옥이 생길 수 있습니다.
(1)의 결과를 가지고 (2)를 만들고, (2)의 결과를 가지고 (3)을 만들기 때문에 어쩔 수 없이 (1)내부에 (2)를 넣고 (2)내부에 (3)을 넣어야 하는 상황이 발생합니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// (1)
setTimeout((name) => {
  let menu = "";

  menu += name + " ";

  // (2)
  setTimeout((name) => {
    menu += name + " ";

    // (3)
    setTimeout((name) => {
      menu += name + " ";

      console.log(menu);
    }, 500, "루이보스");
  }, 500, "캐모마일");
}, 500, "페퍼민트");

0️⃣ Promise

비동기 콜백 지옥을 해결하기 위해서는 Promise를 사용할 수 있습니다.

기본적으로 resolve(), reject()가 호출되면 then()이 실행되게 됩니다.
아래는 Promise의 사용법을 이해하기 위해서 Promise를 비동기 없이 사용했습니다.

1
2
3
4
5
6
7
const func = () => {
  return new Promise((resolve, reject) => {
    resolve(20);
  });
};

func().then((res) => console.log(res));

아래는 비동기를 적용한 Promise 사용 방법에 대한 예시입니다.

1
2
3
4
5
6
7
8
9
10
11
12
// "Promise"를 반환하는 함수 ( 비동기 함수 )
const func = () => {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      resolve(20);
    }, 1000);
  });
};

// 1초를 기다렸다가 "then()"의 콜백이 실행됩니다. ( 즉, 익명의 화살표 함수 "(res) => console.log(res)" )
// "resolve()"에 전달한 값이 콜백의 첫 번째 인자로 들어옵니다.
func().then((res) => console.log(res));

하지만 저의 경험으로는 new Promise를 이용해서 구현하는 경우는 거의 없습니다.
프론트에서 서버로 온 요청을 강제로 기다리게 하는 의도를 제외하고는 사용해 본 적이 없습니다.

실제로는 아래처럼 Promise를 반환하는 함수를 사용하는 것이 대부분입니다. ( fetch, axios, prisma, sequelize 등 )

1
2
3
4
5
6
// "fetch" 내부적으로 "Promise"를 반환
// (input: RequestInfo | URL, init?: RequestInit): Promise<Response>;
fetch("https://api.github.com/users/1-blue")
  .then((res) => res.json())
  .then((data) => console.log(data))
  .catch((error) => console.error(error));

1️⃣ Generator

아래처럼 비동기를 순차적으로 처리할 수 있긴 한데 사용법이 미숙해서 실질적인 예시는 생략하겠습니다
( TODO: 나중에 알게 되면 추가 )

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
const mulNum = (num) => {
  setTimeout(() => g.next(num * 2), 500);
};

function* gf(num) {
  // 1

  num = yield mulNum(num);
  console.log(num); // 2

  num = yield mulNum(num);
  console.log(num); // 4

  num = yield mulNum(num);
  console.log(num); // 8

  num = yield mulNum(num);
  console.log(num); // 16

  num = yield mulNum(num);
  console.log(num); // 32

  num = yield mulNum(num);
  console.log(num); // 64
}

const g = gf(1);

g.next();

2️⃣ async/await

Promise가 개발되고 나서 비동기 콜백 지옥에서는 벗어났지만 Promise지옥이라는 개념이 생겼습니다.

아래의 예시는 의도적으로 만들었긴 하지만 이전에 패치한 값을 기반으로 새로운 비동기 요청을 하는 경우 then()으로 계속 연결하게 됩니다.
이런 경우를 Promise지옥이라고도 부릅니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
const doubleNumber = (number) => {
  return new Promise((resolve, reject) => {
    setTimeout(() => resolve(number * 2), 200);
  });
};

new Promise((resolve, reject) => {
  setTimeout(() => {
    resolve(1);
  }, 200);
})
  .then((number) => doubleNumber(number))
  .then((number) => doubleNumber(number))
  .then((number) => doubleNumber(number))
  .then((number) => doubleNumber(number))
  .then((number) => doubleNumber(number))
  .then((number) => doubleNumber(number))
  .then((number) => doubleNumber(number))
  .then(console.log);

async/await을 사용하면 비동기로 동작해도 동기적인 코드처럼 작성하고 해석할 수 있어서 가독성이 더 좋습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
const getRandomId = () => {
  return new Promise((resolve, reject) => {
    console.log("아이디 생성중...");
    setTimeout(() => {
      resolve(Math.floor(Math.random() * 10));
    }, 1000);
  });
};

const getUser = (userId) =>
  fetch(`https://jsonplaceholder.typicode.com/users/${userId}`);

(async () => {
  const userId = await getRandomId();
  const response = await getUser(userId);
  const user = await response.json();

  console.log(user);
})();

코드의 흐름대로 userId를 가져오고, userId를 이용해서 유저 정보를 가져오는 흐름이 명확하게 이해가 됩니다.

📮 레퍼런스

  1. « 코어 자바스크립트 4장 » ( 정재남 지음, 위키북스, 2019 )
  2. this - 1-blue
  3. 함수와 메서드의 정확한 차이
이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.