WeniVooks

검색

JavaScript 에센셜

동기와 비동기

1. 동기 VS 비동기

1.1. 일상에서 마주치는 동기와 비동기

마트에서 물건을 구입할 때 계산대에서 손님들이 물건을 계산하러 줄을 섭니다. 첫 손님이 지갑을 찾느라고 잠시 계산을 못하는 상황이 발생했다고 가정해보겠습니다. 동기와 비동기는 다음과 같이 나타낼 수 있습니다.

  1. 동기적 처리: 점원이 손님이 지갑을 찾고 돈을 꺼낼 동안 대기
  2. 비동기적 처리: 첫 손님이 지갑을 찾는 동안 기다리면서 동시에 비어있는 옆 계산대로 이동해 다음 손님의 계산을 먼저 처리

이처럼 동기적 처리는 순차적으로 처리되는 반면, 비동기적 처리는 동시에 처리됩니다. 다음으로는 코드로 동기와 비동기가 어떻게 다른지 살펴보겠습니다.

1.2. 코드로 살펴보는 동기와 비동기

자바스크립트 코드는 기본적으로 동기적으로 실행됩니다. 다음 코드를 실행하면 1부터 6까지 차례대로 콘솔에 출력됩니다.

console.log(1); console.log(2); [3, 4, 5].forEach((i) => console.log(i)); console.log(6);

하지만 아래 코드를 실행하면 1, 3, 4, 5, 6 이 출력되고 시간이 흘러 2가 콘솔에 출력됩니다. setTimeout을 이용하면 일정 시간이 지난 후 콜백함수가 실행되도록 코드를 작성할 수 있습니다. 따라서 코드가 비동기적으로 실행됩니다. setInterval, addEventListener와 같은 함수들도 비동기적으로 실행됩니다.

console.log(1); setTimeout(() => console.log(2), 100); // 위니북스에서는 출력되지 않습니다. [3, 4, 5].forEach((i) => console.log(i)); console.log(6);

위니북스에서는 setTimeout 내부에서 작성된 console은 출력되지 않으므로 주의해주세요.

1.3. 비동기 처리의 중요성

AJAX(Asynchronous JavaScript and XML) 의 등장으로 비동기 처리 방법이 매우 중요해졌습니다. AJAX는 기본적으로 서버와 비동기로 통신을 처리하기 때문에 기존의 동기식 코드와 함께 작성했을 때 코드의 실행 순서에 문제가 발생합니다. 자바스크립트 엔진은 비동기 코드가 끝날 때까지 다른 코드의 실행을 멈추지 않기 때문입니다.

다음 의사코드를 살펴보겠습니다. 비동기통신함수1비동기통신함수2가 있을 때, 이 두 함수의 결과를 더한 값을 출력하기 위해 다음과 같이 코드를 작성해도 우리는 원하는 결과를 얻을 수 없습니다. 그 이유는 비동기 함수의 결과를 기다리지 않고 바로 console을 실행하기 때문입니다.

const result1 = 비동기통신함수1();
const result2 = 비동기통신함수2();
 
console.log(result1 + result2);
const result1 = 비동기통신함수1();
const result2 = 비동기통신함수2();
 
console.log(result1 + result2);

앞서 작성한 XHR 코드에서 통신 함수 외부에 변수를 선언하고, 통신 함수가 끝난 후 변수에 값이 출력되는지 확인해봅시다.

let result;
function xhrRequest() {
  const requestObj = new XMLHttpRequest();
  requestObj.open('GET', 'https://test.api.weniv.co.kr/mall');
  requestObj.onreadystatechange = () => {
    if (requestObj.readyState === 4 && requestObj.status === 200) {
      result = requestObj.responseText;
      console.log(result); // 콘솔에 무엇이 찍히는지 확인해봅시다.
    }
  };
  requestObj.send();
}
 
xhrRequest();
console.log(result); // 콘솔에 무엇이 찍히는지 확인해봅시다.
let result;
function xhrRequest() {
  const requestObj = new XMLHttpRequest();
  requestObj.open('GET', 'https://test.api.weniv.co.kr/mall');
  requestObj.onreadystatechange = () => {
    if (requestObj.readyState === 4 && requestObj.status === 200) {
      result = requestObj.responseText;
      console.log(result); // 콘솔에 무엇이 찍히는지 확인해봅시다.
    }
  };
  requestObj.send();
}
 
