탱구탱구 개발자 일기

동기와 비동기 처리

동기와 비동기 처리

  • 동기 처리(Synchronous Processing) : 하나의 작업이 끝난 뒤 다음 작업을 진행하는 순차적인 처리 방식
  • 비동기 처리(Asynchronous Processing) : 하나의 작업의 종료까지 기다리지 않고 다음 작업을 진행하는 비순차적인 처리방식

 

자바스크립트 비동기 처리

자바스크립트에서 비동기 처리를 빼놓으면 시체라고 할 정도로 개발할 때 정말 많이 쓰이는 기술이다. 자바스크립트는 싱글 스레드 기반의 언어이기 때문에 작업을 처리하는 공간(호출 스택)이 하나이다. 따라서 비동기적으로 실행되는 setTimeout, addEventListener 메서드나 XMLHttpRequest 객체에서의 코드는 작성한 순서대로 실행되지 않는다.

 

예제 setTimeout

console.log("A");
setTimeout(function () {
    console.log("B");
}, 0);
console.log("C");

코드는 0초 후에 로그로 B를 출력하도록 짜여있지만 원하는 결과(A->B->C)를 얻지 못했다. 이유는 해당 코드가 0초 후에 실행되지 않기 때문이다.

 

결과

A
C
B

 

비동기 처리 프로그램의 실행 과정

 

다른 동기 처리 프로그램과 마찬가지로 비동기 처리 프로그램도 호출 스택에서 실행된다. 다만 실행하기에 앞서 이벤트 큐에 대기 행렬을 생성한다. 그리고 현재의 실행 중인 함수 or 문맥이 끝나면, 즉 호출 스택에서 pop 되면 그 대기 행렬에 들어온 순서대로 호출 스택에 push 해서 실행한다.(선입선출, FIFO)

 

비동기 처리의 장단점

비동기 처리를 사용한다면 동기 처리 프로그램보다 자원을 좀 더 효율적으로 사용할 수 있다. 비동기 처리를 요청하고 그 처리 시간 동안 다른 작업을 할 수 있기 때문이다. 반면에 동기 처리 방식 프로그램보다는 개발이 복잡해지는 단점이 있다.

 

AJAX (Asynchronous Javascript And Xml)

AJAX는 비동기적 자바스크립트 and xml을 말한다. AJAX가 활용되기 이전에 웹브라우저는 정적인 구조를 갖고 있었고 동기 처리 방식으로 인해 대단히 느렸다. 페이지 내용이 변경될 때, 즉 클라이언트에서 서버에 새로운 정보를 요청하고 응답할 때마다 HTTP 프로토콜이 재설정되면서 페이지 전체가 갱신(새로고침)되었다. 실제로는 페이지의 일부가 변경되는 것이었지만 전체가 새로 요청되고 따라서 자원 및 시간 소모가 심했다.

 

그러나 기존에 웹브라우저의 XMLHttpRequest 객체를 활용한 AJAX 개념이 주목받으면서 더이상 페이지의 전체 갱신이 아닌 변경되는 부분의 데이터만 AJAX를 통해서 받아 갱신하면서 그 문제점을 해결할 수 있게 되었다. 이 기술이 널리 활용되면서 웹페이지가 동적으로 변화하였고 로딩 속도도 빨라졌다. 이렇게 자바스크립트는 비동기적으로 서버에 요청을 보낸 뒤 계속해서 다른 작업을 가능할 수 있게 되었다.

 

동작원리

1. 클라이언트에서 자바스크립트 함수를 통해 AJAX 요청을 한다.

2. XMLHttpRequest 객체의 인스턴스가 생성된다.

3. XMLHttpRequest를 통해 현재 HTML의 상태를 가진 XML 메시지를 구성하여 웹서버로 요청한다.

4. 웹서버에서 처리 후 응답값을 XML 메시지를 보내 XMLHttpRequest 객체가 수신한다.

5. 수신된 XML 메시지를 파싱하여 데이터를 업데이트한다.

 

AJAX의 장점

1. 웹페이지 전체를 다시 로딩하지 않고 일부만 갱신 가능하여 전체적인 속도를 향상한다.

2. 서버의 처리가 완료될 때까지 기다리지 않고 비동기적인 처리 가능하다.

3. 서버는 Data만 전송만 하고 클라이언트는 view에 대한 처리를 하여 서버에서 처리하는 일이 줄어들었다.

4. 기존 웹에서는 불가능했던 다양한 동적인 개발이 가능하다.

 

