자바스크립트에서의 비동기처리

Posted on May 5, 2022

Promise

자바스크립트 ES6이후에 등장하게 된 promise객체는 비동기 처리 작업 할 때 콜백함수가 쌓이게되는 콜백지옥을 해결하기 위해 처음 등장이 되었다.

같은 원리이지만, promise객체를 통해 더 가독성이 쉽게 표시할 수 있어서 이용하게 되었고,

promise 또한 많은 promise변수를 사용하여 리턴하게 되면 promise지옥을 경험 할 수 있다.

콜백함수, promise, async/await의 응답속도

콜백함수나 promise의 응답속도는 거의 같다.

처음 호이스팅 환경에서 차례로 읽기 때문에 비동기 처리 되는 영역은 처리 완료 되지 않은 상태에서 다음을 읽는다. 따라서, setTimeout함수와 같이 딜레이를 주게 되어도 응답속도는 딜레이를 무시하고 먼저 처리한다.

콜백함수와 promise의 응답속도가 거의 같다고 하는 이유는, 브라우저의 환경 차이로 인해서 미세한 속도 차이가 발생 할 수 있다.

전체응답속도는 async/await는 모든 비동기작업(setTimeout과 같은 딜레이 작업들)이 끝나고 순서대로 처리가 완료 된다. 따라서, 처리완료되는 속도는 가장 느리다고 할 수 있다.

콜백함수

콜백함수를 이해하기 위해서는 먼저 ‘호이스팅’에 대해 이해 해야 한다.

호이스팅

함수 안에 있는 선언들을 모두 끌어올려서 해당 함수 유효범위의 최상단에 선언하는 것.

함수가 실행되기 전에 함수에 필요한 모든 값을 최상단에 선언한다.

setTimeout(callback , time)

브라우저 API

callback 함수를 time(ms단위) 이후 출력한다.

동기(Synchronous)와 비동기(Asynchronous)

자바스크립트는 Synchronous 이다.

Synchronous callback

console.log('1')
setTimeout(() => console.log('2'), 1000)
console.log('3')

function printImmediately(print) {
  print()
}
printImmediately(() => console.log('hello'))

호이스팅 결과.

function printImmediately(print) {
  // 1. 호이스팅 함수선언문
  print()
}
console.log('1') // 2. '1' 출력
setTimeout(() => console.log('2'), 1000) // 3. 브라우저API에 1초후에 '2' 출력 요청
console.log('3') // 4. '3' 출력
printImmediately(() => console.log('hello')) // 5. 함수 즉시출력 실행 'hello' 출력
// 6. 1초 후, 브라우저API에 의해 '2' 출력

Asynchronous callback

console.log('1')
setTimeout(() => console.log('2'), 1000)
console.log('3')

function printImmediately(print) {
  print()
}
printImmediately(() => console.log('hello'))
function printWithDelay(print, timeout) {
  setTimeout(print, timeout)
}

printWithDelay(() => console.log('async callback'), 2000)

호이스팅 결과 호이스팅 결과

function printImmediately(print) {
  print()
}
function printWithDelay(print, timeout) {
  setTimeout(print, timeout)
}

console.log('1') // 동기
setTimeout(() => console.log('2'), 1000) // 비동기------------>
console.log('3') // 동기
printImmediately(() => console.log('hello')) // 동기
printWithDelay(() => console.log('async callback'), 2000) // 비동기 ------------->

Promise

Promise는 “비동기 상태를 값으로 다룰 수 있는 객체”

Promise가 왜 필요한가요?

프로미스는 주로 서버에서 받아온 데이터를 화면에 표시할 때 사용합니다. 일반적으로 웹 애플리케이션을 구현할 때 서버에서 데이터를 요청하고 받아오기 위해 API를 사용합니다.

콜백 패턴의 문제

  1. 콜백 함수의 중첩 사용
function requestData1(callback) {
  callback(data) // 2
}
function requestData2(callback) {
  callback(data) // 4
}
function onSuccess1(data) {
  console.log(data)
  requestData2(onSuccess2) // 3
}
function onSuccess2(data) {
  // 5
  console.log(data)
}
requestData1(onSuccess1) // 1
// 콜백 패턴의 코드의 흐름이 순차적이지 않기 때문에, 코드를 읽기가 힘들다.
  1. 간단한 프로미스 코드 예
