본문 바로가기

JavaScript

JavaScript 비동기(asynchronous)

 

 

  • 학습 내용

이번 시간은 자바스크립트의 큰 강점이라고 할 수 있는 비동기(aynchronous)에 대하서 학습하고자 한다. 비동기를 이해하기 위해선 먼저 동기적(synchronous) 작업을 이해해야 하는데, 동기적 작업은 클라이언트가 특정 요청을 보내서 서버가 요청을 받아서 일처리를 위해 데이터를 꺼내고 가공할 때, 그 과정 중에 클라이언트는 아무 작업도 하지 않고 멈추게 된다. 그리고 서버에서 응답이 오면 그때 하려던 일을 처리하는 식으로 작업을 진행하는 방식을 말한다. 즉, 순차적으로 작업을 진행하는 것이다. 이와 달리 비동기 작업은 클라이언트에서 요청을 하면 서버에서 데이터를 꺼내고 가공하여 반응을 줄 준비를 한다. 그러나 이때 동기적 작업과 달리 클라이언트가 쉬지 않는다. 계속해서 작업을 하고 응답이 돌아오면 작업을 마무리한다. 동기적 작업은 하나의 작업 중 다른 작업들을 blocking 하지만 비동기식 작업은 non-blocking 하는 것이다. 

 비동기식 작업은 이런 점들로 인해 여러 작업을 동시에 처리할 수 있다. 그래서 여러 작업을 처리해야 하는 경우에는 순차적으로 처리하는 동기식 작업에 비해 비동기식 작업이 훨씬 효율적이라고 할 수 있다. 비동기식 작업의 예를 들면 요즘 많이 사용하는 넷플릭스나 유튜브를 시청할 때 하나의 영상을 실행하면서도 동시에 다른 영상을 서치 할 수 있는 이런 작업의 결과가 비동기로 인해 탄생된 결과물이다.

 

  • 동기식(synchronous)과 비동기식(aynchronous)

동기식 작업은 위에서도 언급한 대로 작업을 순차적으로 진행하는 방식이다. 비동기를 고려하지 않고 코드를 작성하면 대부분 동기식으로 작업을 처리한다고 보면 된다.

function waitSync(ms) {
  let start = Date.now();
  let now = start;
  while(now - start < ms) {
    now = Date.now();
  }
}

function drink(person, coffee) {
  console.log(person + '는 ' + coffee + '를 마십니다.');
}

function orderCoffeeSync(coffee) {
  console.log(coffee + '가 접수되었습니다.');
  waitSync(2000);
  return coffee;
}

let customers = [{
  name: 'Steve',
  request: '카페라떼'
}, {
  name: 'John',
  request: '아메리카노'
}];

customers.forEach(function(customer) {
  let coffee = orderCoffeeSync(customer.request);
  drink(customer.name, coffee);
});

이와 달리 비동기식의 경우 여러 방법들을 통해 구현하여 클라이언트가 서버에서의 응답을 기다리는 동안에도 멈추지 않고 작업을 계속하게 한다. 가장 기본적이고 대표적인 구현 방법으로 callback함수를 이용하는 방법이다. 

function waitAsync(callback, ms) {
  setTimeout(callback, ms); //브라우저의 타임API를 사용한다
}

function drink(person, coffee) {
  console.log(person + '는 ' + coffee + '를 마십니다.');
}

function orderCoffeeAsync(mene, callback) {
  console.log(menu + '가 접수되었습니다.');
  waitAsync(function() {
    callback(menu);
  }, 2000);
}

let customers = [{
  name: 'Steve',
  request: '카페라떼'
}, {
  name: 'John',
  request: '아메리카노'
}];

customers.forEach(function(customer) {
  let coffee = orderCoffeeAsync(customer.request, function(coffee) {
    drink(customer.name, coffee)
  });
});

 

  • callback

callback이란 고차 함수의 하나로 함수의 파라미터로 전달되는 또 다른 함수를 의미한다. 위에서도 언급했듯 이 콜백 함수는 비동기식 작업에 많이 사용된다.

