JavaScript/JS를 파헤쳐보자

[JS] 자바스크립트의 비동기 처리

동구름이 2024. 7. 30. 21:31

자바스크립트에서 비동기 처리와 비동기를 어떻게 처리하는지 정리해보자.

 

비동기 처리

 동기와 비동기는 상반된 개념이다. 동기는 순차적으로 코드가 실행되는 것을 말한다. 동기 처리 방식은 순차적으로 코드가 실행되어서 이해하기가 직관적이지만 한 가지 문제가 있다.

 

 이전 코드가 끝나기 전까지는 아무것도 하지못해서 다음 코드로 넘어갈 수 없다는 것인데, 아주 쉬운 예로는 자장면을 배달하시는 배달부가 짜장면을 배달하고, 손님이 짜장면을 전부 먹을 때까지 다른 일도하지못하고, 계속해서 대기해야하는 것과 비슷하다.

 

 

그래서 비동기 처리 방식이 등장한다.

 

 비동기 처리는 꼭 순차적으로 동작하지 않는다. 위의 예시로 보자면, 배달부가 자장면을 배달하고 손님이 식사할 동안 다음 장소로 이동할 수 있는 것이다.

 

여기서 한 가지 놓치지 말아야할 포인트가 있다. 위 예시가 가능하려면, 바로 손님과 배달부가 다른 쓰레드에서 동작한다는 것이다. 손님은 손님쓰레드에서 식사를 하고, 배달부는 배달 쓰레드에서 동작을 한다.

 

그래서 프로그램이 비동기로 처리된다는 것은 여러 쓰레드가 동작하는 것이다. (자바스크립트의 쓰레드 환경에 대해서는 이전 포스팅에서 다루었다.)

 

자바스크립트 비동기 처리

콜백 함수

자바스크립트에서 이렇게, 특정 로직의 실행이 끝날 때까지 기다리지 않고 나머지 코드를 먼저 실행하려면 콜백 함수를 이용할 수 있다.

function fetchData(**callback**) {
    setTimeout(() => {
        const data = '데이터 수신 완료';
        callback(data);
    }, 1000);
}

// 콜백 함수
function processData(data) {
    console.log('처리된 데이터:', data);
}

// fetchData 함수에 콜백 함수 전달
fetchData(processData);

콜 백함수는 이전에 다루었지만, 함수의 인자로 전달되어 호출되는 함수를 말한다. 콜백함수를 통해 특정 로직이 끝났을 때, 원하는 동작을 실행시킬 수 있다.

 

콜백 지옥

그러나 콜백 함수는 콜백 지옥이라는 문제가 생긴다.

function asyncTask(taskName, callback) {
    setTimeout(() => {
        console.log(`${taskName} 완료`);
        callback();
    }, 1000);
}

function performTasks() {
    asyncTask('작업 1', () => {
        asyncTask('작업 2', () => {
            asyncTask('작업 3', () => {
                asyncTask('작업 4', () => {
                    asyncTask('작업 5', () => {
                        console.log('모든 작업 완료');
                    });
                });
            });
        });
    });
}

performTasks();

모든 과정을 비동기로 처리를 하려면, 콜백안에 콜백을 물게 되면서 가독성이 매우 떨어지게 된다.

 

 그래서 이 콜백 지옥을 해결하기 위해 등장한 것이 PromiseAsync이다. 물론 코딩 패턴으로 콜백 지옥을 해결할 수는 있다. 하지만 Promise와 Async를 통해 더 편하게 구현 가능하다.

 

 

Promise

Promise는 자바스크립트에서 비동기 처리에 사용되는 객체이다.

// 숫자 제곱 비동기 함수
function squareNumber(number) {
    return new Promise((resolve, reject) => {
        setTimeout(() => {
            if (typeof number === 'number') {
                resolve(number * number);
            } else {
                reject(new Error('입력 값이 숫자가 아닙니다.'));
            }
        }, 2000);
    });
}

squareNumber(5)
    .then(result => {
        console.log('제곱 결과:', result);
    })
    .catch(error => {
        console.error('오류 발생:', error.message);
    });

 위 코드는 프로미스의 간단한 예시이다. 한눈에 봐도 가독성이 좋아보인다.

 

 

프로미스의 장점은 이렇게 비동기를 가독성 있게 처리하는 것도 있지만, 3가지 상태를 통해 프로미스 처리 과정을 나타내고 그에 따라 로직을 제어할 수 있다는 점이 있다. 이것을 통해 프로미스 구성을 하나하나 살펴보자.

 

 

 

프로미스의 3가지 상태

프로미스는 3가지 상태가 있는데 전혀 어렵게 생각할 필요는 없다.

  • 대기(Pending)
  • 이행(Fulfilled)
  • 거부(Rejected)

위에서부터 말 그대로 프로미스가 대기 중일때, 실행 되었을 때, 실패했을 때가 전부이다. 각각 하나씩 살펴보자

 

대기(Pending)

function pendingPromise() {
    return new Promise((resolve, reject) => {
        // 프로미스는 아직 이행되지도 거부되지도 않음
        console.log('프로미스 대기 중...');
    });
}

const promise = pendingPromise();

 pending은 비동기 처리 로직이 아직 완료되지 않은 것을 말한다.

 

 위 예시에서 보이듯이 콜백함수의 인자로 resolve와 reject가 있다. 이 두 가지의 역할이 무엇일까? 아래의 이행과 거부에서 쉽게 알 수 있다!

 

 