requestData1()
  .then((data) => {
    console.log(data)
    return requestData2()
  })
  .then((data) => {
    console.log(data)
  })
  1. 프로미스의 세 가지 상태
  • pending : 대기
  • fulfilled : 수행이 정상적으로 끝났고 결과값 존재
  • rejected : 수행이 비정상적으로 끝났음.
  • settled : 이행됨 , 거부됨 상태 프로미스는 처리 됨 상태가 되면 더 이상 다른 상태로 변경되지 않는다. 대기 중 상태일 때만 이행됨 또는 거부됨 상태로 변할 수 있다.
  1. 프로미스를 생성하는 방법
const p1 = new Promise((resolve, reject) => {
  resolve(data)
  // or reject('error message')
})
const p2 = Promise.reject('error message')
const p3 = Promise.resolve(param)
  1. Promise.resolve 반환 값
const p1 = Promise.resolve(123)
console.log(p1 !== 123) // true 이행됨 상태인 프로미스가 반환된다.
const p2 = new Promise((resolve) => setTimeout(() => resolve(10), 1))
console.log(Promise.resolve(p2) === p2) // true
// Promise.resolve 함수에 프로미스가 입력되면 그 자신이 반환된다.
  1. 프로미스 이용하기 .then then은 처리 됨 상태가 된 프로미스 처리 할 때 사용되는 메서드.
//requestData().then(onResolve, onReject);
// 프로미스 처리됨 상태가 되면, onResolve 함수가 호출되고, 거부됨 상태가 되면 onReject 함수가 호출 된다.
Promise.resolve(123).then((data) => console.log(data)) // 123
Promise.reject('err').then(null, (error) => console.log(error)) // err
  1. 연속해서 then 메서드 호출하기
let requestData1 = new Promise((resolve, reject) => {
  setTimeout(() => {
    resolve('hun')
  }, 1000)
})
let requestData2 = new Promise((resolve, reject) => {
  setTimeout(() => {
    resolve('jenny')
  }, 2000)
})

requestData1
  .then((data) => {
    console.log(data)
    return requestData2 // 1
  })
  .then((data) => {
    return data + 1 // 2
  })
  .then((data) => {
    throw new Error('some Error') // 3
  })
  .then(null, (error) => {
    console.log(error)
  })
  1. 함수에서 프로미스를 반환하면 then메서드는 그 값을 그대로 반환한다.

  2. 프로미스가 아닌 값을 반환하면, then 메서드는 이행됨 상태인 프로미스를 반환한다.

  3. 함수 내부에서 예외가 발생하면 then 메서드는 거부됨 상태인 프로미스를 반환한다.

  4. 거부됨 상태가 되면 onReject 함수를 호출한다.

Promise.reject('err')
  .then(() => console.log('then 1')) // 1번
  .then(() => console.log('then 2')) // 1번
  .then(
    () => console.log('then 3'),
    () => console.log('then 4')
  ) // 2번
  .then(
    () => console.log('then 5'),
    () => console.log('then 6')
  ) // 3번

거부 됨 상태인 프로미스는 처음으로 만나는 onReject 함수를 호출 하므로 1번 이 생략되고, 2번 코드의 then 4가 출력 된다. then 4를 출력하는 함수는 undefined 를 결과로 가지면서 이행됨 상태인 프로미스를 생성한다. 3번 따라서 이어지는 then 메서드에서는 then 5가 출력된다. then 메서드의 가장 중요한 특징은 항상 연결된 순서대로 호출된다는 점이다.

  1. 프로미스 이용하기 2 : catch catch는 프로미스 수행 중 발생한 예외를 처리하는 메서드이다. catch메서드는 then 메서드의 onReject 함수와 같은 역할을 한다.
Promise.reject(1).then(null, (error) => {
  console.log(error)
})
Promise.reject(1).catch((error) => {
  console.log(error)
})

예외 처리는 then메서드의 onReject를 이용하기보다는, catch메서드를 이용하는게 가독성 면에서 좋다.

  1. then 메서드의 onReject를 사용했을 때 문제점.
Promise.resolve().then(
  () => {
    // 1번
    throw new Error('some error')
  },
  (error) => {
    // 2번
    console.log(error)
  }
)

1번 then 메서드의 resolve 함수에서 발생한 예외는 같은 then 메서드의 2번 reject함수에서 처리되지 않는다. 실행하면 Unhandled promise rejection 에러가 발생한다. 거부됨 상태인 프로미스를 처리하지 않았기 때문이다.

  1. onReject 함수를 사용하지 않고 catch를 사용한 예
