[React 정복기] ES6+ 향상된 비동기 프로그래밍 (실전 리액트 프로그래밍) - 3

예흠·2020년 12월 11일
1

React 정복기!

목록 보기
3/8

React를 정복해보자 💪

실전 리액트 프로그래밍 개정판 - 이재승 지음 (참고자료)

* 향상된 비동기 프로그래밍 1: 프로미스

프로미스(Promise)는 비동기 상태를 값으로 다룰 수 있는 객체다.
ES6에서는 프로미스가 자바스크립트 언어에 포함됐다.

프로미스를 사용하면 비동기 프로그래밍을 할 때 동기 프로그래밍 방식으로 코드를 작성할 수 있다.

1. 프로미스 이해하기

- 콜백 패턴의 문제

자바스크립트에서는 비동기 프로그래밍의 한 가지 방식으로 콜백 패턴을 많이 사용했었다. 하지만 콜백 패턴은 콜백이 조금만 중첩돼도 코드가 상당히 복잡해지는 단점이 있다.

function requestData1(callback) {
  //...
  callback(data);
}
function requestData2(callback) {
  //...
  callback(data);
}
function onSuccess1(data) {
  console.log(data);
  requestData2(onSuccess2);
}
function onSuccess2(data) {
  console.log(data);
  // ...
}
requestData1(onSuccess1);

이런식으로 콜백 패턴은 코드의 흐름이 순차적이지 않기 때문에 코드를 읽기가 상당히 힘들다.

하지만 프로미스를 사용하면 코드가 순차적으로 실행되게 작성할 수 있다.

requestData1()
.then(data => {
  console.log(data);
  return requestData2();
})
.then(data => {
  console.log(data);
  // ...
});

이런식으로 .then()을 이용해서 코드를 순차적으로 작성할 수 있다.

- 프로미스의 세 가지 상태

  • 대기 중(pending): 결과를 기다리는 중
  • 이행됨(fulfilled): 수행이 정상적으로 끝났고 결괏값을 가지고 있음
  • 거부됨(rejected): 수행이 비정상적으로 끝났음

이행됨, 거부 상태를 처리됨(settled) 상태라고 부른다. 프로미스는 처리됨 상태가 되면 더 이상 다른 상태로 변경되지 않는다.
=>대기 중 상태일 때만 이행됨 또는 거부됨 상태로 변할 수 있다.

- 프로미스를 생산하는 방법

프로미스는 아래의 세 가지 방식으로 생성할 수 있다.

const p1 = newwromise((resolve, reject) => {
  // ...
  // resolve(data)
  // or reject('error message')
});
const p2 = Promise.reject('error message');
const p3 = Promise.resolve(param);

일반적으로 new 키워드를 사용해서 프로미스를 생성한다. 이 방법으로 생성된 프로미스는 대기 중 상태가 된다. 생성자에 입력되는 함수는 resolvereject라는 콜백 함수를 매개변수로 갖는다. 비동기로 어떤 작업을 수행 후 성공했을 때 resolve를 호출하고, 실패했을 때 reject를 호출하면 된다.

  • resolve를 호출하면 이행됨 상태가 된다.
  • reject를 호출하면 거부됨 상태가 된다.

new 키워드를 사용하지 않고 Promise.reject를 호출하면 거부됨 상태인 프로미스가 생성된다.

Promise.resolve를 호출해도 프로미스가 생성된다. 만약 입력값이 프로미스였다면 그 객체가 그대로 반환되고, 프로미스가 아니라면 이행됨 상태인 프로미스가 반환된다.

- 프로미스 이용하기 1 : then

then은 처리됨 상태가 된 프로미스를 처리할 때 사용되는 메서드다. 프로미스가 처리됨 상태가 되면 then 메서드의 인수로 전달된 함수가 호출된다.

requestData().then(onResolve, onReject);
Promise.resolve(123).then(data => console.log(data)); // 123
Promise.reject('err').then( null, error => console.log(error)); // 에러 발생

프로미스가 처리됨 상태가 되면 onResolve 함수가 호출되고, 거부됨 상태가 되면 onReject함수가 호출된다. then 메서드는 항상 프로미스를 반환한다.
=> 하나의 프로미스로부터 연속적으로 then 메서드를 호출할 수 있다.

requestData1()
  .then(data => {
  	console.log(data);
  	return requestData2();
  })
  .then(data => {
  	return data + 1;
  })
  .then(data => {
  	throw new Error('some erre');
  })
  .then(null, error => {
  	console.log(error);
  });
  • onResolve 또는 onReject 함수에서 프로미스를 반환하면 then 메서드는 그 값을 그대로 반환한다.
  • 만약 프로미스가 아닌 값을 반환하면 then 메서드는 이행됨 상태인 프로미스를 반환한다.
  • onResolve 또는 onReject 함수 내부에서 예외가 발생하면 then 메서드는 거부됨 상태인 프로미스를 반환한다.
    => 결과적으로 then 메서드는 항상 프로미스를 반환한다.

