비동기 프로그래밍. 왜 하죠?

셔노·2022년 7월 15일
0

들어가기 전에

당신은 까페 바리스타입니다. 도와주는 사람도 없이 혼자서 주문을 받고 커피를 만들지만, 뭐 그러려니 합시다. 오늘도 문을 열고 손님을 맞이할 준비를 하네요. 언제나 조심스러운 성격인 당신은 한땀한땀 커피를 만들기로 명성이 자자합니다. 얼마나 정성스럽게 만드는지 커피나 디저트를 만들 때에는 다른 일을 하지도 못합니다. 아메리카노는 3분, 라떼는 4분, 허니브레드는 10분이 걸리는군요.

만드는 중간에 주문을 받는다던가 다른 일은 못합니다. 당신의 손은 두 개고, 입은 하나니깐요. 발로 커피를 만들 수는 없잖아요? :)

이제 첫 손님이 들어옵니다. "아메리카노 하나와 허니브레드 하나요!" 주문을 받고 아메리카노부터 만들기로 합니다. 1,2...3분! 이제 다 만들었으니 허니브레드를 만들어야죠. 두 번째 손님이 그새 들어옵니다. 한 번에 한 가지 일만 할 수 있으니 주문을 할 수 없어 기다리는군요. 총 13분이 지나고, 첫 번째 손님에게 주문을 전달합니다. 그런데 두 번째 손님의 주문이 더 가관이군요. "아메리카노 세 잔과 라떼 한 잔, 허니브레드 하나 주세요!" 그리고 그새 들어오는 세 번째 손님...

당신은 과연 오늘 얼마나 주문을 처리할 수 있을까요? 사장님이 보면 뭐라고 할까요? 까페도 망하지 않고, 당신도 살아남을 수 있을까요?


하나 밖에 모르는 바보가 더 일하는 법
자바스크립트는 싱글스레드 언어입니다. '싱글은 1개인 것 같고 스레드는 뭐지?'라고 하신다면, 자바스크립트는 한 번에 한 가지 작업 밖에 수행하지 못한다는 뜻입니다. 마치 위의 바리스타처럼요 :).

그래서 자바스크립트로 코드를 구동하면 무언가 '순차적'으로 일어나게 됩니다. 순차적으로 일어난다는 것은 즉 '뒤에 있는 코드가 시행될려면 앞의 연산들이 끝날 때까지 기다려야 한다'는 뜻입니다. 이게 왜 문제가 될까요?

오늘날의 웹페이지는 엄청나게 많은 이미지와, 엄청나게 많은 데이터와, 엄청나게 - 정말 엄청나게 - 많은 정보들이 흘러넘칩니다. 이런 곳에서 위와 같이 코드가 일을 하게 된다면, 아마 페이지 최상단에 있는 것부터 하나씩 또 하나씩 로딩이 될 것입니다. 더 심한 건 시작하면 중간에 물릴 수도 없으니, 페이지를 로딩하는 중간에 다른 명령을 수행할 수도 없습니다. 만약 유튜브 한 페이지가 오브젝트 하나씩하나씩 따로 로딩해서 동영상이 돌아가는데 꽤 오랜 시간이 걸리고, 그 시간 동안 다른 건 아무것도 클릭하지 못한다면?

뭐, 그건 여러분 상상에 맡기구요.

// 그런 암걸리는 상황을 한 번 만들어봅시다.
const btn = document.querySelector('button');
btn.addEventListener('click', () => {
  let myDate;
  for(let i = 0; i < 10000000; i++) {
    let date = new Date();
    myDate = date
  } // 날짜를 천만번 계산하는 이벤트리스너.....얼마나 걸릴까?

  console.log(myDate);
  // 이 console.log가 실행되는 때는 언제인가요?

  let pElem = document.createElement('p');
  pElem.textContent = 'This is a newly-added paragraph.';
  document.body.appendChild(pElem);
});

// 으아아아악!
// 이렇게 동기적으로 실행되는 무거운 작업이 수행되어서
// 이후의 작업들이 실행되지 않는 것을 Blocking 이라고 합니다.
// 그럼 이걸 non-blocking하게 만들어 줄 수도 있겠죠?

// We gonna do that !


//코드 출처 : MDN

그런데 오늘날 웹페이지를 보면 꼭 그렇지만도 않은 것 같습니다. 한 번에 한 가지 일만 처리가 가능하다면 구글 이미지검색이나 네이버 메인, 유튜브와 같은 웹사이트들은 왜 하나씩 하나씩 로딩이 되지 않고 파바바바박 로딩이 되는 것일까요? 유튜브에서는 동영상 로딩이 채 끝나기도 전에 다른 동영상으로 넘어갈 수 있고, 구글 이미지 검색에서는 한 번 마우스 스크롤을 할 때마다 열 댓 개씩이나 이미지가 로딩이 되는데 어떻게 가능한 것일까요?

