[JS] 콜백 함수

서로·2024년 10월 17일
0

JS

목록 보기
9/15
post-thumbnail

➊ 콜백 함수

콜백 함수는 다른 코드의 인자로 넘겨주는 함수이다.

다른 코드의 인자로 넘겨줌으로써 그 제어권도 함께 위임한다.
제어권을 위임하였다는 건 무슨 의미일까?

제어권을 넘겨받은 함수 A는 콜백 함수 B의 호출 시점, 인자, this를 결정할 권리를 가진다.

① 호출 시점

제어권을 넘겨받은 함수는 콜백 함수가 언제 호출되는지 결정할 수 있다.

아래의 예제를 살펴보자!

var count = 0;
var cbFunc = function () {
    console.log(count);
    if (++count > 4) clearInterval(timer);
};
var timer = setInterval(cbFunc, 300);

cbFunc은 콜백 함수로서 setInterval의 인자로 전달되었다.
setInterval0.3초마다 콜백 함수를 실행하는 함수이다.
이처럼 콜백 함수는 즉시 실행하는 것이 아니라 언제 실행할지에 대한 호출 시점을 setInterval에게 결정하도록 맡긴다.

② 인자

콜백 함수의 제어권을 넘겨받은 코드는 콜백 함수를 호출할 때 인자에 어떤 값들을 어떤 순서로 넘길 것인지에 대한 제어권을 가진다.

var newArr = [10, 20, 30].map(function (currentValue, index) {
    console.log(currentValue, index);
    return currentValue + 5;
});
console.log(newArr);

// 실행 결과
// 10 0
// 20 1
// 30 2
// [15, 25, 35]

map 함수는 호출 주체인 배열을 순회하면서 각 요소에 접근하는 함수이다.
각 요소에 대하여 콜백 함수를 실행하고 그 리턴값으로 새로운 배열을 생성하여 반환한다.

위의 코드에서 콜백 함수는 currentValue, index을 인자로 받는다.
이때 currentValue은 배열의 각 요소의 값을 의미하고 index은 각 요소의 순서(인덱스)를 의미한다.

🚨 만약 이 두 인자의 순서를 바꾼다 하더라도 첫 번째 인자는 값을 의미하고 두 번째 인자는 인덱스를 의미한다.

이처럼 콜백 함수의 제어권을 넘겨받은 코드는 콜백 함수를 호출할 때 인자에 어떤 값들을 어떤 순서로 넘길 것인지에 대한 제어권을 가진다.

③ this

콜백 함수도 함수이기 때문에 기본적으로는 this가 전역 객체를 참조하지만,
제어권을 넘겨받을 코드에서 콜백 함수에 별도로 this가 될 대상을 지정한 경우에는 그 대상을 참조하게 된다.

실제 map 함수는 아래의 코드와 유사하다.
어떻게 map 함수가 콜백 함수의 this를 지정할 수 있는지 한번 살펴보자!

Array.prototype.map = function (callback, thisArg) {
    var mappedArr = [];
    for (var i = 0; i < this.length; i++) {
        var mappedValue = callback.call(thisArg || window, this[i], i, this);
        mappedArr[i] = mappedValue;
    }
    return mappedArr;
}

위 코드에서 call 메서드를 사용하여 thisArg 값이 있을 경우에 thisArgthis로 지정하고,
thisArg 값이 없을 경우에 this을 전역 객체로 지정한다.

이처럼 제어권을 넘겨받은 코드에서 콜백 함수 내부에서의 this가 될 대상을 명시적으로 바인딩한다.

setTimeout(function () {
    console.log(this);
}, 300);

[1, 2, 3, 4, 5].forEach(function (x) {
    console.log(this);
});

document.body.querySelector('#a')
    .addEventListener('click', function (e) {
        console.log(this, e);
    }
);

위의 예시에서 setTimeout, forEach, addEventListener 각 함수가 인수로 콜백 함수를 넘겨받는다.

이때, setTimeout, forEach는 내부 코드에서 콜백 함수의 this를 따로 바인딩하지 않았기 때문에 전역 객체인 window가 출력된다.
그러나 addEventListeneraddEventListenerthis를 그대로 콜백 함수의 this
지정하도록 정의되어 있기 때문에 id='a'dom 요소가 출력된다.