Promise.resolve()
  .then(() => {
    throw new Error('some error')
  })
  .catch((error) => {
    console.log(error)
  })
  1. catch 메서드 이후에도 then 메서드 사용하기
Promise.reject(10)
  .then((data) => {
    console.log('then1:', data)
    return 20
  })
  .catch((error) => {
    console.log('catch:', error)
    return 30
  })
  .then((data) => {
    console.log('then2:', data)
  })
// catch: 10
// then2: 30

프로미스 이용하기 3 : finally

  • finally를 사용한 간단한 코드
requestData()
.then(data => {
	...
})
.catch(error => {
	...
})
.finally(() => {
	...
});
  • finally 메서드는 새로운 프로미스를 생성하지 않는다.
function requestData(){
  return fetch()
    .catch(error => {
		...
	})
    .finally(() => {
	senLogToServer('requestData finished')
	})
  }
 requestData().then(data => console.log(data)); // 1번

1번, requestData 함수의 반환값은 finally 메서드 호출 이전의 프로미스다. 따라서, requestData함수를 사용하는 입장에서는 finally 메서드 존재 여부를 신경 쓰지 않아도 된다.

프로미스 활용하기

병렬로 처리하기 : Promise.all

Promise.all은 여러 개의 프로미스를 병렬로 처리할 때 사용되는 함수이다. then 메서드를 체인으로 연결하면 각각 비동기 처리가 병렬로 처리되지 않는 단점을 극복한다.

  • 순차적으로 실행되는 비동기 코드
requestData1()
  .then((data) => {
    console.log(data)
    return requestData2()
  })
  .then((data) => {
    console.log(data)
  })

비동기 함수 간에 서로 의존성이 없다면, 병렬로 처리하는게 더 빠르다. then 메서드를 체인으로 연결하지 않고, 비동기함수를 각각 호출하면, 병렬로 처리 된다.

  • 병렬로 실행 되는 코드
requestData1().then((data) => {
  console.log(data)
})
requestData2().then((data) => {
  console.log(data)
})

requestData1과 requestData2는 동시에 실행 된다. 여러 프로미스를 병렬로 처리하고 싶을 경우 Promise.all을 사용하자.

  • Promise.all 을 사용하는 코드
Promise.all([requestData1(), requestData2()]).then(([data1, data2]) => {
  console.log(data1, data2)
})

Promise.all 함수는 프로미스를 반환한다. Promise.all 함수가 반환하는 프로미스는 입력된 모든 프로미스가 처리됨 상태가 되어야 마찬가지로 처리됨 상태가 된다. 만약 하나가 거부됨 상태가 된다면, Promise.all 함수가 반환하는 프로미스도 거부됨 상태가 된다.

가장 빨리 처리된 프로미스 가져오기 : Promise.race

Promise.race 함수에 입력된 여러 프로미스 중에서 하나라도 처리됨 상태가 되면, Promise.race 함수가 반환하는 프로미스도 처리됨 상태가 된다.

  • Promise.race를 사용한 간단한 코드
Promise.race([
  requestData(),
  new Promise((_, reject) => setTimeout(reject, 3000)),
])
  .then((data) => console.log(data))
  .catch((error) => console.log(error))

requestData 함수가 3초안에 데이터를 받으면 then메서드가 호출되고 그렇지 않으면 catch 메서드가 호출 된다.

프로미스를 이용한 데이터 캐싱

처리됨 상태가 되면, 그 상태를 유지하는 프로미스의 성질을 이용해서 데이터를 캐싱할 수 있다.

  • 프로미스로 캐싱 기능 구현하기.
let cachedPromise
function getData() {
  cachedPromise = cachedPromise || requestData() // 1번
  return cachedPromise
}
getData().then((v) => console.log(v))
getData().then((v) => console.log(v))

1번 getData 함수를 처음 호출 할 때만, requestData가 호출된다. 데이터를 가져오는 작업이 끝나면, 그 결과는 cachedPromise 프로미스에 저장된다.

프로미스 사용 시 주의 할 점

  • return 키워드 깜빡하지 않기. then 메서드 내부 함수에서 return 키워드를 입력하는 것을 깜빡하기 쉽다. then 메서드가 반환하는 프로미스 객체의 데이터는 내부함수가 반환한 값이다. return 키워드를 사용하지 않으면 프로미스 객체의 데이터는 undefined가 된다.

  • return 키워드를 깜빡한 코드