거기에 대한 해답이 바로 '비동기 프로그래밍'입니다. 비동기적으로 코드를 실행하면 더 유동적으로, 더 많은 일을 할 수 있답니다!


실제로는 무슨 일이 일어날까?

비동기 프로그래밍을 설명할 때에 가장 먼저 드는 예제인 setTimeout을 먼저 써보도록 해요. setTimeout은 브라우저의 setTimeout의 문법은 아래와 같습니다.

setTimeout(Callback, Time);
Time(milliseconds)가 지나면 callback을 실행시킵니다.

setTimeout(() => {console.log('setTimeout 작동!')}, 3000)
// 3초 뒤에 console.log 실행!

setTimeout 메소드가 실행될 때 무슨 일이 생길까요? Javascript는 모든 작업을 순차적으로 콜스텍에다가 집어넣고 수행을 합니다. 콜스텍에서 들어온 작업을 순서대로 수행을 하게됩니다. 그러다가 setTimeout을 보면 이렇게 받아들입니다.

음? 이거 setTimeout이네? 브라우저야. 이거 타이머 API에 맡겨서 시간지나면 알려줘. 그럼 내가 그거 콜백함수 처리 해줄께 ^^
그러고나서 콜스텍에 들어온 다른 작업을 먼저 수행하게 됩니다. 정말 그렇냐구요? 여기에 가시면 조금 더 자세하게 어떻게 돌아가는 지 그림으로 보실 수 있으실 거예요.

function print_with_delay (print, time){
	setTimeout(print(), time)
}

console.log('A')
setTimeout(()=>{console.log('B')}, 2000)
console.log('C')

그렇다면 위의 코드는 어떻게 실행이 될까요? 우리가 만약 코드의 동기적 실행과 비동기적 실행에 대해서 알지 못했다면 아마 "A-B-C" 순서대로 실행되는 걸로 알고 있을 거예요. 그러나 setTimeout을 처리할 차례가 되면 자바스크립트엔진은 setTimeout을 브라우저의 API로 보내버리고 Console.log(C)를 처리하게 됩니다.

자, 이렇게 싱글스레드로 하나씩 일을 처리하는 자바스크립트엔진이 더 많은 일을 유동적으로 처리하게 되었습니다. 이런 식으로 콜백을 동반해서 비동기 프로그래밍을 구현할 수 있습니다...만.


헬테이커 어디 없나? 콜백지옥

콜백을 통해서 실행하는 것도 완벽하지는 않았었습니다. 지금에서야 setTimeout 과 같이 비동기가 필요한 부분이 하나였기에 잘 알아볼 수 있지, 이게 3,4개가 중첩되어있다면 어떻게 될까요? 어떤 작업이 있는데 그게 비동기적으로 실행되어야 하는데, 그 안에 또 비동기적으로 실행되어야 할 것이 있고, 또 있고, 그 안에 또 있고....

비동기적으로 실행!! (function 콜백() {
  비동기적으로 실행2 (function 또다른 콜백(){
    비동기적으로 실행3 (function 또또다른 콜백(){
    	비동기적으로 실행4 (function 또또또다른 콜백(){
        	console.log(`차라리 날 죽여줘 ㅠ_ㅠ`)
          		비동기적으로 실행.....
        })
    })
  })
})

이와 같이 코드가 산을 그리면서 산으로 가는 것을 콜백지옥(callback hell)이라고 합니다. callback hell에서도 잘 돌아가긴 합니다. 돌아가긴 하는데... 만약에 저기 안에서 무언가가 펑 터져서 에러가 난 부분을 찾고 고쳐야한다면? 아주 읽기 싫고, 가독성이 떨어지겠죠?

fs.readdir(source, function (err, files) {
  if (err) {
    console.log('Error finding files: ' + err)
  } else {
    files.forEach(function (filename, fileIndex) {
      console.log(filename)
      gm(source + filename).size(function (err, values) {
        if (err) {
          console.log('Error identifying file size: ' + err)
        } else {
          console.log(filename + ' : ' + values)
          aspect = (values.width / values.height)
          widths.forEach(function (width, widthIndex) {
            height = Math.round(width / aspect)
            console.log('resizing ' + filename + 'to ' + height + 'x' + height)
            this.resize(width, height).write(dest + 'w' + width + '_' + filename, function(err) {
              if (err) console.log('Error writing file: ' + err)
            })
          }.bind(this))
        }
      })
    })
  }
})

// 출처 : http://callbackhell.com

물론 이런 상황을 가만두고 볼 수는 없지요. 그래서 다양한 방식으로 비동기작업을 처리하곤 했습니다. 이벤트와 콜백을 처리하기도 했고, 또 다른 방법을 쓰기도 했고... 그러다가 'Promise'가 나왔습니다.