const printString = (string) => {
  setTimeout(
    () => {
      console.log(string)
    },
    Math.floor(Math.random() * 100) + 1
  )
}

const printAll = () => {
  printString('a')
  printString('b')
  printString('c')
}

printAll(); // 결과로 'a','b','c' 순서대로 나오지 않는다. 순차적으로 a,b,c를 제어할 수 없다.
const printString = (string, callback) => {
  setTimeout(
    () => {
      console.log(string)
      callback()
    },
    Math.floor(Math.random() * 100) + 1
  )
}

// 순차적으로 실행하는 것이 아니라 printString(a)를 실행하면서 콜백을 받아 콜백 안에서 printString(b) 또 그 안에 콜백으로 printString(c)를 실행함
const printAll = () => { 
  printString('a', () => {
    printString('b', () => {
      printString('c', () => {}_
    })
  })
}
printAll() // 순차적으로 a,b,c가 실행됨

callback을 함수 내부에서 디자인할 수도 있다.

const somethingGonnaHappen = callback => {
  waitingUnitSomethingHappens()
  
  if (isSomethingGood) {
    callback(null, something)
  }
  
  if (isSomethingBad) {
    callback(something, null)
  }
}

somethingGonnaHappen((err, data) => {
  if (err) {
    console.log('Err');
    return;
  }
  return data;
})

위의 예시에서 잠깐 드러났듯이 비동기 작업을 통해 에러를 핸들링할 수 있다. 작업이 순차적으로 진행되는 것이 아니라 동시에 진행되기 때문에 만약 진행 중에 작업에 에러가 난다면 에러를 핸들링하는 것이다. 

function loadScript(src, callback) {
  let script = document.createElement('script');
  script.src = src;
  
  script.onload = () => callback(null, script);
  script.onerror = () => callback(new Error(`${src}를 플러오는 도중에 에러가 발생했습니다.`));
  
  document.head.append(script);
}
loadScript('/my/script.js', function(error, script) {
  if (error) {
    console.log('에러가 발생했습니다.')
  } else {
    return script
  }
});

콜백을 이용하여 비동기 방식을 작성하는 것에도 단점이 있는데 콜백이 길어지고 많아지면 callback hell이라는 것에 빠질 수도 있다는 것이다. 이런 경우 가독성이 매우 떨어지기 때문에 지양하는 것이 좋다. 이를 위한 테크닉이 promise이다.

 

  • promise

promise는 클래스이다. 클래스 키워드를 활용해 작성한다. 프로미스는 기본적으로 객체로 전달하는데, 전달되는 함수를 실행자(executor)라고 부른다. 이 실행자는 new Promise가 만들어질 때 자동으로 실행되는데, 결과를 최종적으로 만들어낸다. 실행자의 인수는 resolve와 reject가 있는데, 자바스크립트에서 자체적으로 제공하는 콜백이다. 

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

실행자는 결과를 즉시 얻든 늦게 얻는 상관없이 상황에 따라 인수로 넘겨준 콜백 중 하나를 반드시 호출해야 한다. 그때 resolve가 호출되면 성공적으로 실행된 결과를 호출하게 되는 것이고, reject가 호출되면 error가 발생했고, 그 에러의 객체를 호출한다. 그래서 new Promise 생성자가 return 하는 객체는 처음에 'pending'(보류) 상태이다가 resolve가 호출되면 'fullfilled'상태가 되고 reject가 호출되면 'rejected'상태가 되는 것이다.

const printString = (string) => {
  return new Promise((resolve, reject) => {
    setTimeout(
      () => {
        console.log(string)
        resolve()
      },
      Math.floor(Math.random() * 1000) + 1
    )
  }
}

const printAll = () => {
  printString('a')
  .then(() => {
    return printString('b')
  })
  .then(() => {
    return printString('c')
  })
}

printAll();

callback hell의 해결을 위해 promise를 많이 사용하는데 이 promise도 promise hell에 빠질 수 있다.

function gotoHome() {
  return new Promise((resolve, reject) => {
    setTimeout(() => { resolve('1. go to home') }, 100)
  })
}
function sitAndStudy() {
  return new Promise((resolve, reject) => {
    setTimeout(() => { resolve('2. sit and study') }, 100)
  })
}
function eatDiner() {
  return new Promise((resolve, reject) => {
    setTimeout(() => { resolve('3. eat diner') }, 100)
  })
}
function goToBed() {
  return new Promise((resolve, reject) => {
    setTimeout(() => { resolve('4. goToBed') }. 100)
  })
}

gotoHome()
.then(data => {
  console.log(data)
  sitAndStudy()
  .then(data => {
    console.log(data)
    eatDiner()
    .then(data => {
      console.log(data)
      goToBed()
      .then(data => {
        console.log(data)
      })
    })
  })
})

 

이를 극복하는 방법 역시 존재하는데, then안에서 return 함으로 이어가면 가능하다.

function gotoHome() {
  return new Promise((resolve, reject) => {
    setTimeout(() => { resolve('1. go to home') }, 100)
  })
}
function sitAndStudy() {
  return new Promise((resolve, reject) => {
    setTimeout(() => { resolve('2. sit and study') }, 100)
  })
}
function eatDiner() {
  return new Promise((resolve, reject) => {
    setTimeout(() => { resolve('3. eat diner') }, 100)
  })
}
function goToBed() {
  return new Promise((resolve, reject) => {
    setTimeout(() => { resolve('4. goToBed') }. 100)
  })
}

gotoHome()
.then(data => {
  console.log(data)
  return sitAndStudy()
})
.then(data => {
  console.log(data)
  return eatDiner()
})
.then(data => {
  console.log(data)
  return goToBed()
})
.then(data => {
  console.log(data)
})

 

  • 자주 사용하는 Promise 메소드

1) then

then의 경우 위에서 사용한 대로 promise의 실행 결과와 에러를 받는다.

promise.then(
  function(result) // 결과(result)를 다룸
  function(error) // 에러(error)를 다룸
);

이 경우 첫 번째 함수인 function(result)를 실행하고 거부되면 두 번째 함수인 function(error)를 실행한다. 성공적으로 처리된 경우만 다루고 싶으면 인수를 하나만 전달하면 된다.

2) catch