AJAX의 단점

1. 히스토리 관리가 안 된다. (보안에 좀 더 신경을 써야 함. plugin, hash 사용 가능)

2. 비동기 처리의 단점으로 연속적인 데이터를 요청하면 서버 부하가 증가할 수 있다.

3. 2번 단점이 발생하는 주요 원인으로 요청을 보내고 특정 페이지 처리를 하지 않는다면 사용자는 서버에 요청 중이라는 것을 알 수 없기 때문에 아직 통신이 완료되지 않았는데 사용자가 페이지를 떠나거나 오작동할 우려가 발생하게 된다. 

   

 

기존 비동기 처리의 문제점과 Promise의 등장

기존 비동기 처리의 문제점

실제로 개발하다 보면 코드의 실행 순서는 매우 중요하다. 예를 들어 API를 사용하여 다른 서버에 저장된 정보를 응답받았을 때 그 정보를 내 DB에 저장하고 메인 페이지로 이동하는 프로그램이 있다. 이 프로그램의 기능은 크게 API를 호출하는 기능 내 DB서버를 호출하는 기능 그리고 메인 페이지로 이동하는 기능으로 나눌 수 있다.

 

이 프로그램에서 기대한 기능은 API 서버를 호출해 응답 값을 DB에 저장하고 메인화면으로 이동하는 것이다. 그러나 실제로 API 호출을 했지만 언제 그 API 서버가 응답값을 줄지 모른다. 자바스크립트는 AJAX를 통해서 API 서버를 호출하게 되는데 말 그대로 비동기적 처리이기 때문에 서버에 요청한 후 응답을 기다리지 않고 다음 코드를 진행한다.

따라서 응답이 아직 오지 않았는데 다음 코드인 메인 페이지로 이동하는 로직이 실행될 수 있다. 이러한 문제점 때문에 비동기적 처리의 실행 순서를 제어하는 것이 필요하고 그 방법으로 콜백함수 있었다.

 

콜백 지옥(Callback Hell)

앞서 이러한 비동기적 처리의 실행 순서는 콜백 함수를 통해 해결할 수 있다고 말했는데 실제 웹 서비스에서처럼 AJAX를 통해 서버는 진짜 데이터만 전송하고 모든 것을 클라이언트에서 처리한다고 했을 때 통신 이후 해야 하는 작업이 엄청 많을 것이다. 가령 데이터를 받아와서 인증을 하거나 데이터를 parsing 하거나 화면에 알맞은 형태로 재가공한다는 등.. 그리고 이 작업들에도 반드시 순서가 있을 것이다.

 

이렇게 할 경우 함수에서 다른 함수를 계속 호출시킬 것이고 바로 콜백 지옥 상황이 펼쳐진다.

$.get('url', function (res) {
    dataParse(res, function (data) {
        changeData(data, functioan (result) {
            show(result, function (num) {
                console.log(num);
            });
        });
    });
});

첫 번째 함수에서 실행할 콜백 함수를 정의하고 그 콜백 함수에서 dataParse함수를 그 함수에서 changeData 함수를 또 show 함수를.. 이렇게 계속 함수를 물고 가서 마지막에 가공된 데이터를 보여주는 식으로 정말 지옥이 펼쳐졌다.

들여 쓰기 지옥을 해결해보겠다고 아래와 같이 분리하는 패턴을 사용해도 결국엔 마찬가지다. 오히려 코드 속에서 보면 더 징그럽게 복잡할 수 있다.

function dataParse(res) {
    auth(res, changeData);
}
function changeData(result) {
    display(result, show);
}
function show(text) {
    console.log(text);
}
$.get('url', function (response) {
    parseValue(response, dataParse);
});

이렇게 딱히 콜백 지옥 문제를 해결할 수 있는 획기적인 방법이 없었지만 ECMAScript 6부터 Promise 객체가 추가되면서 이러한 콜백 지옥을 해결할 수 있고 비동기 처리도 이전보다 간결한 코드로 작성할 수 있게 되었다.

 

Promise(프로미스)

기본

Promise는 비동기 처리를 실행하고 그 처리가 끝난 후 다음 처리를 실행하기 위한 기능을 제공한다. Promise는 아래와 같이 Promise 객체를 생성하는 것부터 시작한다.

// Promise

let promise = new Promise(function (resolve, reject) {
    // 최초 비동기 처리 코드
});

// 화살표 함수
let promise = new Promise((resolve, reject) => {
    // 최초 비동기 처리 코드
});

