자바스크립에서 동기와 비동기

JHyeon·2020년 12월 12일
8

JavaScript

목록 보기
4/4
post-thumbnail

동기, 비동기

동기Syncronous와 비동기Asynchronous는 프로그래밍 언어에서 중요한 개념이다.

  • 동기는 요청 후 응답을 받아야 다음 동작을 실행하는 방식을 말하며
  • 비동기는 요청을 보낸 후 응답과 관계없이 다음 동작을 실행하는 방식이다.

자바스크립트에서 동기와 비동기

자바스크립트는 단일 스레드 프로그래밍 언어로 단일 호출 스택이 있어 한 번에 하나의 일을 처리할 수 있다. 그러므로 자바스크립트는 동기 방식으로 진행이 된다. 하나의 호출 스택만 있기 때문에 하나의 함수를 처리하는데 매우 오랜 시간이 걸린다면 다음 실행해야할 함수에 지장을 줄 수 있다는 문제가 발생한다.

예를 들어 웹 페이지를 사용자에게 보여줄 때 해당 웹 페이지에 있는 모든 데이터(사진, 글 등)를 받고 나서야 화면이 보여진다고 하자. 서버에서 데이터를 모두 받아올 때까지 시간이 오래 걸릴 수 있으며 사용자 입장에서 웹 페이지를 보는데 너무 느려서 답답할 수 있다. 그러나 데이터를 받아오는 일을 하는 도중에 우선 웹 페이지의 기본 레이아웃을 보여주고 보여줄 수 있는 것들을 우선 보여주는 것이 더 바람직할 것이다. 마치 세탁기가 돌아가는 도중에 라면을 끓이는 것처럼!

이것이 비동기의 필요성이다. 그리고 아래와 같은 방법으로 이 문제를 해결할 수 있다.

  • 비동기적 callback 함수 사용
  • ES6 Promise
  • ES8 async await

이전의 비동기 구현 패턴이 가졌던 단점을 극복하기 위해 새로운 문법이 등장한 순서대로 나열한 것이다. 단일 호출 스택을 가진 자바스크립트에서 비동기를 구현할 수 있게 한 위 세가지 방법을 차례대로 알아보자.

call back

콜백 함수란 다른 함수의 인자로 이용되는 함수이며 어떤 이벤트에 의해 호출되는 함수이다. 콜백 함수는 아래 코드와 같이 동기적으로 사용될 수도 있다. primtImmediately 라는 이벤트 함수가 인자로 함수를 받는 코드라는 점을 주목하자.

function printImmediately(callBackFunction) { 
  callBackFunction() 
}
printImmediately(()=>console.log('synchronous callback'))

단순히 인자로서 함수를 받아 그 함수를 실행한다. 일반적인 자바스크립트 코드이기에 당연히 함수 호출 스택에 따라 동기적으로 실행된다. 우리가 구현하고자 하는 비동기적 과정은 여기서 일어나지 않는다. 우리는 비동기적 callback 함수를 사용해야 한다.

아래와 같이 비동기적인 콜백 함수Asynchronous callback 예제를 보자.

function printWithDelay(callback, sec){
  setTimeout(callback, sec*1000)
}
printWithDelay(()=>console.log("async callback"), 2)
console.log("hello")

동기적인 방식을 따른다면 printWithDelay() 가 모두 완료한 뒤 "hello"를 출력해야 한다. 하지만setTimeout 을 사용해 2초 뒤에 "async callback"이 비동기적으로 출력된다. 따라서 "hello"가 먼저 출력이 되고 2초 뒤에 "async callback"이 출력이 된다.

다른 예시로 사용자가 어떤 버튼을 클릭했을 때 실행할 함수도 비동기 콜백 함수이다. 그 이전까진 실행하지 않다가 클릭이라는 이벤트가 발생했을 때 콜백 함수를 실행하는 것이다.

어떻게 비동기가 작동하나

앞서 자바스크립트는 하나의 호출스택을 가진 단일 스레드 프로그래밍 언어라고 말했다. 따라서 혼자서 비동기를 구현할 수 없다. 위 코드에서 어떻게 비동기가 구현된 것일까?