xhrRequest();
console.log(result); // 콘솔에 무엇이 찍히는지 확인해봅시다.

이처럼 비동기 코드는 동기 코드와 다르게 코드의 실행 순서가 보장되지 않아 결과를 예측하기 어렵습니다. 이러한 문제를 해결하기 위해 여러가지 비동기 처리 방법이 등장했습니다.

2. 비동기 처리

2.1. 콜백 함수 (callback)

통신이 끝난 후 실행할 함수를 콜백함수로 전달하는 방식입니다. 콜백함수는 함수의 인자로 전달되어 특정 시점에 실행되는 함수를 나타냅니다. 일반적인 동기식 코드처럼 비동기 함수 실행 후 다음 라인에서 다른 함수를 실행하는, 순차적으로 함수들을 나열하는 방식이 불가능합니다. 따라서 비동기 코드가 끝나고 콜백으로 함수를 부르고, 다음 함수를 또 콜백으로 부르는 형태가 되야합니다.

function asyncFunc(input, callback) {
  setTimeout(() => {
    const result = input + 10;
    callback(result);
  }, 1000);
}
asyncFunc(5, (result) => {
  console.log(result);
});
function asyncFunc(input, callback) {
  setTimeout(() => {
    const result = input + 10;
    callback(result);
  }, 1000);
}
asyncFunc(5, (result) => {
  console.log(result);
});

이처럼 콜백 함수를 이용하여 비동기 처리가 완료된 후 실행할 함수를 지정할 수 있습니다. 하지만 콜백 함수를 중첩하여 사용하면 콜백 지옥(callback hell) 이 발생할 수 있습니다. 콜백 지옥은 콜백 함수가 중첩되는 것으로, 코드의 가독성이 떨어지고 복잡성이 증가해서 오류 처리가 어려워지는 문제가 발생합니다.

function asyncFunc(input, callback) {
  setTimeout(() => {
    const result = input + 10;
    callback(result);
  }, 100);
}
function asyncFunc2(input, callback) {
  setTimeout(() => {
    const result = input + 10;
    callback(result);
  }, 100);
}
function asyncFunc3(input, callback) {
  setTimeout(() => {
    const result = input + 10;
    callback(result);
  }, 100);
}
 
const input = 5;
asyncFunc(input, (result) => {
  console.log('asyncFunc', result);
  asyncFunc2(result, (result) => {
    console.log('asyncFunc2', result);
    asyncFunc3(result, (result) => {
      console.log('asyncFunc3', result);
    });
  });
});
function asyncFunc(input, callback) {
  setTimeout(() => {
    const result = input + 10;
    callback(result);
  }, 100);
}
function asyncFunc2(input, callback) {
  setTimeout(() => {
    const result = input + 10;
    callback(result);
  }, 100);
}
function asyncFunc3(input, callback) {
  setTimeout(() => {
    const result = input + 10;
    callback(result);
  }, 100);
}
 
const input = 5;
asyncFunc(input, (result) => {
  console.log('asyncFunc', result);
  asyncFunc2(result, (result) => {
    console.log('asyncFunc2', result);
    asyncFunc3(result, (result) => {
      console.log('asyncFunc3', result);
    });
  });
});

실제 예시를 위해 로그인을 하는 과정을 콜백함수로 작성해보겠습니다.

function authUser(id, pw, callback) {
  setTimeout(() => {
    if (id === 'weniv' && pw === '1234') {
      callback(null, id);
    } else {
      callback(new Error('인증 실패'), null);
    }
  }, 100);
}
 
function getUserProfile(userId, callback) {
  setTimeout(() => {
    if (userId === 'weniv') {
      const profile = {
        userId: userId,
        name: 'weniv',
      };
      callback(null, profile);
    } else {
      callback(new Error('프로필 조회 실패'), null);
    }
  }, 100);
}
 
function login(id, pw) {
  authUser(id, pw, (authErr, userId) => {
    if (authErr) {
      console.error(authErr);
      return;
    }
 
    getUserProfile(userId, (profileErr, profile) => {
      if (profileErr) {
        console.error(profileErr);
        return;
      }
      console.log('로그인 성공');
    });
  });
}
 