위 코드처럼 promise에는 실행하고자 하는 코드가 담긴 함수를 인수로 갖는다. 또한 그 함수에는 resolve와 reject라는 콜백 함수를 인수로 갖고 있다.

  • resolve : 함수 안의 처리가 성공적으로 끝났을 때 호출해야 하는 콜백 함수. resolve 함수에는 어떠한 값도 인수로 넘길 수 있다. 이 값은 다음 처리를 실행하는 함수에 전달된다.
  • reject : 함수 안의 처리가 실패했을 때 호출해야 하는 콜백 함수. reject 함수에도 어떠한 값도 인수로 받을 수 있는데 주로 오류 메시지 문자열을 인수로 넘긴다.

프로미스의 상태

  • 대기(pending): 이행하거나 거부되지 않은 초기 상태.
  • 이행(fulfilled): 연산이 성공적으로 완료됨.
  • 거부(rejected): 연산이 실패함.

resolve 예제

let promise = new Promise((resolve, reject) => {
    // 최초 비동기 처리 코드
    setTimeout(() => {
        console.log("A");
        resolve("B");
    }, 1000);
});

promise.then((hello) => {
    console.log(hello);
});

Promise 객체를 생성하여 setTimeout메서드를 통해 비동기 처리를 실행하였다. 그리고 다음에 처리할 콜백 함수인 resolve에 문자열 B를 넘겼다. 그리고 resolve 함수를 호출하였다. 이렇게 resolve 함수를 호출하면 Promise 안의 비동기 처리를 종료시킨다. 그리고 다음에 실행할 작업인 then 메서드에 등록한 코드를 호출하게 된다. 이때 resolve 함수의 인수인 "B"는 then 메서드에 등록한 함수의 인수, 즉 hello로 받아서 console.log에서 사용할 수 있다. 당연히 hello는 그 함수 안에서만 유효하기 때문에 이름은 아무것이나 상관없다.

 

결과

promise 기본

1초 후에 promise 객체에 등록된 비동기 처리가 실행되며 곧바로 then 메서드에 등록한 함수를 실행한다.

그렇다면 promise 안에서 오류가 났을 때도 예제를 통해 살펴보자.

 

reject 예제

// reject 상황
let promise = new Promise((resolve, reject) => {
    // 최초 비동기 처리 코드
    setTimeout(() => {
        let num = parseInt(prompt("10 이하의 숫자를 입력하시오"));
        if (num <= 10) {
            resolve("정답");
        }else {
            reject(`오류 : 숫자 ${num}은(는) 10을 초과하였습니다.`);
        }
    }, 1000);
});

promise
    .then((hello) => {
        console.log(hello);
    })
    .catch((error) => {
        console.log(error);
    });

해당 코드를 사용하면 다음과 같이 1초 후에 입력창이 나온다. 그 안에 조건에 부합하지 않는 10을 초과하는 숫자를 입력하고 확인 버튼을 누르게 될 경우 reject 콜백 함수가 실행되어 아래와 같은 결과를 확인할 수 있다.

비동기 처리 실행

결과

오류 : 숫자 11은 10을 초과하였습니다.

 

then 메서드의 두 번째 인수

then 메서드의 두 번째 인수로 실패 콜백 함수를 지정하면 catch 메서드를 사용하지 않고 하나로 작성할 수 있다.

// then의 두 번째 인수로 reject 콜백 함수 넘기기

let promise = new Promise((resolve, reject) => {
    // 최초 비동기 처리 코드
    setTimeout(() => {
        let num = parseInt(prompt("10 이하의 숫자를 입력하시오"));
        if (num <= 10) {
            resolve(num);
        }else {
            reject(`오류 : 숫자 ${num}은(는) 10을 초과하였습니다.`);
        }
    }, 1000);
});

promise.then(
    (num) => {
        console.log(`정답 : 숫자 ${num}은(는) 10 이하입니다.`);
    },
    (error) => {
        console.log(error);
    }
)

결과

// 9입력
정답 : 숫자 9은(는) 10 이하입니다.

// 15입력
오류 : 숫자 15은(는) 10을 초과하였습니다.

 

Promise가 실행하는 콜백 함수에 인수를 넘길 수도 있다. 그러기 위해선 Promise 객체를 반환하는 함수를 정의하여 그 함수의 인수에 원하는 값을 넘기면 된다.

예제

 