에러가 발생한 경우만 다루고 할 때 .then(null, 에러핸들링함수)을 사용해 null을 첫 번째 인수로 전달하면 된다. 이와 함께 catch 역시 같은 역할을 할 수 있다. .catch(해러핸들링함수)를 사용하면 동일한 역할이 가능하다.

let promise = new Promise((resolve, reject) => {
  setTimeout(() => reject(new Error("에러")), 1000);
});

promise.catch(console.log)

 

3) finally

. then(func, func)을 사용하면 프라미스가 처리되면 func이 항상 실행된다. 이와 같이 .finally(func) 역시 같은 역할을 한다. 결과가 어떻든 마무리가 필요할 때 이것을 사용하는 것이다.

new Promise((resolve, reject) => {
  setTimeout(() => resolve("결과"), 1000)
})
.finally(() => console.log("프로미스가 준비되었습니다."))

물론 finally의 경우 인수가 없는 것과 프로미스가 이행되었는지 거부되었는지 알 수 없다는 것이다. .then(func, func)과는 차이가 있다. 그리고 finally는 프로미스 결과를 처리하는 것이 아닌 중간 과정에 가깝다. 프로미스 결과가 finally를 통과해서 전달되는 것이다. 

 

  • async/awit

promise와 함께 async await 역시 같은 역할을 한다. promise와 같지만 형태만 다른 것이다. 비동기를 동기식 함수인 것처럼 표현할 수 있다는 장점이 있다. 일반 함수처럼 순차적으로 표현할 수 있다.

