Javascript 비동기 처리에 대한 정리

콜백(Callback), 프로미스(Promise), async/await의 차이점을 알아보자

·

3 min read

1. 콜백(Callback) 함수

  • 특징: 가장 기본적인 비동기 처리 방식. 함수를 다른 함수의 인자로 넘겨주고, 어떤 이벤트가 발생한 후 해당 함수가 나중에 호출됩니다.

  • 장점: 간단한 비동기 처리, 함수 주입을 통한 제어 역전, 오래된 라이브러리/프레임워크와 호환 용이, 낮은 학습 곡선이 있습니다.

  • 단점: 중첩된 콜백으로 인해 코드의 복잡도가 증가하는 '콜백 지옥' 이 발생 할 수 있습니다.

  • 사용 예시:

function fetchData(callback) {
    setTimeout(() => {
        callback('데이터');
    }, 1000);
}

fetchData(data => {
    console.log(data); // '데이터'
});

2. 프로미스(Promise)

  • 특징: 콜백 지옥의 해결책으로 등장했습니다. 비동기 작업의 최종 성공 또는 실패를 나타내는 객체입니다.

  • 장점: 연속된 비동기 작업을 체이닝을 통해 깔끔하게 표현 가능합니다. 작업 체인 내에서의 오류를 catch()를 통해 쉽게 할 수 있고, Promise.all() 등을 활용해서 병렬처리도 지원합니다.

  • 단점: 잘못 사용하면 여전히 콜백 지옥을 유발할 수 있습니다.

  • 사용 예시:

function fetchData() {
    return new Promise((resolve, reject) => {
        setTimeout(() => {
            resolve('데이터');
        }, 1000);
    });
}

fetchData().then(data => {
    console.log(data); // '데이터'
}).catch(error => {
    console.error(error);
});

3. async/await

  • 특징: 프로미스를 기반으로 한 비동기 처리 패턴입니다. 비동기 코드를 동기 코드처럼 보이게 하는 문법적 설탕(Syntactic sugar)입니다.

  • 장점: 코드의 가독성을 대폭 향상해줍니다. try/catch 블록을 사용한 에러 처리가 가능해서 좀 더 익숙한 방식의 에러 처리가 가능합니다.

  • 단점:

  • 사용 예시:

async function fetchData() {
    try {
        const response = await fetch('https://api.example.com/data');
        const data = await response.json();
        console.log(data);
    } catch (error) {
        console.error(error);
    }
}

fetchData();

여기서 볼 수 있듯이, 콜백 -> 프로미스 -> async/await로 넘어오면서 비동기 처리의 복잡성과 가독성 문제가 점점 개선되고 있습니다. async/await는 내부적으로 프로미스를 사용하긴 하지만, 코드를 동기식으로 보이게 하여 더 이해하기 쉽고, 관리하기 편리한 형태로 작성할 수 있게 해줍니다.

Promise와 async/await는 그럼 무조건 좋을까요?

답은 당연히 그렇지 않습니다. 지금은 많이 알려진 심각한 단점이 있습니다.

제어권의 상실

  • async/await를 사용하면, await 키워드가 사용된 비동기 작업이 완료될 때까지 함수의 실행이 멈추게 됩니다. 이는 작업이 완료되지 않으면 함수가 영원히 중단될 수 있음을 의미합니다. 즉, 프로그램의 나머지 부분으로 제어권이 돌아가지 않을 수 있습니다.

자원 누수의 위험

  • 메모리라는 제한된 자원의 누수가 축적될 수 있는 위험이 존재합니다. 하단의 예시에서는 간단한 락(lock) 메커니즘을 사용하여, work 함수의 실행 전에 락을 획득하고 실행 후에 락을 해제하는 방식으로 자원 관리를 시도해보겠습니다.
async function acquireLock() {
    // 임의의 락 획득 로직을 가정
    console.log("Lock acquired");
    return { id: "lockId" }; // 실제 구현에서는 락 객체를 반환할 것
}

function releaseLock(lock) {
    // 임의의 락 해제 로직을 가정
    console.log(`Lock ${lock.id} released`);
}

async function protect(work) {
    let lock = await acquireLock();
    try {
        await work();
    } finally {
        releaseLock(lock);
    }
}

function work() {
    return new Promise((resolve, reject) => {
        // 임의의 비동기 작업을 가정
        setTimeout(() => {
            console.log("Work done");
            resolve();
        }, 2000); // 2초 후 작업 완료
    });
}

protect(work);
  • 예로 들어진 protect 함수에서 볼 수 있듯이, work()에서 반환된 Promise가 절대 처리되지 않으면, protect() 함수는 "await 사건의 지평선"을 넘어서고 재개되지 않게 됩니다. 이는 초기에 획득한 자원이 영원히 해제되지 않음을 의미하며, 이러한 자원 누수는 시스템에 심각한 문제를 일으킬 수 있습니다.

비동기 작업 처리에 대한 보장이 없음

  • Promise가 언제 처리될지, 심지어 처리될지 여부조차도 보장할 수 없습니다. 이는 코드가 필요한 설정, 동작 수행, 그리고 해체 작업을 안전하게 완료하는 것을 어렵게 만듭니다.

그래서 이 문제를 해결하려면 어떻게 해야할까요? 내용이 너무 길어질 수 있어서 좋은 글 링크를 소개해 드리겠습니다. 자바스크립트 await 사건의 지평선

이러한 Promise와 async/await에 있는 문제들은 콜백에는 해당되지 않는 문제입니다. 콜백은 비동기 작업이 완료될 때 실행되는 함수를 제공하지만, Promiseasync/await처럼 실행 흐름을 멈추거나 "await 사건의 지평선" 같은 개념에 직접적으로 묶여 있지 않습니다.

하지만 콜백을 사용할 때도 비동기 작업의 완료를 관리하는 복잡성, 에러 처리의 어려움 등 다른 종류의 문제가 발생할 수 있으므로 여전히 좋은 선택지는 아닙니다.

마치며

위에서 소개 드린 것처럼 콜백(Callback), 프로미스(Promise), async/await는 비동기 작업을 처리하는 방법들이며, 각기 다른 장단점을 가지고 있어요. 콜백은 가장 기본적이지만 콜백 지옥으로 인한 복잡성이 문제가 될 수 있고, 프로미스와 async/await는 가독성과 에러 처리를 개선하지만, 제어권 상실과 자원 누수의 위험이 있어요. 이들 방법은 비동기 처리를 용이하게 하지만, 각각의 사용 시 주의가 필요합니다.