login('licat', '0000');
login('weniv', '1234');
function authUser(id, pw, callback) {
  setTimeout(() => {
    if (id === 'weniv' && pw === '1234') {
      callback(null, id);
    } else {
      callback(new Error('인증 실패'), null);
    }
  }, 100);
}
 
function getUserProfile(userId, callback) {
  setTimeout(() => {
    if (userId === 'weniv') {
      const profile = {
        userId: userId,
        name: 'weniv',
      };
      callback(null, profile);
    } else {
      callback(new Error('프로필 조회 실패'), null);
    }
  }, 100);
}
 
function login(id, pw) {
  authUser(id, pw, (authErr, userId) => {
    if (authErr) {
      console.error(authErr);
      return;
    }
 
    getUserProfile(userId, (profileErr, profile) => {
      if (profileErr) {
        console.error(profileErr);
        return;
      }
      console.log('로그인 성공');
    });
  });
}
 
login('licat', '0000');
login('weniv', '1234');
2.2. Promise

Promise는 이름에서 알 수 있듯이 약속을 하는 것입니다. 그렇다면 무엇을 약속할까요?

우리가 스타벅스에서 커피를 주문하는 상황을 생각해봅시다.

  1. 스타벅스에 가서 커피를 주문할 때, 카운터에서 잠시 고민을 하겠죠? (pending : 보류중인, 대기중인)
  2. 주문을 받은 점원은 우리에게 “대략 5 ~ 10분 정도 커피를 내리는 시간이 필요합니다. 완료되면 알려드리겠습니다._” 라고 얘기할겁니다. 우리에게 약속(promise) 하는 것입니다. 이때 약속은 우리에게 커피를 주거나 또는 못 주거나에 대한 결과를 알려준다는 것 입니다.
  3. 우리는 커피가 만들어지는 동안(Asynchronously) 공부하기 적당한 테이블을 찾고, 노트북을 꺼내고, 주문이 완료되기를 기다립니다.

4.1. 잠시 뒤 커피가 만들어져 나옵니다! 즉, 우리의 주문이 해결되었고(resolved), 약속이 이루어졌습니다. (fulfilled : 약속을 이행, 완수)

4.2. 그런데 어쩌면 이런 상황이 발생할 수도 있습니다. 점원이 급하게 와서 사용할 원두가 떨어졌다고 알려줄 수도 있겠죠. 그렇다면 약속이 이뤄지지 않은 즉, **거절된 상태(rejected)**가 됩니다.

이처럼 Promise를 이용하여 비동기 연산을 다룰 수 있습니다. 비동기 작업의 결과는 즉시 알 수 없지만 '결과가 준비되면 이행되거나 거절될 것이라고 약속을 하는 것'입니다. 위의 예시를 Promise의 세 가지 상태로 다시 나타내면 다음과 같습니다.

  • 대기 (Pending) -> resolve() -> 성공 (Fulfilled)
  • 대기 (Pending) -> reject() -> 실패 (Rejected)

위의 예시에 나온 키워드들을 잘 생각해보면서 실제 코드로 작성해 보겠습니다.

// 커피를 주문하는 프로미스 객체를 생성합니다. 생성자에는 약속을 지키기 위한 resolve와, 약속을 지키지 못했을 때를 대비한 reject 두 가지를 인자로 전달합니다.
// 프로미스 객체를 생성하는 순간 프로미스 생성자함수의 콜백 함수가 실행됩니다. 이를 실행자(executor)라 부릅니다.
const orderCoffee = new Promise((resolve, reject) => {
  const requestObj = new XMLHttpRequest();
  requestObj.open('GET', 'http://test.api.weniv.co.kr/');
  requestObj.onreadystatechange = () => {
    if (requestObj.readyState === 4) {
      if (requestObj.status === 200) {
        const result = requestObj.responseText;
        // resolve 메소드가 실행되면 then 메소드가 자동으로 호출됩니다.
        resolve(result);
      } else {
        // resolve 메소드 호출이 없는 상태에서 reject 메소드가 실행되면 catch 메소드가 자동으로 호출됩니다.
        reject(
          new Error(
            `커피주문이 정상적으로 이뤄지지 않았습니다.: ${requestObj.status}`,
          ),
        );
      }
    }
  };
  requestObj.send();
});
 