function gotoHome() {
  return new Promise((resolve, reject) => {
    setTimeout(() => { resolve('1. go to home') }, 100)
  })
}
function sitAndStudy() {
  return new Promise((resolve, reject) => {
    setTimeout(() => { resolve('2. sit and study') }, 100)
  })
}
function eatDiner() {
  return new Promise((resolve, reject) => {
    setTimeout(() => { resolve('3. eat diner') }, 100)
  })
}
function goToBed() {
  return new Promise((resolve, reject) => {
    setTimeout(() => { resolve('4. goToBed') }. 100)
  })
}

const result = async () => {
  const one = await gotoHome();
  console.log(one)

  const two = await sitAndStudy();
  console.log(two)

  const three = await eatDiner();
  console.log(three)

  const four = await goToBed();
  console.log(four)
}

result();

 

  • 타이머 관련 API

함수 실행 순서를 제어하기 위해 즉, 비동기를 사용하기 위해 각 함수의 실행 시간을 설정해야 한다. 그때 자주 사용하는 API는 이렇다.

1) setTimeout(callback, millisecond)

일정 시간 후에 함수를 실행한다.

setTimeout(function() {
  console.log('1초 후 실행');
}, 1000);

2) setInterval(callback, millisecond)

일정 시간 간격을 가지고 함수를 반복적으로 실행한다.

setInterval(function() {
  console.log('1초 마다 실행');
}, 1000);

3) clearInterval(timerId)

반복 실행 중인 타이머를 종료한다.

const timer = setInterval(function() {
  console.log('1초마다 실행');
}, 1000);

clearInterval(timer);
// 더 이상 반복 실행되지 않음

clearTimeout 역시 setTimeout에 대해 clearInterval과 같은 역할을 한다.

 

  • 비동기 관련 라이브러리 및 개념

1) window.requestAnimationFrame

자주 쓰이는 비동기 함수 중 하나이다. CSS의 transition으로 처리하기 어려운 애니메이션이나, HTML5의 Canvas, SVG 등의 애니메이션 구현을 위해 사용하는 함수이다. window.requestAnimationFrame은 모든 애니메이션을 직접 프레임 단위로 계산해야 하기 때문에 사용하기 까다롭다는 단점이 있다. window.setTimeout 함수와의 차이는 브라우저가 실행 시기를 결정한다는 것에 있다. 그리고 window.setInterval과 다르게 스스로 반복해서 호출하지 않기 때문에 다음 함수를 반복하기 위해 재귀적으로 사용해야 만한다. 이 함수는 기본적으로 모니터 주사율에 맞춰 함수를 실행하는데, 예를 들어 60FPS이면 1초에 60번, 140FPS이면 1초에 140번 함수를 실행하는 것이다. 

2) AJAX

AJAX란 Asynchronous JavaScript and XML의 약자이다. 빠르게 동작하는 동적인 웹 페이지를 만들기 위한 개발 기법의 하나로 기능을 사용하여 웹 페이지 전체를 다시 로딩하지 않고도 웹 페이지의 일부분만을 갱신할 수 있다. 백그라운드 영역에서 서버와 통신하여 그 결과를 웹 페이지의 일부분에만 표시할 수 있다. 그래서 웹 페이지가 로드된 후에 서버로 데이터를 요청하거나 서버로부터 데이터를 받을 수 있다. 좀 더 상세한 내용은 다음 시간에 알아보도록 하겠다.

 

  • 느낀 점

비동기가 자바스크립트의 아주 핵심적인 기술이라는 말을 들었다. 개념이 아주 어렵다는 느낌보다는 익숙하지 않다는 느낌이다. 이런 경우 보통 반복학습을 통해 적응하는 것이 좋다고 느낀다. 특히 promise나 async await는 문법적인 요소에 가깝기 때문에 많이 써보고 익숙해지는 게 더욱 중요하다 생각된다. 다음 시간에 정리할 네트워크까지 들어가면 서버의 데이터를 불러오는 것에도 많이 사용될 것이다.