프로미스가 거부됨 상태인 경우에는 onReject 함수가 존재하는 then을 만날때까지 이동한다.

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

거부됨 상태인 프로미스 이기 때문에 then 1,2는 생략되고 then 4가 출력된다. 그리고 undefined를 결과로 가진 이행됨 상태인 프로미스를 생성하여 이어지는 then 메서드에서 then 5가 출력된다.

then 메서드의 가장 중요한 특징은 항상 연결된 순서대로 호출된다는 점이다.
=> 비동기 프로그래밍을 할 때 동기 프로그래밍 방식으로 코드를 작성할 수 있다.

- 프로미스 이용하기 2 : catch

catch는 프로미스 수행 중 발생한 예외를 처리하는 메서드다. catch 메서드는 then 메서드의 onReject 함수와 같은 역할을 한다.

Promise.reject(1).then(null, error => {
  console.log(error);
});
Promise.reject(1).catch(error => {
  console.log(error);
});

예외 처리는 가독성면에서 catch를 사용하는게 좋다.

다음은 onReject 함수에서 예외를 처리할 때 발생하는 문제를 보여 준다.

Promise.resolve().then(
  () => {
    throw new Error('some error');
  },
  error => {
    console.log(error);
  },
);
// Unhandled promise rejection 에러 발생

then 메서드의 onResolve 함수에서 발생한 예외는 같은 then 메서드의 onReject 함수에서 처리되지 않는다.
=> 거부됨 상태인 프로미스를 처리하지 않았기 때문에 오류 발생

Promise.resolve()
  .then(() => {
  	throw new Error('some error');
  })
  .catch(error => {
  	console.log(error);
  });

이렇게 해결할 수 있다.

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 메서드는 .then(onFinally, onFinally) 코드와 유사하지만, 이전에 사용된 프로미스를 그대로 반환한다는 점이 다르다.
=> 처리됨 상태인 프로미스의 데이터를 건드리지 않고 추가 작업을 할 때 유용하게 사용될 수 있다.

//finally 메서드는 새로운 프로미스를 생성하지 않는다.
function requestData() {
  return fetch()
    .catch(error => {
      // ...
    })
    .finally(() => {
      sendLogToServer('requestData finished');
    });
}
requestData().then(data => console.log(data));

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

finally는 결국 마지막에 이행됐든, 거부됐든 상관없이 처리하는 것 이라고 생각하자.

2. 프로미스 활용하기

- 병렬로 처리하기 : Promise.all

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

아래의 예제를 보자.

requestData1()
  .then(data => {
    console.log(data);
    return requestData2();
  })
  .then(data => {
    console.log(data);
  });

비동기 함수 간에 의존성이 없다면 병렬로 처리하는 게 더 빠르다.

requestData1().then(data => console.log(data));
requestData2().then(data => console.log(data));

위 코드에서 두 함수는 동시에 실행된다. 이렇게 여러 프로미스를 병렬로 처리하고 싶은 경우에 다음과 같이 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();
  return cachedPromise;
}
getData().then(v => console.log(v));
getData().then(v => console.log(v));

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

3. 프로미스 사용 시 주의할 점

- return 키워드 깜빡하지 않기

then 메서드가 반환하는 프로미스 객체의 데이터는 내부 함수가 반환한 값이다. return 키워드를 사용하지 않으면 프로미스 객체의 데이터는 undefined가 된다.

Promise.resolve(10)
  .then(data => {
    console.log(data);
    Promise.resolve(20);
  })
  .then(data => {
    console.log(data)
  });

이려면 undefined가 출력이된다.
=> return 키워드를 입력하면 의도한 대로 20이 출력된다.

- 프로미스는 불변 객체라는 사실 명심하기

프로미스는 불변 객체인 것을 인지하지 못하고 코드를 작성하면 다음과 같은 실수를 할 수 있다.

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

then 메서드는 기존 객체를 수정하지 않고, 새로운 프로미스를 반환하기 때문에 10이 나오게 된다.

- 프로미스를 중첩해서 사용하지 않기

프로미스를 중첩해서 사용하면 콜백 패턴처럼 코드가 복잡해지므로 사용을 권장하지 않는다.

//중첩의 예
requestData1().then(result1 => {
  requestData2(result1).then(result2 => {
    //...
  });
});

//권장하는 프로미스의 예
requestData1()
  .then(result1 => {
    return requestData2(result1);
  })
  .then(result2 => {
    //...
  });

만약 두번째 then 에서 result1 변수를 참조해야 한다면 어떻게 해야 할까?
=> Promise.all 함수를 사용하면 프로미스를 중첩하지 않고도 다음과 같이 해결할 수 있다.

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

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

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

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

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

이렇게 되면 doSync 함수에서 발생하는 예외는 처리가 되지 않으므로 then 안에 넣어주는게 좋다.

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

async await는 비동기 프로그래밍을 동기 프로그래밍처럼 작성할 수 있도록 함수에 추가된 기능이다.

