자바스크립트의 비동기 처리(미해결)

zimablue·2023년 5월 24일

javascript

목록 보기
14/30

자바스크립트는 싱글 스레드라서 한 번에 하나의 작업만 순차적으로 수행할 수 있습니다.(동기적, Sync)

만약 앞선 작업이 시간이 정말 오래 걸린다면 끝나기 전까지는 뒤의 작업이 수행될 수 없기 때문에 정말 오랜 시간을 기다려야한다는 단점이 있습니다.

이런 상황을 위해 사용하는 것이 비동기 처리입니다.(Async)

스레드: 한 프로세스 내에서 실행되는 동작의 단위
싱글 스레드: 한 프로세스 내에서 한가지 작업을 실행하기 위해 순차적으로 실행되는 하나의 흐름



event loop 와 call stack 작동원리와 비동기 처리

Web Browser의 경우 Javascript Engine과 Web APIs가 탑제되어 있습니다.
Javascript 코드를 한 줄씩 실행하다, 비동기 함수를 만나게 되면 비동기 작업을 Web APIs에 콜백 함수와 함께 넘겨줍니다.
Web APIs는 전달 받은 작업이 완료되면 다시 Javascript Engine에 돌려줍니다.
그럼 Javascript Engine은 돌려받은 콜백함수를 실행시킵니다.

이미지 출처: Alexander Zlatkov


비동기 처리

자바스크립트에서 처리해야 할 작업은 call stack에 쌓입니다.
call stack에 쌓인 작업중 DOM, AJAX, Timeout 등의 작업은 API에서 해당 작업을 JavaScript 엔진의 쓰레드와는 다른 쓰레드에 위임합니다.

API로 위임될 DOM, AJAX, Timeout 등의 작업들은 callback함수를 사용합니다.
API로 위임되면 Task Queue(Callback Queue)로 해당 callback함수를 전달합니다.
전달된 callback함수는 Task Queue에 들어온 순서대로 쌓이게 됩니다.
Event Loop는 call stack이 비어있을 경우 callback을 call stack으로 보냅니다.

call stack에 쌓인 callback함수는 늦게 들어온 순서대로 처리됩니다.

API: 웹브라우저, NodeJS에서 제공하는 기능들
callback: 인자로 전달된 함수


코드 예시

console.log('Hi');

setTimeout(function cb1() { 
    console.log('cb1');
}, 5000);

console.log('Bye');

아래 움짤은 위의 코드의 내부 동작입니다.

이미지 출처: Alexander Zlatkov



callback을 활용한 비동기 호출 순서 제어

무엇이 문제인가?

아래의 코드는 <script>태그를 만들고, 페이지 </head><script src="…">태그를 추가합니다.
태그가 페이지에 추가되면 src에 있는 스크립트를 로딩하고 실행됩니다.

실행할 스크립트는 '/my/script.js'에 있으며 newFunction()함수를 가지고 있습니다.
따라서 loadScript()함수가 끝나면 newFunction()함수를 실행합니다.
하지만 newFunction()함수를 실행하면 오류가 발생합니다.

function loadScript(src) {
  let script = document.createElement('script');
  script.src = src;
  document.head.append(script);
}
loadScript('/my/script.js');
// script.js엔 "function newFunction() {…}"이 있습니다.

newFunction(); // 함수가 존재하지 않는다는 에러가 발생합니다!

document는 DOM을 조작하는 함수이기 때문에 비동기처리됩니다.
loadScript()함수가 종료되고 newFunction()을 호출했을 때 loadScript()함수는 종료되었지만 함수 안에서 비동기 처리된 함수는 아직 처리되지 않았기 때문에 newFunction()함수를 찾을 수 없습니다.


callback함수로 해결

문제는 비동기 작업들이 처리되기 전에,
비동기된 작업들이 완료된 후 실행해야 할 작업을 실행해서 생깁니다.
비동기된 함수가 모두 처리되고 난 후에 newFunction()함수를 실행하면 문제가 해결됩니다.

전의 코드와 같이 <script src="…">태그를 만듭니다.
이번엔 onload이벤트를 사용해 <script>로드가 끝나면 callback함수가 실행되도록 합니다.
callback함수는 newFunction()함수를 실행합니다.
이미 <script>가 로드 되었기 때문에 에러를 발생하지 않습니다.

function loadScript(src, callback) {
  let script = document.createElement('script');
  script.src = src;
  
  script.onload = () => callback(script);
  
  document.head.append(script);
}
loadScript('/my/script.js', function() {
  newFunction(); // 이제 함수 호출이 제대로 동작합니다.
  ...
});

그럼 callback이 답인가?

script가 하나일 때는 callback을 통해 에러를 일으키지 않고 실행할 수 있지만 script가 여러개 일때는 문제 생길 수 있습니다.