아래 코드는 비동기 처리로 상품에 대한 지불 금액을 입력한 후에 잔액을 표시하는 프로그램이다. Promise가 실행하는 콜백 함수에 budget이라는 인수를 넘겨 사용하도록 처리하였다. 예제에서는 1000원을 넘겼다.

function buySomething(budget) {
    return new Promise((resolve, reject) => {
        setTimeout(() => {
            let payment = parseInt(prompt("얼마를 지불하시겠습니까?"));
            let balance = budget - payment;
            if (balance > 0) {
                console.log(`${payment}원을 지불하였습니다.`);
                resolve(balance);
            } else {
                reject(`잔액이 부족합니다. 잔액은 ${budget}원 입니다.`);
            }
        }, 1000);
    });
}

buySomething(1000)
    .then((balance) =>{
        console.log(`잔액은 ${balance}원 입니다.`);
    })
    .catch((error) =>{
        console.log(error);
    });
budget 인수를 넘겨서 처리

결과

900원을 지불하였습니다.
잔액은 100원 입니다.

 

만약 1500원짜리 상품에 대한 금액을 지불하였으면 아래와 같은 결과를 얻을 수 있다.

잔액이 부족합니다. 잔액은 1000원 입니다.

이처럼 buySomething 함수는 Promise 객체를 반환하는 함수이고 그 함수에 넘긴 인수를 Promise 객체가 실행하는 콜백 함수인익명 함수 안에서 사용할 수 있다.

 

Promise 응용 - 비동기 처리 연결하기

비동기 처리 여러 개를 직렬로 연결(순차적인 실행)

Promise를 사용하면 비동기 처리를 여러 개 연결하여 순차적으로 실행할 수 있다. then 메서드 안에서 실행하는 성공 콜백 함수가 다시 Promise 객체를 반환하도록 코드를 처리하면 된다.

 

예제

buySomething(1000)
    .then((balance) =>{
        console.log(`잔액은 ${balance}원 입니다.`);
        return buySomething(balance);
    })
    .then((balance) =>{
        console.log(`잔액은 ${balance}원 입니다.`);
        return buySomething(balance);
    })
    .then((balance) =>{
        console.log(`잔액은 ${balance}원 입니다.`);
        return buySomething(balance);
    })
    .catch((error) =>{
        console.log(error);
    });

위와 같이 순차적으로 then 메서드 체인을 통해 Promise 작업은 연결시켰다. 조건에 부합한다면 buySomething 함수를 통해 추가적으로 3번 더 쇼핑할 수 있다. 물론 then이 여러 번 연결되었어도 조건에 부합하지 않는다면 reject 콜백 함수를 호출할 것이다.

결과

800원을 지불하였습니다.
잔액은 200원 입니다.
100원을 지불하였습니다.
잔액은 100원 입니다.
잔액이 부족합니다. 잔액은 100원 입니다.

예제에서는 모든 then 메서드에서 동일한 Promise 객체를 반환하지만 then마다 다른 Promise 객체를 반환하게 하여 다른 비동기 처리를 연결하여 순차적으로 실행할 수 있다.

 

비동기 처리 여러 개를 병렬로 연결

 

- Promise.all 메서드

Promise 객체의 all 메서드를 사용하면 비동기 처리 여러 개를 병렬로 실행할 수 있다. 그리고 모든 처리가 성공했을 때만 다음 작업을 실행하도록 처리할 수 있다.

 

사용법

Promise.all(iterable);

 iterable은 Promise 객체가 요소로 들어 있는 반복 가능한(iterable) 객체이다. 예를 들어 아래와 같이 Promise 객체가 요소로 들어 있는 배열을 넘기면 Promise.all 메서드는 그 안의 요소로 들어 있는 모든 Promise 객체를 병렬로 실행한다. 그리고 그 모든 Promise 객체에서 resolve 함수를 호출한 후 then 메서드에 지정한 함수(다음에 처리해야 할 작업)를 실행한다. 위에서 resolve 함수의 인수로 넘긴 값을 then 메서드에 등록한 함수의 인수로 받아서 쓸 수 있었듯이 각각의 resolve 함수의 인수가 response라는 배열의 요소로 들어가 반환된다.

 

만약 그 Promise 객체에서 하나라도 실패로 끝난다면(reject 콜백 함수를 호출) 가장 먼저 실패로 끝난 Promise 객체에서 실행한 reject 함수의 인수를 실패 콜백 함수의 인수로 받을 수 있다.

 

예제