결론!
이처럼 콜백 함수는 자신을 호출한 함수에게 제어권을 넘겨준다.
제어권이란 호출 시점, 인자, this을 결정할 권리를 말한다.
또한, 콜백 함수는 어떤 객체의 메서드여도 무조건 메서드가 아닌 함수로 호출되므로 이 점에 유의하자!

➋ 콜백 지옥

콜백 지옥은 콜백 함수를 익명 함수로 전달하는 과정이 반복되어
코드의 들여쓰기 수준이 감당하기 힘들 정도로 깊어지는 현상이다.

주로 이벤트 처리나 서버 통신과 같이 비동기적인 작업을 수행하기 위해 이런 형태가 자주 등장하는데
가독성이 떨어지는 것은 물론, 코드를 수정하기도 어렵다.

그렇다면 동기비동기는 무엇일까?

① 동기 vs 비동기

먼저, 비동기는 동기의 반대말이다.

동기적인 코드는 현재 실행 중인 코드가 완료된 후에야 다음 코드를 실행하는 방식이다.
반대로 비동기적인 코드는 현재 실행 중인 코드의 완료 여부와 무관하게 즉시 다음 코드로 넘어간다.

만약 서버에게 유저 데이터를 요청한다고 가정해보자!
동기적인 방식에서는 서버가 유저 데이터를 반환할 때까지 다른 코드를 실행하지 않고 계속 기다린다.
만약 서버의 응답이 지연되면 그동안 프로그램의 다른 작업도 지연된다.

반면에 비동기적인 방식에서는 서버에게 데이터를 요청한 후,
그 응답을 기다리지 않고 다른 코드를 실행한다.
서버가 데이터를 반환하면 그때서야 데이터를 처리한다.

이처럼 서버 요청이 잦은 웹 앱, 모바일 앱에서 비동기는 매우 중요하다!
사용자의 인터페이스가 멈추지 않도록 하고 동시에 여러 작업을 효율적으로 처리할 수 있다.

🤔 콜백 함수를 사용해서 비동기적인 작업을 수행하면
왜 콜백 지옥 현상이 자주 일어난다는 걸까?

아래의 예시를 살펴보자!

loginUser(userEmail, password, function(user) {
    console.log('User logged in:', user);
    
    getUserRole(user.id, function(role) {
        console.log('User role:', role);
        
        if (role === 'admin') {
            getAdminData(function(adminData) {
                console.log('Admin data:', adminData);
                
                // 추가적인 작업 수행
            });
        } else {
            getUserData(function(userData) {
                console.log('User data:', userData);
                
                // 추가적인 작업 수행
            });
        }
    });
});

위의 예시에서 유저의 로그인 요청을 하고 유저가 로그인을 성공하면 그 유저의 역할을 조회한다.
유저의 역할을 성공적으로 조회하면 그 유저가 관리자인지 일반 유저인지에 따라 또 비동기적인 요청을 수행한다.

(위는 간단한 예시이지만) 이처럼 연달아 비동기 작업을 수행하기 위해
콜백 함수를 사용하면 들여쓰기 수준이 깊어져 가독성이 떨어지고 유지보수도 힘들어진다.
🚨 이를 콜백 지옥이라 한다!

② Promise

콜백 지옥을 완화하기 위해 ES6부터 Promise가 도입되었다.

Promise은 어떠한 비동기 작업을 수행하고 성공 혹은 실패 여부를 반환한다.
비동기 작업이 성공했을 경우와 실패했을 경우로 나누어 각기 다른 작업을 수행할 수 있다.

Promise은 클래스이기 때문에 new 키워드를 사용하여 객체를 생성할 수 있다.
executornew Promise에 의해 자동으로 그리고 즉각적으로 호출된다.

const promise = new Promise(function(resolve, reject) {
    // executor
});

ⓐ 상태

Promise 객체는 상태를 가지는데 여기서 말하는 상태란 프로미스의 처리 과정을 의미한다.
new Promise로 프로미스를 생성하고 종료될 때까지 3가지 상태를 갖는다!

  • 대기(pending): 이행하지도, 거부하지도 않은 초기 상태
  • 이행(fulfilled): 비동기 작업이 성공적으로 완료됨 (= 성공)
  • 거부(rejected): 비동기 작업이 실패함

ⓑ Producer

new Promise 메서드를 호출할 때 콜백 함수를 선언할 수 있고, 콜백 함수의 인자는 resolve, reject이다.
resolvereject 둘 중 하나는 반드시 호출해야 한다!

  • resolve: 호출하면 Promise가 이행(fulfilled) 상태가 된다.
  • jeject: 호출하면 Promise가 실패(rejected) 상태가 된다.