이행(Fulfilled)

function fulfilledPromise() {
    return new Promise((resolve, reject) => {
        setTimeout(() => {
            resolve('작업 성공');
        }, 1000);
    });
}

fulfilledPromise().then(result => {
    console.log('이행 결과:', result);
});

콜백 함수의 인자인 resolve를 실행해서 이행 상태를 표현할 수 있다. 말 그대로 작업이 제대로 성공했다는 것이다.

 

이후 then()을 이용해 처리 결과 값을 받을 수 있다.

 

 

거부(Rejected)

function rejectedPromise() {
    return new Promise((resolve, reject) => {
        setTimeout(() => {
            reject(new Error('작업 실패'));
        }, 1000);
    });
}

rejectedPromise().catch(error => {
    console.log('에러 메시지:', error.message);
});

반대로 reject를 통해 실패 상태를 표현할 수 있다. 이때는 catch를 이용해 실패 에러를 받는다.

 

 

 

이렇게 프로미스 처리 흐름을 나타낸 그림은 아래와 같다.

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

 

영어 용어가 안 익숙할 뿐 개념 자체는 대기, 성공, 실패가 전부이니 쉽게 이해가 간다.

 

 

 

프로미스를 통해 위의 콜백 지옥을 아래처럼 간단히 나타낼 수 있다.

function asyncTask(taskName) {
    return new Promise((resolve) => {
        setTimeout(() => {
            console.log(`${taskName} 완료`);
            resolve();
        }, 1000);
    });
}

// 프로미스를 사용한 예제
asyncTask('작업 1')
    .then(() => asyncTask('작업 2'))
    .then(() => asyncTask('작업 3'))
    .then(() => asyncTask('작업 4'))
    .then(() => asyncTask('작업 5'))
    .then(() => console.log('모든 작업 완료'));

이렇게 여러 개의 프로미스를 .then()으로 연결해 처리할 수 있다.

 

async & await

async & await은 비동기처리 문법 중 가장 최근에 나온 문법이다. 그렇다는 것은 기존 문법의 단점을 보완한 부분이 있지 않을까?

 

 

예시를 통해 기존의 콜백, 프로미스에 비해 어떤 장점을 취할 수 있는지 알아보자

 

async & await의 가장 큰 장점은 읽기가 편하다는 것이다. 앞서 다루었던 프로미스 코드를 가져와서 async & await로 변경해보자!

// 숫자 제곱 비동기 함수
function squareNumber(number) {
    return new Promise((resolve, reject) => {
        setTimeout(() => {
            if (typeof number === 'number') {
                resolve(number * number);
            } else {
                reject(new Error('입력 값이 숫자가 아닙니다.'));
            }
        }, 2000);
    });
}

squareNumber(5)
    .then(result => {
        console.log('제곱 결과:', result);
    })
    .catch(error => {
        console.error('오류 발생:', error.message);
    });

위는 프로미스 객체를 이용한 비동기 방식이다.

 

// 숫자 제곱 비동기 함수
function squareNumber(number) {
    return new Promise((resolve, reject) => {
        setTimeout(() => {
            if (typeof number === 'number') {
                resolve(number * number);
            } else {
                reject(new Error('입력 값이 숫자가 아닙니다.'));
            }
        }, 2000);
    });
}

async function getSquaredNumber(number) {
    try {
        const result = await squareNumber(number);
        console.log('제곱 결과:', result);
    } catch (error) {
        console.error('오류 발생:', error.message);
    }
}

getSquaredNumber(5);

그리고 위는 같은 코드에 async & await를 적용한 것이다.

 

 

이렇게 하면 가장 큰 장점이 뭘까?

 

위 프로미스 객체와 콜백 함수는 콜백함수의 틀을 따르기 때문에, 사실 사고방식에 있어서 적응하기가 어렵다.

 

하지만 async & await는 꽤 직관적이다. async를 통해 함수의 시작을 알리고 내부의 await에서 비동기 함수를 호출하는 것을 명시한다. 여기서 await 안에는 반드시 프로미스 객체를 반환해야한다.

추가로 위 예시처럼 async & await 에서는 에러 처리를 try catch로 잡아주기 때문에, 기존의 사고방식을 유지할 수 있다.

 

 

개인적인 방법으로.. 조금 더 쉽게 이해하려면 영단어 자체를 해석하면 되는데, async는 비동기를 뜻하고, await은 ~을 기다린다는 뜻이다.(+ wait은 기다리다라는 뜻이라면 await은 영단어 자체가 ~을 기다리다 의 의미의 타동사이다!)

 

 

 비동기 함수임을 명시하고 안에서 ~을 기다리겠다는 의미로 해석하면 어떤 방식으로 동작이 이루어지는지 직관적으로 이해할 수 있지 않을까?

 

 

여기까지 자바스크립트의 비동기 처리 패턴을 살펴보았다.

 

 

 

 

 

참고자료

https://noodabee.tistory.com/entry/Nodejs-EventEmitter란-feat-Promise와의-차이점
https://joshua1988.github.io/web-development/javascript/js-async-await/
https://developer.mozilla.org/ko/docs/Web/JavaScript/Reference/Global_Objects/Promise
https://sheldonlee.tistory.com/113
https://www.youtube.com/watch?v=m0icCqHY39U