function buySomething(name, budget) {
    return new Promise((resolve, reject) => {
        setTimeout(() => {
            let payment = parseInt(prompt(`${name}님, 얼마를 지불하시겠습니까?`));
            let balance = budget - payment;
            if (balance > 0) {
                console.log(`${name}님 ${payment}원을 지불하였습니다.`);
                resolve(balance);
            } else {
                reject(`잔액이 부족합니다. ${name}님의 잔액은 ${budget}원 입니다.`);
            }
        }, 1000);
    });
}

Promise.all([
    buySomething("탱구링", 600),
    buySomething("아저씨", 1000),
    buySomething("피카츄", 300)
])
    .then((balance) =>{
        console.log(balance);
    })
    .catch((error) => {
        console.log(error);
    });

 위 예제는 고객 이름을 받아서 여러 명이 쇼핑할 수 있도록 처리한 코드이다.

결과

- 성공했을 때

탱구링님 300원을 지불하였습니다.
아저씨님 500원을 지불하였습니다.
피카츄님 200원을 지불하였습니다.
(3) [300, 500, 100]

위 코드를 실행하면 1초 후 프롬프트 창을 통해 all 메서드의 인수로 받은 배열의 요소로 등록된 모든 Promise 객체가 실행된다. 모든 작업이 성공으로 끝나면 then 메서드에 등록한 성공 콜백 함수가 실행된다. 그리고 모든 Promise 객체에서 실행된 resolve 함수의 인수가 response라는 배열의 요소 값으로 들어가 반환된다.

그러나 하나라도 실패로 끝나면 catch 메서드에 등록한 실패 콜백 함수가 실행되고 가장 먼저 실패로 끝난 Promise 객체에서 실행한 reject 함수의 인수가 콜백 함수의 인수로 들어간다.

 

- 실패했을 때

탱구링님 500원을 지불하였습니다.
잔액이 부족합니다. 아저씨님의 잔액은 1000원 입니다.

병렬 처리이기 때문에 하나가 성공했을 때는 해당 성공한 것과는 별개로 가장 먼저 실패한 것을 catch 메서드에서 처리할 수 있다.

 

- Promise.race 메서드

 

Promise.race 메서드는 경주(race)에서 승리한 작업, 즉 가장 먼저 종료한 Promise 객체의 결과만 다음 작업으로 보내는 처리 방법이다. 즉, 먼저 종료한 작업이 성공했을 때는 성공 콜백을 호출하고 실패했을 때는 실패 콜백을 호출한다. 나머지 Promise 객체도 실행되기는 하지만 반환하는 것은 먼저 종료한 작업의 결괏값이다.

 

사용법

Promise.race(iterable);

예제

위 예제에서 메서드만 race로 변경하고 실행해보았다.

Promise.race([
    buySomething("탱구링", 600),
    buySomething("아저씨", 1000),
    buySomething("피카츄", 300)
])
    .then((balance) =>{
        console.log('balance:', balance);
    })
    .catch((error) => {
        console.log(error);
    });

탱구링은 500원을 지불, 아저씨는 600원을 지불, 피카츄는 200원을 지불하였다.

결과

탱구링님 500원을 지불하였습니다.
balance: 100
아저씨님 600원을 지불하였습니다.
피카츄님 200원을 지불하였습니다.

 먼저 종료한 작업인 탱구링의 Promise 객체가 종료되고 성공 콜백으로 balance : 100을 호출한 것을 알 수 있다. 하지만 나머지 작업도 실행되었지만 then 메서드를 호출하지 않는다.

실패했을 때도 마찬가지이다. 

잔액이 부족합니다. 탱구링님의 잔액은 600원 입니다.
피카츄님 200원을 지불하였습니다.

 탱구링의 결제가 실패하여 먼저 종료되었기 때문에 실패 콜백을 호출하였다. 아저씨의 경우 1100원을 지불하였기 때문에 실행은 되었고 else문에 걸려 reject 실패 콜백을 호출해야 하지만 이미 먼저 종료한 탱구링의 reject 함수만 호출하기 때문에 관련 로그가 뜨지 않는다. 피카츄는 200원을 지불하여 지불 자체는 실행되었지만 마찬가지로 then 함수는 호출되지 않는다.

지금까지 자바스크립트 비동기 처리와 AJAX 그리고 Promise에 대해 알아보았다. 다음 포스팅에서는 Promise에 대한 문제점으로 인해 등장한 또 다른 처리 방식인 async&await 및 비동기 처리 관련 라이브러리에 대해 살펴보도록 하겠다.

 

이 글을 공유합시다

facebook twitter kakaoTalk kakaostory naver band
loading