ⓒ Consumer

  • then: 이행(fulfilled) 상태가 되면 처리 결과 값을 받아 그에 따른 로직을 수행한다. (= 성공)
  • catch: 실패(rejected) 상태가 되면 실패한 이유(실패 처리의 결과 값)를 받아 그에 따른 로직을 수행한다.
  • finally: 이행 또는 실패 여부와 상관 없이 마지막에 무조건 호출된다!

아래의 예제를 살펴보자!

function loginUser(userEmail, password) {
    return new Promise((resolve, reject) => {
        // 아래는 비동기 작업이라 가정
        if (userEmail === 'user@gmail.com' && password === '1234') {
            const user = { id: 1, email: userEmail };
            resolve(user);
        } else {
            reject('Invalid email or password');
        }
    });
}

function getUserRole(userId) {
    return new Promise((resolve, reject) => {
        // 아래는 비동기 작업이라 가정
        if (userId === 1) {
            const role = 'admin';
            resolve(role);
        } else if (userId > 1) {
            const role = 'user';
            resolve(role);
        } else {
            reject('User role not found');
        }
    });
}

function getAdminData() {
    return new Promise((resolve, reject) => {
        // 아래는 비동기 작업이라 가정
        const adminData = { secret: 'secret data' };
        resolve(adminData);
    });
}

function getUserData() {
    return new Promise((resolve, reject) => {
        // 아래는 비동기 작업이라 가정
        const userData = { info: 'user data' };
        resolve(userData);
    });
}

loginUser, getUserRole, getAdminData, getUserData은 모두 비동기 작업을 수행한다.
이 비동기 작업은 언제 완료될지 모르기 때문에 Promise 객체로 감싸주었다.

loginUser은 이 Promise 객체를 반환하는데 이 객체는 처음엔 대기 상태를 가지고 있다가
비동기 처리가 완료됨에 따라 이행 혹은 실패 상태를 갖게 될 것이다.

이행 혹은 실패 상태를 갖게 되면 그에 따라 분기 처리를 할 수 있다.
바로 아래의 예시 코드처럼 말이다.

loginUser(userEmail, password)
    .then(user => {
        console.log('User logged in:', user);
        return getUserRole(user.id);
    })
    .then(role => {
        console.log('User role:', role);
        if (role === 'admin') {
            return getAdminData();
        } else {
            return getUserData();
        }
    })
    .then(data => {
        console.log('Data:', data);
        // 추가적인 작업 수행
    })
    .catch(error => {
        console.error('Error:', error);
    });

이처럼 Promise을 사용하면 executor은 바로 실행되지만
그 내부에 resolve 또는 reject 함수를 호출하는 구문이 있을 경우
둘 중 하나가 실행되기 전까지 다음(then) 또는 오류(catch) 구문으로 넘어가지 않는다.

이러한 프로미스 체이닝을 통해 비동기 작업의 동기적 표현이 가능해진다.

③ async - await

ES2017부터 async - await 기능이 추가되었다.

비동기 작업을 수행하고자 하는 함수 앞에 async을 표기하고,
함수 내부에서 실질적인 비동기 작업이 필요한 위치마다 await을 표기하는 것만으로
뒤의 내용을 자동으로 promise로 전환하고, 해당 내용이 resolve된 이후에야 다음으로 진행한다.

async function handleUser(userEmail, password) {
    try {
        const user = await loginUser(userEmail, password);
        const role = await getUserRole(user.id);

        let data;
        if (role === 'admin') {
            data = await getAdminData();
        } else {
            data = await getUserData();
        }
        
        console.log('Data:', data);
        // 추가적인 작업 수행
        
    } catch (error) {
        console.error('Error:', error);
    }
}

// 함수를 호출하여 실행
handleUser('user@gmail.com', '1234');

위의 예시 코드를 살펴보면,
비동기 작업을 수행하는 loginUser, getUserRole, getAdminData, getUserData 앞에 모두 await을 표기하였다.
await이 표기된 함수들은 자동으로 promise로 전환되고 resolve된 이후에야 다음으로 진행한다!

이처럼 Promiseasync - await를 활용하면 콜백 지옥 문제를 효과적으로 해결할 수 있다.

profile
읽기 쉬운 코드와 글을 작성해요 📝

0개의 댓글