Promise.resolve(10)
  .then((data) => {
    console.log(data)
    Promise.resolve(20) // 2번
  })
  .then((data) => {
    console.log(data) // 1번
  })

1번은 의도와는 다르게 undefined가 출력 된다. 2번 코드에서 return 키워드를 입력하면 의도한 대로 20이 출력 된다.

  • 프로미스는 불변 객체라는 사실 명심하기 프로미스는 불변 객체이다.

  • 프로미스가 수정된다고 생각하고 작성한 코드

function requestData() {
  const p = Promise.resolve(10)
  p.then(() => {
    //  1번
    return 20
  })
  return p
}
requestData().then((v) => {
  console.log(v) // 10 // 2번
})

1번 then 메서드는 기존 객체를 수정하지 않고, 새로운 프로미스를 반환한다. 2번, 코드에서 20이 출력되길 원한다면 requestData 함수를 다음과 같이 수정해야 한다.

  • then 메서드로 생성된 프로미스를 반환하는 코드.
function requestData() {
  return Promise.resolve(10).then((v) => {
    return 20
  })
}
  • 프로미스를 중첩해서 사용하지 않기 프로미스를 중첩해서 사용하면 콜백 헬과같이 프로미스 헬이 발생한다.

    requestData1().then(result1 => {
      requestData2(result2 => {
    		....
     });
    });
    

가독성이 좋지 않기 때문에, 아래 24번과 같이 바꾸자.

  • 중첩된 코드를 리팩터링 한 코드
requestData1()
  .then(result1 => {
	return requestData2(result1)
})
  .then(result2 => {
	return ... // 1번
})

만약 1번, 에서 result1 변수를 참조 해야 한다면 어떻게 해야 할까?

  • Promise.all 함수를 사용하면 프로미스를 중첩하지 않고도 해결할수 있다.

  • Promise.all을 사용해서 변수 참조 문제를 해결한 코드