콜백지옥

스크립트가 두 개이고, 첫 번째 스크립트의 로딩이 끝난 이후 두 번째 스크립트 로딩을 하고 싶다면 아래와 같이 콜백함수 안에 콜백함수를 넣어야 합니다.

function loadScript(src, callback) {
  let script = document.createElement('script');
  script.src = src;
  script.onload = () => callback(script);
  document.head.append(script);
}
loadScript('/my/script.js', function(script) {

  alert(`${script.src}을 로딩했습니다. 이젠, 다음 스크립트를 로딩합시다.`);

  loadScript('/my/script2.js', function(script) {
    alert(`두 번째 스크립트를 성공적으로 로딩했습니다.`);
  });
});

아직은 읽을만 하지만 이런식으로 콜백함수 안에 콜백함수들 1억개를 넣게 되면 가독성이 떨어지는 문제가 생길 수 밖에 있습니다.



Promise를 활용한 비동기 호출 순서 제어

Promise는 자바스크립트에 내장된 객체입니다.
Promise객체를 활용하면 callback이 가진 문제를 보완하며 비동기 호출 순서를 제어할 수 있습니다.

Promise 생성

const promise = new Promise((resolve, reject) => { 
  //executor 
});

프라미스 생성 코드는 resolve, reject라는 인수를 받는 콜백함수를 받습니다.
이 콜백함수를 executor라고 부릅니다.
(resolvereject는 자바스크립트에서 자체 제공하는 콜백이므로 사용자는 신경쓰지 않아도 됩니다.)

executor는 프라미스가 생성될때 자동으로 실행되며 원하는 작업을 실행할 수 있습니다.
그리고 처리 성공 여부에 따라 인수로 넘겨준 콜백(resolve, reject) 중 하나를 반드시 호출해야 합니다.


state와 result

Promise 내부에는 state([[PromiseStatus]])와 result([[PromiseValue]])라는 프로퍼티가 있습니다.
성공 여부는 state값에 따라 결정합니다.

이미지 출처: 모던 JavaScript 튜토리얼


resolve와 reject

Promise가 생성되었을 때 state는 "pending"값을 가지고 있습니다.
생성된 Promise에서 resolvereject 중 하나를 반드시 호출해야 하기 때문에 한 번씩 호출해보겠습니다.

state가 pending

이미지 출처: Lydia Hallie


resolve를 호출 했을 때는 state가 "pending"에서 "fulfulled"로 바뀌게 됩니다.
result는 호출한 resolve의 매개변수가 됩니다.

let promise = new Promise(function(resolve, reject) {
  setTimeout(() => resolve("완료"), 1000);
  // state: "fulfilled", result: "완료"
});

reject를 호출 했을 때는 state가 "pending"에서 "rejected"로 바뀌게 됩니다.
result는 호출한 resolve의 매개변수가 됩니다.
일반적으로 reject는 실패했을 때 사용하기 때문에 Error인스턴스를 매개변수로 사용합니다.

let promise = new Promise(function(resolve, reject) {
  setTimeout(() => reject(new Error("에러 발생!")), 1000);
  // state: "rejected", result: "에러 발생!"
});

state가 fulfulled, reject

이미지 출처: Lydia Hallie



then, catch, finally

프라미스 객체의 state, result 프로퍼티는 내부 프로퍼티이므로 개발자가 직접 접근할 수 없습니다.
.then/ .catch/ .finally 메서드를 사용하면 접근 가능합니다.

function getImage(file) {
	return new Promise((resolve, reject) => {
      try {
        const data = readFile(file)
        resolve(data);
      } catch(err) {
        reject(new Error(err))
      };
    });
};

이미지 출처: Lydia Hallie


then

.then을 사용하면 resolvereject상태의 result 값에 접근할 수 있습니다.

then은 인수 두 개를 사용할 수 있습니다.
첫 번째 인수는 resolve호출하고 두 번째 인수는 reject를 호출합니다.

promise.then(
  result => console.log(result),
  error => console.log(error)
);

or

promise.then(
  function(result) { },
  function(error) { }
);

resolve를 호출할 때

let promise = new Promise(function(resolve, reject) {
  setTimeout(() => resolve("완료!"), 1000);
});

// resolve 함수는 .then의 첫 번째 함수(인수)를 실행합니다.
promise.then(
  result => alert(result), // 1초 후 "완료!"를 출력
  error => alert(error) // 실행되지 않음
);

reject를 호출할 때

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

// reject 함수는 .then의 두 번째 함수를 실행합니다.
promise.then(
  result => alert(result), // 실행되지 않음
  error => alert(error) // 1초 후 "Error: 에러 발생!"을 출력
);

catch

then에서 reject만 다루고 싶다면 아래와 같이 null을 첫 번째 인수로 전달하면 됩니다.