이럴 땐 이렇게 실행해줘, 약속! 'Promise'

Promise는 뭘까요? 비동기적 작업을 하는 함수의 리턴타입으로 쓰이는 것이 프로미스입니다. 일단 기본적인 틀을 볼게요!

// 기본적으로 PROMISE는 인터페이스와 같으므로 new 생성자를 이용합니다
// Promise는 resolve와 reject에 해당하는 callback 2개를 받는
// 함수를 인자로 받습니다.
let promise = new Promise(function(resolve, reject) {});


//기본예제
let myFirstPromise = new Promise((resolve, reject) => {  
  setTimeout(function(){
    resolve("Success!"); // Yay! Everything went well!
  }, 250);
});

//.then 체이닝 예제
myFirstPromise.then((successMessage) => {
  console.log("Yay! " + successMessage);
});


// 출처 : MDN

Promise는 앞서 말씀드렸다시피 '비동기적 작업을 하는 함수의 리턴타입으로 쓰이는 것'입니다. 그래서 Promise는 함수 안의 작업이 성공적으로 동작했을 때 동작하는 'resolve' callback과 함수가 동작이 실패해서 오류상황이 난 때에 동작하는 'reject' callback을 인자로 받는 함수를 실행하게 됩니다.

원래는 이렇게 분기적인 명령을 실행할려고 하면 미리 실행 성공시에 실행할 함수와 에러 상황에서 실행할 함수를 정의했습니다. 그러면 미리 해당 함수들을 정의해야 하는 등의 불편함이 있었어요. 그러나 Promise를 사용하고 난 뒤로 그냥 Promise만 반환해주면 간단하게 끝나게 되었습니다! 불필요하게 함수를 따로 정의하고, 인자를 더 많이 받고 그러는 대신에요.

function do_something_async(if_success,if_fail) {
	// 좀 많이 무거운 작업...
  	// 상황에따라 callback를 실행
}

function if_success(){
	// 어쩌구 저쩌구
}
function if_fail(){
	// 어쩌구 저쩌구
}