requestData1()
 .then(result1 => {
  return Promise.all([result1, requestData2(result1)]) // 1번
  .then(([result1, result2]) => {
    ...........
});

1번, Promise.all 함수로 입력하는 배열에 프로미스가 아닌 값을 넣으면, 그 값을 그대로 이행됨 상태인 프로미스처럼 처리된다.

동기 코드의 예외 처리 신경 쓰기

프로미스를 동기(sync)코드와 같이 사용할 때는 예외 처리에 신경 써야 한다.

  • 동기 코드에서 발생한 예외가 처리되지 않는 코드.
function requestData() {
  doSync() // 1번
  return fetch()
    .then((data) => console.log(data))
    .catch((error) => console.log(error))
}

1번, doSync 함수가 반드시 fetch 전에 호출되어야 하는게 아니라면 다음과 같이 then 메서드 안에 넣어주는게 좋다.

  • 동기 코드도 예외처리가 되는 코드
function requestData() {
  return fetch()
    .then((data) => {
      doSync()
      console.log(data)
    })
    .catch((error) => console.log(Error))
}

doSync에서 발생하는 예외는 catch 메서드에서 처리가 된다.

향상된 비동기 프로그래밍 async, await

async await 이해하기

프로미스는 객체로 존재하지만, async await는 함수에 적용되는 개념이다.

프로미스를 반환하는 async await 함수

async function getData() {
  return 123 //Promise {<fulfilled>: 123}
}
getData().then((data) => console.log(data)) // 123 Promise {<fulfilled>: undefined}
  • 프로미스를 반환하는 async await 함수
async function getData() {
  return Promise.resolve(123)
}
getData().then((data) => console.log(data))

프로미스의 then 메서드와 마찬가지로 async await 함수 내부에서 반환하는 값이 프로미스라면 그 객체를 그대로 반환한다.

  • async await 함수에서 예외가 발생하는 경우.
async function getData() {
  throw new Error('123')
}
getData().catch((error) => console.log(error))
// Error : 123
  • await 키워드를 사용하는 방법
function requestData(value) {
  return new Promise((resolve) =>
    setTimeout(() => {
      console.log('requestData:', value)
      resolve(value)
    }, 100)
  )
}
async function getData() {
  const data1 = await requestData(10) //1번
  const data2 = await requestData(20) //1번
  console.log(data1, data2) // 2번
  return [data1, data2]
}
getData()
// requestData: 10 // 3번
// requestData: 20 // 3번
// 10, 20  // 3번

1번, requestData 함수가 반환하는 프로미스가 처리됨 상태가 될 때 까지 2번,의 코드는 실행되지 않는다. 따라서 getData 함수를 호출 한 결과는 3번,이다.

async 키워드는 오직 async await 함수 내에서만 사용할 수 있다. 일반함수에서 사용하면 에러가 발생한다.

  • await 키워드는 async 키워드 없이 사용 할 수 없다.
function getData(){
  const data = await requestData(10); // 에러 발생
  console.log(data);
}

async await는 프로미스보다 가독성이 좋다.

  • async await와 프로미스 비교하기
function getDataPromise() {
  asyncFunc1()
    .then((data) => {
      console.log(data)
      return asyncFunc2()
    })
    .then((data) => {
      console.log(data)
    })
} // 1번 프로미스로 작성한 코드
async function getDataAsync() {
  const data1 = await asyncFunc1()
  console.log(data1)
  const data2 = await asyncFunc2()
  console.log(data2)
} // 2번 async await로 작성한 코드

가독성이 async await로 작성 한 코드가 좋다. 간결한 이유는 async await 함수는 then 메서드를 호출할 필요가 없기 때문이다.

  • 의존성이 높은 코드에서 가독성 비교하기
function getDataPromise() {
  return asyncFunc1()
  .then(data1 => Promise.all([data1, asyncFunc2(data1)])) // 1번
  .then([data1, data2]) => {
    return asyncFunc3(data1,data2);
   });
}
async function getDataAsync() { // 2번
  const1 data1 = await asyncFunc1();
  const2 data2 = await asyncFunc2(data1);
  return asyncFunc3(data1, data2);
}

1번, 두 반환값을 asyncFunc3 함수에 전달하기 위해 Promise.all을 사용했다. 2번, async await함수는 복잡한 의존성이 존재함에도 코드가 직관적이다.

async await 활용하기

  • 비동기 함수를 병렬로 실행하기.
  • 순차적으로 실행되는 비동기 코드
async function getData() {
  const data1 = await asyncFunc1()
  const data2 = await asyncFunc2()
  // ...
}

두 함수사이에 의존성이 없다면, 동시에 실행하는게 더 좋다. 프로미스는 생성과 동시에 비동기 코드가 실행된다. 따라서, 두 개의 프로미스를 먼저 생성하고 await 키워드를 나중에 사용하면 병렬로 실행되는 코드가 된다.

  • await 키워드를 나중에 사용해서 병렬로 실행되는 비동기 코드
async function getData() {
  const p1 = asyncFunc1()
  const p2 = asyncFunc2()
  const data1 = await p1
  const data2 = await p2
}
  • Promise.all을 사용해서 병렬로 처리하기
async function getData() {
  const [data1, data2] = await Promise.all([asyncFunc1(), asyncFunc2()])
  // ....
}
  • 예외 처리하기 async await 함수 내부에서 발생하는 예외는 try catch 문으로 처리하는게 좋다.
  • 동기와 비동기 함수 모두 catch 문에서 처리된다.
async function getData() {
  try {
    await doAsync()
    return doSync()
  } catch (error) {
    console.log(error)
  }
}

비동기함수와 동기함수에서 발생하는 모든 예외가 catch문에서 처리된다.

  • Thenable을 지원하는 async await Thenable은 프로미스 처럼 동작하는 객체다.

  • async await 함수에서 Thenable을 사용한 예

class ThenableExample {
  then(resolve, reject) {
    // 1번
    setTimeout(() => resolve(123), 1000)
  }
}
async function asyncFunc() {
  const result = await new ThenableExaple() // 2번
  console.log(result) // 123
}

1번 ThenableExample 클래스는 then 메서드를 가지고 있으므로, ThenableExample 클래스로 생성된 객체는 Thenable이다. 2번 async await 함수는 Thenable도 프로미스 처럼 처리한다.

  • sync/await , promise 차이 promise : 코드 실행 중에 만나더라도 무시하고 다음 코드를 실행한다. async-await : await을 만났을 때 ,해당 코드가 끝날때까지(요청이 마무리 될 떄까지, pending이 아닐떄까지) 기다렸다 코드를 실행한다.
comments powered by Disqus