.then(null, error)

catch는 위의 코드와 같지만 위의 코드보다 간결하다는 장점이 있습니다.

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

promise.catch(alert); // 1초 뒤 "Error: 에러 발생!" 출력

finally

.then(f, f)와 유사하지만 다릅니다.

차이점은 아래와 같습니다.

  1. finally 핸들러엔 인수가 없습니다. finally에선 프라미스가 이행되었는지, 거부되었는지 알 수 없습니다. finally에선 절차를 마무리하는 ‘보편적’ 동작을 수행하기 때문에 성공·실패 여부를 몰라도 됩니다.

  2. finally 핸들러는 자동으로 다음 핸들러에 결과와 에러를 전달합니다.

.finally(() => alert("프라미스가 준비되었습니다."))

프라미스 resolve를 호출하던 reject를 호출하던 finally는 실행되며 "프라미스가 준비되었습니다."라는 알림을 실행할 것입니다.


프라미스 체이닝

then, catch, finally은 체이닝해서 사용할 수 있습니다.
then은 프라미스를 반환하기 때문에 그 다음 then을 호출할 수 있습니다.

new Promise(function(resolve, reject) {
  
  setTimeout(() => resolve(1), 1000);
  
}).then(function(result) {
  
  alert(result); // 1
  return result * 2;
  
}).then(function(result) {
  
  alert(result); // 2
  return result * 2;
  
}).then(function(result) {

  alert(result); // 4
  return result * 2;

});

이미지 출처: 모던 JavaScript 튜토리얼

이미지 출처: Lydia Hallie



프라미스 아직 이해하지 못한것...

프라미스 reject를 호출하면 state가 rejected가 되어야 하는데 fulfilled가 되는 이유를 찾지 못했습니다.
뭔가 느낌적인 느낌은 알것 같은데 설명하라고 하면 못하겠습니다.

  1. 첫 번째 코드
let promise = new Promise(function (resolve, reject) {
  setTimeout(() => reject(new Error('에러 발생!')), 1000);
});

promise.then(
  result => console.log(result),
  error => console.log(error)
);
  • 실행 결과

  1. 두 번째 코드
let promise = new Promise(function (resolve, reject) {
  setTimeout(() => reject(new Error('에러 발생!')), 1000);
});

promise.then((result, error) => {
	console.log(result)
	console.log(error)
});
  • 실행 결과

  1. 세 번째 코드
let promise = new Promise(function (resolve, reject) {
  setTimeout(() => reject(new Error('에러 발생!')), 1000);
});

promise.then(null, error => {
	console.log(error)
});
  • 실행 결과

추측

아마도 promise의 setTimeout이 해결되지 못한 채 출력을 해서 그런것이 아닌가 생각합니다.
근데 그럼 왜 pending이 아닌 fulfilled가 출력이 되는지는 모르겠습니다.

GPT의 답변

Promise의 상태가 fulfilled로 표시된 점이 의아할 수 있지만, 실제로 이 상태가 변경된 것이 아니라 브라우저의 콘솔에서 Promise의 상태를 시각화하는 방식 때문입니다.

브라우저 콘솔에서는 Promise 객체의 상태가 즉각적으로 반영되지 않을 때가 있습니다. 즉, Promise가 생성될 당시에는 pending 상태였으나, setTimeout에 의해 reject가 호출된 후에는 상태가 rejected로 변경됩니다. 다만 콘솔에서는 Promise가 즉시 평가되지 않고 이후에 변경된 상태를 보여주기도 합니다. 이로 인해 PromiseStatefulfilled처럼 잘못 보이는 상황이 발생할 수 있습니다.

따라서 이 문제는 코드의 로직에 문제가 있는 것이 아니라 콘솔에서 Promise 객체 상태를 갱신하는 방식의 시차에 의한 것으로 이해하시면 됩니다.

답: Promise의 상태가 rejected가 아니라 fulfilled로 보이는 점은 당황스러울 수 있습니다. 이 현상은 브라우저 콘솔이 Promise의 상태를 실시간으로 정확히 반영하지 못해 발생할 수 있는 현상입니다.

기본적으로 Promise가 reject로 호출되면 PromiseState가 rejected가 되어야 정상입니다. 그러나 일부 브라우저 콘솔에서는 Promise 객체의 상태가 변화하는 시점을 정확하게 추적하지 못해 fulfilled로 잘못 표시되는 경우가 있습니다. 이는 실제로 코드에 문제가 있는 것은 아니며, 브라우저 콘솔의 상태 표시 오류로 간주할 수 있습니다.

이 문제는 특정 브라우저나 개발자 도구 버전에서 발생할 수 있는 버그로 알려져 있으며, 코드의 정상적인 동작에는 영향을 미치지 않습니다. Promise의 catch 핸들러가 정상적으로 작동하고 reject의 결과가 제대로 처리된다면 이 출력 오류는 무시해도 괜찮습니다.