function do_something_async() {
	return new Promise(function (resolve, reject){
    	// 좀 많이 무거운 작업...
    }
  if(success){
    	resolve();
    } else {
    	reject();
    }
}

어떤가요? 위와 같이 실행된 프로미스는 코드가 동작할 때에 일회성으로 동작하게 됩니다. 그래서 Promise는 처음에는 함수가 실행되기를 쭉 기다리고 있는 Pending 상태였다가, 함수가 잘 작동이 되면 resolve 가 되면서 resolve callback을 실행시키며 프로미스를 종료하고, 함수가 작동하다가 에러가 나거나 무언가 안되면 rejected 상태가 되면서 reject callback을 실행시키게 됩니다.

Promise의 진가는 이전에 콜백지옥을 불러왔었던, 연속적으로 비동기적인 동작을 하는 코드를 작성할 때에 드러나게 됩니다.

//asyc programming with Promise
const our_work = function do_something_async() {
	return new Promise(function (resolve, reject){
    	// 좀 많이 무거운 작업...
    }
  	if(success){
    	resolve();
    } else {
    	reject();
    }
}

//...를 아래와 같이 써먹어줄 수 있습니다.

//.then() & .catch() 메소드를 활용하여 분기화가 가능합니다.
//두 메소드는 모두 callback함수를 인자로 받습니다.
//Promise의 callback이 정상적으로 실행되면 .then()으로 넘어가서 콜백을 실행합니다.
//.then() 메소드를 체이닝 시켜서 연속적인 비동기 작업이 가능하게 합니다.
//중간에 오류가 한번이라도 나면 .catch()가 실행됩니다.

// 예제- 
// 앞의 작업이 성공할 때마다 결과갚을 콘솔에 띄우고
// 성공했을 시에 저마다 다른 콜백을 연이어 실행함
// 오류가 생길 시에는 에러 객체를 콘솔에 띄우고 reject 함수 실행
our_work.then((...) => {
  console.log(...)
  return callback_on_success1()
})
	.then((......) => {
  console.log(......)
  return callback_on_success2()
})
	.then((.........) => {
  console.log(.........)
  return callback_on_success3()
})
	.catch(error => {
  console.log(error)
  return reject()
})

이전같으면 콜백 안에 콜백이 있고, 또 콜백 안에 콜백이 있던 상황이 깔끔하게 처리가 되었습니다...! 그럼에도 불구하고 Promise에 아주 단점이 없는 것은 아닙니다.

Promise를 .then으로 체이닝해서 연속적으로 실행한다는 데서 눈치를 챌 수 있겠죠. Promise는 Promise를 반환합니다. 그리고 .then과 .catch를 포함한 Promise 관련 메소드들도 마찬가지지요. 단일한 작업이면 몰라도 비동기작업이 필요해서 프로미스를 쓰기는 썼는데, 반환값이 중간에서 꼬여버린다면 어떻게 될까요? 콜백지옥은 코드를 스윽 읽어라도 볼 수 있지만, Promise는 일일히 반환값을 디버거로 뜯어봐야할 것입니다.

중간에 다른 값이 필요할 때에도 callback과 마찬가지로 코드를 읽기 어렵도록 가독성을 해칩니다.

const function work_with_promise(){
promise.then(data => {
	....
    //난 지금 여기에 다른 프로미스를 가져오고 싶은걸?
    return another_promise(data).then(data1 => 
   	....
    //앞의 두개의 결과값을 받는 새로운 프로미스를 또 써먹고 싶은걸?
	return new_promise(data1, data2)
    })
	// -_-;;;;;;;;;
    .catch....
  })
}

비동기적인 것을 동기적인 것 마냥... asyc / await

이런 복잡하고 직관적이지 않은 뻘짓 없이 async/await를 이용하면 더욱 간결하고, 깔끔하게 비동기적 작업을 실행하는 함수를 구현할 수 있습니다. async/await는 이전의 callback과 Promise에서 보듯이 이질적인 비동기 작업의 실행을 좀 더 유연하고 '일반함수'적으로 실행할 수 있습니다.

async function 함수명() {
  await 비동기_처리_메서드_명();
  // 그 뒤에 다른 것을 뚜닥뚜닥하면 됩니다.
}

간단하게 해당 함수의 선언자 function 앞에다가 async를 붙여줍니다. 그리고 비동기작업이 필요한 작업 앞에다가 await를 붙여줍니다. 끝입니다. 간단하죠? 실제로 간단하게 아래와 같이 써먹을 수 있습니다.

//특정 유저 정보를 url로 받아와서 입력한 id와 대조한 뒤, 이름을 log에 띄우는 작업

async function log_username(idnumber){
  // 서버에서 데이터를 받아와야.... 값이 제대로 들어가겠죠? 비동기가 필요합니다.
  let user = await fetchUser(User_URL)
  if (user.id === idnumber){
  	console.log(user.username);
  }
}

// 에러캐치는 try/catch 를 사용하시면 됩니다!

async function log_username(idnumber){
  try{
  let user = await fetchUser(User_URL)
  if (user.id === idnumber){
  	console.log(user.username);
  }
  } catch(err) {
  	console.log(err)
  }
}

어때요? 이제 좀 기존의 일반함수 실행방식이랑 비슷하지 않나요? 그렇다고 callback 처럼 복잡하지도 않고요. 굳이 promise를 함수 전체에서 내내 사용해서 .then과 .catch의 체이닝을 고려하는 '이질적인' 사고를 하지 않아도 되구요. 더욱이 좋은 것은 다수의 비동기작업을 처리할 때입니다. Promise.all과 같이 복잡한 로직을 생각하지 않고도 아래처럼 다수의 비동기작업의 결과들을 이용한 작업을 정말, 정말로 간단하게 수행할 수 있습니다.

// 3곳의 url에서 정보를 가져와서 json변환 뒤 객체를 만들어 리턴을 해주는 함수
// data - news - weather를 가져와서 해당 정보를 객체화한다.

async function get_resources(){
  
  //마찬가지로 셋 다 서버에서 완전히 데이터를 받아와야 밑에 제대로 뜨겠죠?
  let data_json = await fetch(data_url...).then(response => response.json())
  let news_json = await fetch(news_url...).then(response => response.json())
  let weather_json = await fetch(weather_url...).then(response => response.json())

    return {
    	data : data_json;
      	news : news_json;
      	weather : weather_json;
    }
  // 임의로 설정한 예제입니다.
  // 디테일은 데이터 저장 양식과 response에 따라 달라질 수 있습니다.
}

이렇듯 async로 "이 함수에는 비동기적으로 실행되는 작업이 포함되어 있습니다"라고 선언하고 비동기적 실행이 필요한 곳에만 await처리를 함으로서 일반적인 함수 실행/선언 환경처럼 편리하게 비동기 프로그래밍을 할 수 있습니다.


마치며

지금까지 비동기 프로그래밍은 왜 필요한지, 그리고 어떤 방법으로 하는지에 대해서 알아보았습니다. 비동기 프로그래밍은 메소드와 같은 기능이 아니라 단일한 일을 하나씩 처리하는 자바스크립트를 잘 굴릴 수 있는 방법론에 가깝습니다. callback과 promise, async/await는 이를 이루는 도구이지만, 응용의 방법은 다양하니 실제로 설계하고 부딫히는 편이 처음 배우는 분들에게는 나으실 수 있다고 생각이 듭니다.

그럼 오늘도 해피코딩하시길 바랍니다 :)

profile
초보개발자

0개의 댓글