자바스크립트 엔진만으로는 비동기적으로 구현할 수 없으므로 자바스크립트 실행 환경(Runtime)은 브라우저에서 제공하는 Web API를 사용하여 비동기를 구현하게 된다. DOM 이벤트, setTimeout과 같은 비동기 함수는 web API를 호출하여 콜백 함수를 콜백 큐에 넣는다. 콜백 함수들이 담긴 큐는 특정 시점에서 콜백을 실행시키는 방식이다. 중요한 내용이지만 이 글의 주제와 벗어나는 내용이므로 더 자세한 사항에 대해서는 내가 참고한 블로그 글 링크를 남긴다.

출처 및 자세한 내용: https://new93helloworld.tistory.com/361

콜백 지옥

비동기 구현을 위한 첫 번째 방법인만큼 큰 문제가 있다. 콜백 함수가 콜백 함수를 부르고, 그 콜백 함수가 또 다른 콜백함수를 부르는 이른바 콜백 지옥이 발생하는 것이다.

많은 중첩함수가 생겨 가독성과 유지보수면에 끔찍한 코드가 발생한다. 이를 해결하기 위해 Promise가 나오게 되었다.

Promise

콜백 함수를 사용하지 않고 Promise object를 틍해 지옥에 빠지지 않고 어떻게 깔끔하게 비동기 함수를 처리할 수 있는지 바로 코드부터 확인해보자. 에러 처리에 관한 내용은 이 글에서 자세히 다루지 않는다.

const myPromise = new Promise((resolve, reject)=>{
  console.log("doning some heavy work: network, read files")
  setTimeout(()=>{
    // resolve('hi');
    reject(new Error('this is error msg'));
  }, 2000);
})

myPromise.then(value=>{
  console.log(value) // resolve 가 있다면 'hi' 출력 
})
.catch(error=>{
  console.log(error) // reject에 있는 'this is error msg' 출력
})
.finally(()=>{
  console.log('finally!!')
})

위 코드의 경우 2초뒤에 'this is error msg'와 'finally!!' 가 출력된다. 만약, resolve('hi') 가 주석처리 되지 않았다면 .then() 에서 'hi' 가 출력되었을 것이다.

콜백 함수의 중첩과 같이 여러 promise 객체를 만들어 중첩시킬 수도 있다. 아래 예시 코드를 보자. 포켓몬 파이리의 진화 과정을 1초마다 보여주는 코드이다.

const initialPokemon = () =>
  new Promise((resolve, reject)=>{
    setTimeout(()=>resolve('파이리'), 1000)
  });

const nextPokemon = prevPokemon =>
  new Promise((resolve, reject)=>{
    setTimeout(()=>resolve(`${prevPokemon} => 리자드`), 1000)
  });

const finalPokemon = prevPokemon =>
  new Promise((resolve, reject)=>{
    setTimeout(()=>resolve(`${prevPokemon} => 리자몽`), 1000)
  });

initialPokemon() // 1초 소요
  .then(prev=>{
    console.log(prev) // 파이리
    return nextPokemon(prev) // 1초 소요
  })
  .then(prev=>{
    console.log(prev) // 파이리 => 리자드
    return finalPokemon(prev) // 1초 소요
  })
  .then(console.log) // 파이리 => 리자드 => 리자몽

확실히 콜백 지옥보다 훨씬 로직이 깔끔하고 가독성도 뛰어나다는 것을 알 수 있다. 위 코드들을 사용하는 방법의 경우 promise chaining으로 then , catch 매소드를 사용하여 비동기를 관리하고 있다.

async await

asynce await 는 비동기처리의 최신문법이다. 기존의 promise와 다른 것은 아니고, syntatic sugar일 뿐이다. promise를 사용할 경우에 callback처럼 chaining이 일어나는 것은 마찬가지이다. 따라서 콜백 지옥의 문제가 어느정도 나타날 수 있다는 것이다.

하지만 async await을 사용하면 promise를 '깔끔한 스타일'로 작성할 수 있다. 그러나 무조건 async await이 절대적으로 깔끔한 방법은 아니고 상황에 따라 적절한 것을 선택하면 된다. async await를 사용한 예시 코드를 살펴보자.

const getTodo = async (id) => {
  const todoResponse = await fetch(`https://jsonplaceholder.typicode.com/todos/${id}`)
  const todo = await todoResponse.json()
  return todo;
}

getTodo(172).then(console.log)