async await를 이용해서 비동기 코드를 작성하면 프로미스의 then 메서드를 체인 형식으로 호출하는 것보다 가독성이 좋아진다.

그렇다고 async await가 프로미스를 완전히 대체하는 것은 아니다. 프로미스는 비동기 상태를 값으로 다룰 수 있기 때문에 async await보다 큰 개념이다.

1. async await 이해하기

- async await함수는 프로미스를 반환한다.

프로미스는 객체로 존재하지만 async await는 함수에 적용되는 개념이다. 다음과 같이 async await 함수는 프로미슬르 반환한다.

async function getData() {
  return 123;
}
getData().then(data => console.log(data)); // 123
  • async 키워드를 이용해서 정의된 함수는 async await 함수이며, 항상 프로미스를 반환한다.
  • 함수 호출 후 then 메서드를 사용할 수 있다.

async await 함수 내부에서 프로미스를 반환하는 경우를 보자.

async function getData() {
  return Promise.resolve(123):
}
getData().then(data => console.log(data)); // 123

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

- await 키워드를 사용하는 방법

await 키워드는 async await 함수 내부에서 사용된다. await 키워드 오른쪽에 프로미슬르 입력하면 그 프로미스가 처리됨 상태가 될 때까지 기다린다.

따라서 await 키워드로 비동기 처리기다리면서 순차적으로 코드를 작성할 수 있다.

function requestData(value) {
  return new Promise(resolve =>
    setTimeout(()=> {
      console.log('requestData:', value);
      resolve(value);
    }, 100),
  );
}
async function getData() {
  const data1 = await requestData(10);
  const data2 = await requestData(20);
  console.log(data1, data2);
  return [data1, data2];
}
getData();
// requestData: 10
// requestData: 20
// 10 20

await 키워드는 오직 async await 함수 내에서만 사용될 수 있다.

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

async await와 프로미스는 비동기 프로그래밍을 동기 프로그래밍 방식으로 작성할 수 있게 해준다. 다음 코드는 async await와 프로미스를 비교하기 위해 같은 기능을 각각의 방식으로 구현한 것이다.

function getDataPromise() {
  asyncFunc1()
    .then(data => {
      console.log(data);
      return asyncFunc2();
  	})
    .then(data => {
      console.log(data);
  	});
}
async function getDataAsync() {
  const data1 = await asyncFunc1();
  console.log(data1);
  const data2 = await asyncFunc2();
  console.log(data2);
}

async await 함수는 then 메서드를 호출할 필요가 없기 때문에 더 간결하다.
비동기 함수 간에 의존성이 높아질수록 async await와 프로미스의 가독성 차이는 더 선명하게 드러난다.

function getDataPromise() {
  return asyncFunc1()
  .then(data => Promise.all([data1, asyncFunc2(data1)]))
  .then(([data1, data2]) => {
    return asyncFunc3(data1, data2);
  });
}
async function getDataAsync() {
  const data1 = await asyncFunc1();
  const data2 = await asyncFunc2(data1);
  return asyncFunc3(data1, data2);
}

async await 함수는 복잡한 의존성이 존재함에도 코드가 직관적이고 가독성이 좋다.

2. async await 활용하기

- 비동기 함수를 병렬로 실행하기

다음과 같이 여러 비동기 함수에 각각 await 키워드를 사용해서 호출하면 순차적으로 실행된다.

async function getData() {
  const data1 = await asyncFunc1();
  const data2 = await asyncFunc2();
  // ...
}

앞의 코드에서 두 함수 사이에 의존성이 없다면 동시에 실행하는 게 더 좋다.

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

async function getData() {
  const p1 = asyncFunc1();
  const p2 = asyncFunc2();
  const data1 = await p1;
  const data2 = await p2;
  // ...
}

두 개의 프로미스가 생성되고 각자의 비동기 코드가 실행된다. 두 프로미스가 생성된 후 기다리기 때문에 두 개의 비동기 함수가 병렬로 처리된다.

Promise.all과 async await를 함께 사용하면 다음과 같이 간단해 진다.

async function getData() {
  const [data1, data2] = await Promise.all([asyncFunc1(), asyncFunc2()]);
  // ...
}

- 예외 처리하기

async await 함수 내부에서 발생하는 예외는 다음과 같이 try catch 문으로 처리하는게 좋다.

async function getData() {
  try {
    await doAsync();
    return doSync();
  } catch (error) {
    console.log(error)
  }
}

만약 getData가 async await 함수가 아니었다면 doAsync 함수에서 발생하는 예외는 catch 문에서 처리되지 않는다. 왜냐하면 doAsync 함수의 처리가 끝나는 시점을 알 수 없기 때문이다.

- Thenable을 지원하는 async await

Thenable은 프로미스처럼 동작하는 객체다.
async await는 ES6의 프로미스가 아니더라도 then 메서드를 가진 객체를 프로미스처럼 취급한다.
=> 이렇게 프로미스가 아니더라도 then 메서드를 가진 객체를 Thenable이라고 부른다.

profile
노래하는 개발자입니다.

0개의 댓글