결론: 찝찝하다.



async, await

프라미스도 비동기를 제어하기 좋지만 조금 더 간편하게 동기식으로 작성하는 것처럼 느낄 수 있게 도와주는 api가 있습니다.(syntactic sugar)


async

async는 함수 앞에 async를 붙이면 사용할 수 있으며 async를 붙인 함수는 Promise를 반환합니다.

async function f() {
  return 1;
}

f().then(console.log()); // 1
// f함수가 Promise를 반환하기 때문에 .then메서드를 사용할 수 있습니다.

명시적으로 프라미스를 반환하는 것도 가능합니다.
위의 코드와 차이점은 없습니다.

async function f() {
  return Promise.resolve(1);
}

f().then(alert); // 1

이미지 출처: Lydia Hallie



await

await는 async가 붙은 함수 안에서만 동작하며, resolve된 Promise를 반환할 때까지 기다립니다.

async와 await를 사용한 비동기 처리 코드를 보겠습니다.


console.log('Before function!')이 Call Stack에서 실행되고 로그에 찍힙니다.


myFunc()함수가 실행되면 Call Stack에서 실행됩니다.
myFunc()함수 내부의 console.log('In function!')이 Call Stack에 쌓이고 실행되고 출력됩니다.


myFunc()함수 내부의 one()함수가 실행되며 Call Stack에 쌓입니다.
one()함수 내부의 Promise가 Call Stack에 쌓이고 resolve를 호출하여 PromiseValus에 'One!'을 가진 Promise를 반환합니다.
myFunc()함수는 await를 사용하였기 때문에 Microtask Queue로 이동합니다.


console.log('After function!')이 Call Stack에서 실행되고 출력됩니다.


Call Stack이 비어있기 때문에 Microtask Queue에 있는 myFunc()함수가 Call Stack에서 실행됩니다.
console.log(res)의 "One!"이 출력됩니다.

이미지 출처: 모던 JavaScript 튜토리얼



await과 then 비교 예시

function delay(ms) {
	return new Promise((resolve, reject) => {
      setTimeout(resolve, ms));
    }
}

  • then 사용
function getApple() {
	return delay(3000)
      .then(() => 'apple')
};

function getBanana() {
	return delay(3000)
      .then(() => 'banana')
};
  • await 사용
async function getApple() {
	await delay(3000);
	return 'apple';
};

async function getBanana() {
	await delay(3000);
	return 'banana';
};

  • then 사용
function pickFruits() {
	return getApple().then(apple => {
    	return getBanana().then(banana => {
          return `${apple} + ${banana}`
        });
    });
};

pickFruits().then(console.log);
  • await 사용
async function pickFruits() {
	const apple = await getApple();
  	const banana = await getBanana();
  	return `${apple} + ${banana}`
};

pickFruits().then(console.log);





추가 async/await 불필요한 경우

  1. promise를 그대로 return 하는 중간 함수

  2. 간단하게 then으로 처리 가능한 경우

  3. 원래부터 promise를 반환하는 함수


예시

비동기 통신인 fetch 함수를 사용하면서 render함수 호출 직전까지 async/await 가 사용됐습니다.

document.querySelector('#selector')?.addEventListener('change', handleChange)

async function fetchProduct(id) {
  const res = await fetch(`https://api.escuelajs.co/api/v1/products/${id}`)
  const json = await res.json()
  return json
}

async function getProduct(id) {
  // (...) 사전준비코드 생략
  const json = await fetchProduct(id)
  return json;
}

async function handleChange(e) {
  const id = e.target.value
  const product = await getProduct(id)
  renderProduct(product)
}

async/await 불필요한 경우에 맞춰 정리했습니다.

document.querySelector('#selector')?.addEventListener('change', handleChange)

// fetch는 Promise를 반환 fetchProduct도 Promise를 그래도 반환하면 됨
// .then을 사용해도 가독성에 문제가 없음
const fetchProduct = id => await fetch(`https://api.escuelajs.co/api/v1/products/${id}`).then(res => res.json())


// fetchProduct의 반환값은 이미 Promise 
// getProduct의 반환도 Promise 이기 때문에 
// async/await으로 Promise를 만들어 줄 필요가 없음
function getProduct(id) {
  // (...) 사전준비코드 생략
  const json = fetchProduct(id)
  return json;
}

async function handleChange(e) {
  const id = e.target.value
  const product = await getProduct(id)
  renderProduct(product)
}

어디선가 최소 한 번은 async/await를 사용하는게 좋지만 중간 과정에 있는 함수 전부에서 사용해야 하는 것은 아닙니다.

0개의 댓글