fetch 함수에서 사용한 API는 무료 가짜 데이터 REST API를 제공하는 API 주소이다. 간단한게 데이터를 가져오는 과정을 async await 문법을 사용한 코드이다. async 키워드는 함수 앞에 붙이는 키워드이며 await 키워드는 async 키워드가 붙어 있는 함수 내부에서만 사용할 수 있다. await 라는 코드로 의도한 순서대로 코드의 흐름을 제어하고 있다. 일반적인 동기 코드 처리와 동일한 흐름으로 코드를 작성할 수 있기에 코드 읽기가 수월해진다.

한 가지 주의할 것은 async 함수를 호출할 때 명시적으로 promise 객체를 생성하여 리턴하지 않아도 promise 객체가 리턴된다. 따라서 호출하는 코드를 보면 then() 매서드를 사용하여 결과값을 출력하고 있다.

async await의 또다른 장점은 동기와 비동기 구분없이 try catch 로 일관된 예외 처리를 할 수 있다는 점이다!

const myFunction = async (postId) => {
  const postResponse = await fetch(
    `https://jsonplaceholder.typicode.com/posts/${postId}`
  )
  const post = await postResponse.json()
  const userId = post.userId

  try {
    const userResponse = await fetch(
      // 1번 주소: `https://jsonplaceholder.typico`
      // 2번 주소: `https://jsonplaceholder.typicode.com/users/${userId}`
    )
    const user = await userResponse.json()
    return user.name || "no data"
  } catch (err) {
    console.log(err)
    return "Unknown"
  }
}

myFunction(15).then(console.log)

위 코드에서 2번 주소가 정상적으로 작동하는 코드이다. postId 를 주면 해당 포스트의 userId 로부터 다시 user 의 이름을 얻어오는 코드이다. 2번 주소일 때, postId 혹은 userId 가 DB에 없을 경우 빈 객체가 반환되는데 그 때는 "no data" 를 반환하도록하였다. 데이터가 존재할 경우 user.name 을 정상적으로 반환한다.

그리고 1번 주소의 경우 에러 상황으로 err를 출력하고 (위 코드에서는 Error: 'Failed to fetch' 라는 에러 발생) "Unknown"을 반환하게 된다.

유용한 Promise APIs

//Promise.all
//Promise.race
const getTodo = async (id="") => {
  const todoResponse = await fetch(`https://jsonplaceholder.typicode.com/todos/${id}`)
  const todo = await todoResponse.json()
  return todo;
}

const allExample = async () => {
  const todo1 = getTodo(1)
  const todo2 = getTodo(2)
  return Promise.all([todo1, todo2])
}

const raceExample = async () => {
  const todo1 = getTodo(1)
  const todo2 = getTodo(2)
  return Promise.race([todo1, todo2])
}

// todo1과 todo2 모두 출력
allExample().then(console.log)
// todo1과 todo1 중 빨리 되는 것만 출력.
// 실제로 코드 실행 결과 1이 나올 때도 있고 2가 나올 때도 있었다.
for(let i=0; i<5; i++) raceExample().then(todo=>console.log(todo.id))

Promise.all 은 순회 가능한 객체에 주어진 모든 promise 객체를 이행한 후에 promise 객체를 반환한다. 만약 하나라도 거부하는 경우 첫 번째로 거절된 promise의 이유로 자신도 거부한다. Promise.race 는 순회 가능한 객체에 주어진 promise 들 중에 가장 먼저 완료된 것의 결과값을 그대로 이행하거나 거부한다.

const myFunction = async (postId) => {
  const postResponse = await fetch(
    `https://jsonplaceholder.typicode.com/posts/${postId}`
  )
  const post = await postResponse.json()
  const userId = post.userId

  try {
    const userResponse = await fetch(
      //`https://jsonplaceholder.typico`
      //`https://jsonplaceholder.typicode.com/users/${userId}`
    )
    const user = await userResponse.json()
    return user.name || "empty"
  } catch (err) {
    console.log(err)
    return "Unknown"
  }
}

myFunction(15).then(console.log)

참고자료

유튜브 드림코딩 by 엘리

유튜브 얄팍한 코딩사전

블로그 자바스크립트 비동기 처리

블로그 https://www.daleseo.com/js-async-async-await/

profile
The only thing that interferes with my learning is my education.

0개의 댓글