// 이 부분에 주목해주세요. then 메소드를 사용하면 비동기 코드를 마치 동기적인 코드처럼 작성할 수 있습니다. 앞에서 작성한 XHR 코드와 비교해보는것도 좋습니다.
// resolve 메소드가 실행될때 전달된 인자는 then 메소드의 콜백함수의 인자로 전달됩니다.
orderCoffee
  .then((asyncResult) => {
    console.log(asyncResult);
    console.log('약속이 이루어졌습니다.');
    return asyncResult;
  })
  .catch((error) => {
    // then 메소드는 프라미스 객체를 반환하기 때문에 catch 메소드를 이어서 쓰는것이 가능합니다.
    // resolve 메소드와 마찬가지로 reject 메소드가 실행될때 전달된 인자는 catch 메소드의 콜백함수의 인자로 전달됩니다.
    console.log(error);
  });
// 커피를 주문하는 프로미스 객체를 생성합니다. 생성자에는 약속을 지키기 위한 resolve와, 약속을 지키지 못했을 때를 대비한 reject 두 가지를 인자로 전달합니다.
// 프로미스 객체를 생성하는 순간 프로미스 생성자함수의 콜백 함수가 실행됩니다. 이를 실행자(executor)라 부릅니다.
const orderCoffee = new Promise((resolve, reject) => {
  const requestObj = new XMLHttpRequest();
  requestObj.open('GET', 'http://test.api.weniv.co.kr/');
  requestObj.onreadystatechange = () => {
    if (requestObj.readyState === 4) {
      if (requestObj.status === 200) {
        const result = requestObj.responseText;
        // resolve 메소드가 실행되면 then 메소드가 자동으로 호출됩니다.
        resolve(result);
      } else {
        // resolve 메소드 호출이 없는 상태에서 reject 메소드가 실행되면 catch 메소드가 자동으로 호출됩니다.
        reject(
          new Error(
            `커피주문이 정상적으로 이뤄지지 않았습니다.: ${requestObj.status}`,
          ),
        );
      }
    }
  };
  requestObj.send();
});
 
// 이 부분에 주목해주세요. then 메소드를 사용하면 비동기 코드를 마치 동기적인 코드처럼 작성할 수 있습니다. 앞에서 작성한 XHR 코드와 비교해보는것도 좋습니다.
// resolve 메소드가 실행될때 전달된 인자는 then 메소드의 콜백함수의 인자로 전달됩니다.
orderCoffee
  .then((asyncResult) => {
    console.log(asyncResult);
    console.log('약속이 이루어졌습니다.');
    return asyncResult;
  })
  .catch((error) => {
    // then 메소드는 프라미스 객체를 반환하기 때문에 catch 메소드를 이어서 쓰는것이 가능합니다.
    // resolve 메소드와 마찬가지로 reject 메소드가 실행될때 전달된 인자는 catch 메소드의 콜백함수의 인자로 전달됩니다.
    console.log(error);
  });

정리해보면, 프로미스는 비동기 코드를 마치 동기적인 코드처럼 작성할 수 있습니다. 우리는 약속을 의미하는 Promise 객체를 만들었고, 이 약속은 이행되거나(fulfilled), 거절되거나(reject) 둘 중에 한 가지 결과만을 가지게 될겁니다. (물론 두 가지 모두 이뤄지지 않는다면 계속 pending(대기중) 상태가 되겠지만, 실제로 이렇게 코드를 작성할 일은 없겠죠?)

때문에 통신의 결과는 코드상에서는 아직 알 수 없지만, ‘이행되거나 거절되거나 둘 중의 하나의 결과는 전달될거라 약속하고 작업을 진행하자!’ 라는 개념으로 만들어졌기 때문에 then과 catch를 이용해 동기적으로 코드를 이어 쓸 수 있는것 입니다.

비동기(콜백함수, 프로미스, await/async, fetch) 알잘딱깔센 JavaScript 비동기 프로그래밍 - 비동기 너 내 동기가 돼라
12.1 AJAX12.3 fetch API