[모던JS: Core] 비동기 - 프라미스(Promise) 및 async/await (2)

KG·2021년 5월 27일
0

모던JS

목록 보기
20/47
post-thumbnail

Intro

본 포스팅은 여기에 올라온 게시글을 바탕으로 작성되었습니다.
파트와 카테고리 동일한 순서로 모든 내용을 소개하는 것이 아닌, 몰랐거나 새로운 내용 위주로 다시 정리하여 개인공부 목적으로 작성합니다.
중간중간 개인 판단 하에 필요하다고 생각될 시, 기존 내용에 추가로 보충되는 내용이 있을 수 있습니다.

프라미스 체이닝

앞서 프라미스는 then을 콜백과 달리 계속해서 호출할 수 있다고 했다. 또한 콜백의 주요 문제점중에 하나는 중첩 콜백 방식은 자칫 콜백지옥으로 이어질 수 있다는 점이다.

만약 앞서 살펴본 중첩 콜백과 같이 순차적으로 스크립트를 계속해서 불러와야 하는 비동기 작업이 있을때 프라미스를 사용하여 여러 해결책을 만들 수 있다. 가장 대표적인 방식이 프라미스 체이닝 방식이다.

1) 프라미스 체이닝

프라미스 체이닝은 다음과 같이 생겼다.

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

프라미스 체이닝은 result.then 핸들러의 체인을 통해 계속 전달된다는 점에서 착안한 아이디어이다. 읽는데 크게 무리가 없지만 순서를 나타내면다음고 ㅏ같다.

  1. 1초후 최초의 프라미스가 이행된다.
  2. 이후 첫 번째 .then 핸들러가 호출된다.
  3. 위에서 반환한 값은 다음 .then 핸들러에 전달된다.
  4. 이러한 과정을 마지막 .then에 도달할 때 까지 반복한다.

이때 .then 핸들러 안에서 result라는 인수를 똑같이 사용하고 있지만 이는 모두 다른 값을 가지는 것에 주의하자. 이는 단순히 상위의 핸들러에서 처리된 결과값을 전달받는 인수일 뿐, 그 값이 모두 동일하다는 것을 의미하는 것이 아니다. 출력 결과 역시 1, 2, 4와 같이 순차적으로 각각 다른 것을 볼 수 있다.

프라미스 체이닝이 가능한 이유는 promise.then을 호출하면 그 결과로 프라미스가 반환되기 때문이다. 반환값이 프라미스이기 때문에 당연히 연속해서 then 메서드를 호출할 수 있다. 위의 예시에서는 핸들러가 값(result)를 반환하고 있다. 이처럼 핸들러가 값을 반환하는 경우에는 이 값이 프라미스의 result가 된다. 따라서 다음 then은 이 값을 이용해 호출된다.

2) 프라미스 반환하기

then(handler)에 사용된 핸들러가 프라미스를 생성하거나 반환하는 경우도 가능하다. 이땐 이어지는 핸들러가 상위 프라미스가 처리될 때까지 기다리다가 처리가 완료되면 그 결과를 받아 자신의 동작을 수행한다.

new Promise((resolve, reject) => {
  setTimeout(() => resolve(1), 1000);
}).then(result => {
  console.log(result);	// 1
  return new Promise((resolve, reject) => {
    setTimeout(() => resolve(result*2), 1000);
  });
}).then(result => {
  console.log(result);	// 2
  return new Promise((resolve, reject) => {
    setTimeout(() => resolve(result*2), 1000);
  });
}).then(result => {
  console.log(reulst);	// 4
});

위의 예시와 출력 결과는 동일하지만, 이번에는 반환값이 1초 뒤에 결과값을 이행하는 프라미스 객체이다. 따라서 각 출력값은 모두 1초씩 대기후에 각각의 출력값을 출력하게 된다. 이처럼 핸들러 안에서 프라미스를 반환하는 것 역시 비동기 작업 체이닝을 가능케한다.

3) loadScript 예시 개선

앞서 구현한 loadScript 함수를 프라미스 체이닝을 사용해 개선해보자.

loadScript(url1)
  .then(script => return loadScript(url2))
  .then(script => return loadScript(url3))
  .then(script => return loadScript(url4))
  .then(script => {
    // 이 영역은 4개의 url로 부터 모든 로드가 완료된 시점이다
    one();
    two();
    three();
    four();
  });

loadScript를 호출할 때마다 프라미스가 반환되고, 다음 .then은 이 프라미스가 이행되었을 때 실행된다. 이후에 다음 스크립트를 로딩하기 위한 초기화가 진행되고, 스크립트는 이러한 과정을 거쳐 순차적으로 로드된다.

이처럼 체인을 형성하면 콜백지옥과는 달리 코드가 오른쪽으로 길어지지 않고 아래로만 증가하기 때문에 멸망의 피라미드 구조가 형성되지 않는다.

물론 then 메서드를 이용해서도 이런 중첩 구조로 사용은 할 수 있다. 그러나 앞서 말했듯이 이러한 중첩 구조는 depth를 계속 깊게 만들기 때문에 좋은 구조가 아니다.

// 이러한 패턴은 가급적 지양하자
loadScript(url1).then(script1 => {
  loadScript(url2).then(script2 => {
    loadScript(url3).then(script3 => {
      loadScript(url4).then(script4 => {
        one();
        two();
        three();
        four();
      });
    });
  });
});

4) thenable

핸들러는 프라미스가 아닌 thenable이라고 불리는 객체를 반환할 수도 있다. then이라는 메서드를 가진 객체는 모두 thenable 객체로 취급할 수 있다. 이러한 thenable 객체는 프라미스와 같은 방식으로 처리된다.

thenable 객체에 대한 아이디어는 서드파티 라이브러리가 프라미스와 호환 가능한 자체 객체를 구현할 수 있다는 점에서 착안되었다. 이 객체들에는 자체 확장 메서드가 구현되어 있겠지만, then 메서드가 있기 때문에 네이티브 프라미스와도 내부적으로 호환이 가능하다.

class Thenable {
  constructor(num) {
    this.num = num;
  }
  
  // then 메서드의 인수 resolve, reject는
  // 자바스크립트 엔진에서 지원하는 네이티브 코드로 인식한다
  then(resolve, reject) {
    console.log(resolve);
    
    setTimeout(() => resolve(this.num * 2), 1000);
  }
}

new Promise(resolve => resolve(1))
  .then(result => {
    // 반환값은 프라미스 객체가 아니지만
    // thenable 객체 역시 프라미스와 동일하게 처리된다
    return new Thenable(result);
  })
  .then(console.log);	// 1초후 2 출력

이런 식으로 구현하면 Promise를 클래스에 별도로 상속할 필요 없이 커스텀 객체를 사용해 프라미스 체이닝을 형성할 수 있다는 장점이 있다.

5) fetch와 프라미스 체이닝 응용

네트워크 요청 시 fetch(url) 메서드를 많이 사용한다. 이 역시 프라미스를 지원하는 비동기 요청인데 fetch(url)이 호출되면 url로 네트워크 요청을 보내고 반환값으로 프라미스를 반환한다.

원격 서버가 헤더와 함께 응답을 보내면, 프라미스는 response 객체와 함께 이행된다. 이때 response 전체가 완전히 다운로드되기 전에 프라미스는 이행 상태가 되어버린다.

응답이 완전히 종료되고, 응답 전체를 읽으려면 메서드 response.json()을 호출하자. response.json() 메서드는 원격 서버에서 전송한 데이터 전체가 다운로드 되면, 이를 json 형태로 파싱한 값을 result로 갖는 이행된 프라미스를 반환한다.

// 원격서버가 fetch에 응답하면 then 아래 코드가 실행
fetch('/article/promise-chaining/user.json')
  // 응답받은 response를 모두 다운받은 후 json 파싱
  .then(response => response.json())
  // 파싱된 결과값에 접근 가능
  .then(user => console.log(user.name));

이를 통해 깃허브에 요청을 보내 사용자 프로필을 불러오고 아바타를 출력하는 요청을 계속 체이닝을 통해 구현해보자.

fetch('/article/promise-chaining/user.json')
  .then(response => response.json())
  .then(user => fetch(`https://api.github.com/users/${user.name}`))
  .then(response => response.json())
  .then(githubUser => {
    let img = document.createElement('img');
    img.src = githubUser.avatar_url;
    img.className = 'avatar';
    document.body.append(img);
  
    setTimeout(() => img.remove(), 3000);
  });

깃허브로 부터 프로필 정보를 받아오고, 이를 화면에 추가한 뒤 3초가 지나면 이를 다시 화면에서 지우게된다. 이와 같이 동작은 잘 하지만 위 코드엔 프라미스를 다루는데 서툰 개발자가 흔히 하는 잠재적 문제가 내재되어 있다.

img.remove()를 처리하는 부분을 살펴보자. 만약 아바타가 잠깐 보였다가 사라진 이후에 다시 무언가를 하고 싶다면 어떻게 해야할까? 지금 코드로는 방법이 없다. 반환하는 결과값이 존재하지 않기 때문이다. 따라서 체인을 확장 가능한 상태로 만들기 위해서는 아바타가 사라질 때 이행 프라미스가 반환되도록 구현해야 한다.

fetch('/article/promise-chaining/user.json')
  .then(response => response.json())
  .then(user => fetch(`https://api.github.com/users/${user.name}`))
  .then(response => response.json())
  .then(githubUser => new Promise((resovle, reject) => {
    let img = document.createElement('img');
    img.src = githubUser.avatar_url;
    img.className = 'avatar';
    document.body.append(img);
  
    setTimeout(() => {
      img.remove();
      resolve(githubUser);
    }, 3000);
  }))
  .then(githubUser => console.log(githubUser.name));

이제는 setTimeout에서 resolve를 통해 이행된 상태의 프라미스를 반환하기 때문에 then을 통해 프라미스 체이닝을 확장할 수 있다. 이처럼 비동기 동작은 항상 프라미스를 반환하도록 하는 것이 좋다. 지금 당장은 로직이 마무리되고 체인이 끝나더라도 추후에 기존 작업에 이어서 체인이 확장될 가능성이 존재하기 때문이다.

조금 더 좋은 가독성을 위해서는 위에서 구현한 코드를 재사용 가능한 함수 단위로 분할하는 것이 좋다.

function loadJson(url) {
  return fetch(url).then(response => response.json());
}

function loadGithubUser(name) {
  return fetch(`https://api.github.com/users/${name}`)
    .then(response => response.json());
}

function showAvatar(githubUser) {
  return new Promise((resolve, reject) => {
    let img = document.createElement('img');
    img.src = githubUser.avatar_url;
    img.className = 'avatar';
    document.body.append(img);
    
    setTimeout(() => {
      img.remove();
      resolve(githubUser);
    }, 3000);
  });
}

loadJson('/article/promise-chaining/user.json')
  .then(user => loadGithubUser(user.name))
  .then(showAvatar)
  .then(githubUser => console.log(githubUser.name));

프라미스 체이닝의 흐름을 나타내면 다음과 같다.

프라미스와 에러 핸들링

프라미스가 거부되면 제어 흐름이 제일 가까운 rejection 핸들러로 넘어가기 때문에, 프라미스 체인을 사용하면 에러를 쉽게 처리할 수 있다.

다음과 같이 존재하지 않는 urlfetch 함수를 호출하게 되면 에러가 발생하는데, 이때 발생한 에러를 catch를 통해 잡을 수 있다.

fetch('wrong-url')	// 잘못된 주소
  .then(response => response.json())	// 무시
  .catch(err => console.log(err));	// 실행

이처럼 catch 메서드는 첫번째 핸들러일 필요가 없고 하나 혹은 여러 개의 then 뒤에 위치할 수 있다. 여러개의 then 어디에서라도 에러가 발생하더라도 그 이후에 위치한 catch 메서드에서 이를 감지하고 잡아낼 수 있다.

1) 암시적 try...catch

프라미스 executor와 프라미스 핸들러 코드 주위에는 보이지 않는 암시적 try...catch 블록이 형성되어 있다. 예외가 발생하면 암시적 try...catch에서 예외를 잡아내고 이를 reject 처럼 다룬다.

new Promise((resolve, reject) => {
  throw new Error('Error!');
}).catch(console.log);

위 예시는 아래 예시와 완전히 동일하게 동작한다.

new Promise((resolve, reject) => {
  reject(new Error('Error'));
}).catch(console.log);

executor 주의의 암시적 try...catch는 스스로 에러를 잡고, 에러를 거부상태(rejected)의 프라미스로 변경시킨다.

이러한 동작은 executor뿐만 아니라 핸들러에서도 동일하게 작용한다. then 핸들러 안에서도 throw를 통해 에러를 던지면, 이 자체가 거부된 프라미스를 의미하게 된다. 따라서 제어 흐름에 가장 까자운 에러 핸들러로 넘어가게 된다.

이는 비단 throw문이 만든 에러에만 국한되는 것이 아니라 모든 종류의 에러가 암시적 try...catch에서 처리된다.

new Promise((resolve, reject) => {
  resolve('ok');
}).then(result => {
  blabla();	// SyntaxError: 거부된 프라미스 반환
}).then(result => {
  throw new Error('error'); // 직접 에러 생성: 거부된 프라미스 반환
}).catch(err => console.log(err));

2) 다시 던지기

체인 마지막의 catchtry...catch와 유사한 역할을 한다. 따라서 앞에 언급한 바와 같이 then 핸들러를 원하는 만큼 사용하다 마지막에 catch 하나만 붙이더라도 then 핸들러에서 발생하는 모든 에러를 처리할 수 있다.

catch 안에서 throw를 사용하면 제어 흐름이 가장 가까운 곳에 있는 에러 핸들러로 넘어간다. 여기서 에러가 성공적으로 처리되면 가장 가까운 곳에 있는 then 핸들러로 다시 제어 흐름이 넘어가 실행이 이어지게 된다.

new Promise((resolve, reject) => {
  throw new Error('Error!');
}).catch(error => {
  console.log(error + ' 처리 완료!');
}).then(() => {
  console.log('another handler');
});

위에서 catch 블록이 처음 발생한 에러를 정상적으로 잡아내고 성공적으로 처리한 뒤 종료되었기 때문에, 다음 핸들러 then 역시 정상적으로 이어서 호출되게 된다.

앞서 에러 핸들링 챕터에서 자신이 처리할 수 없는 예외는 다시 던지기를 통해 외부에서 해결하도록 하는 기법을 살펴보았다. 프라미스에서도 이와 유사한 일을 할 수 있다.

new Promise((resolve, reject) => {
  throw new Error('Error!');
}).catch(error => {
  if (error instanceof URIError) {
    // 에러 핸들링
  } else {
    console.log('처리할 수 없는 예외: 다시던지기');
    throw error;
  }
}).then(() => {
  // 이 영역은 상위 error가 URIError 인스턴스가 아니라면
  // 실행되지 않는 영역이다
}).catch(error => {
  console.log('알 수 없는 에러 발생 : ', error);
});

3) 처리되지 못한 거부

에러를 처리하지 못하면 스크립트에 어떤 일이 발생할까? 대표적으로 다음과 같이 에러가 생겼는데 catch가 생략되는 경우가 있을 것이다.

new Promise(resolve => {
  throw new Error('error!');
}).then(() => console.log('what happen?'));

예외가 발생하면 프라미스는 거부 상태가 되고, 실행 흐름은 가장 가까운 rejection 핸들러로 넘어가는 것을 앞서 살펴보았다. 그러나 위 예시 같은 경우에는 이를 처리해 줄 rejection 핸들러가 없기 때문에 에러가 갇혀버리게 된다.

이처럼 처리되지 못한 에러가 잔존해 있다는 건 실무에서 썩 반가운 일이 아니다. 일반적인 에러가 발생하고 이를 try...catch에서 잡아내지 못하는 경우만 생각해도, 스크립트가 죽는다는 것을 쉽게 알 수 있다. 프라미스에서도 거부된 프라미스를 처리하지 못하면 이와 유사한 일이 발생한다.

자바스크립트 엔진은 프라미스 거부를 추적하다가 위와 같은 상황이 발생하면 전역 에러를 생성하게 된다. 브라우저 환경에서는 이러한 전역 에러를 unhandledrejection 이벤트로 캐치할 수 있다.

window.addEventListener('unhandledrejection', event => {
  alert(event.promise);	// [object Promise] - 에러 생성 프라미스
  alert(event.reason);	// Error: 에러 발생! - 처리하지 못한 에러 객체
});

new Promise(function() {
  throw new Error("에러 발생!");
}); // 에러 처리 핸들러, catch가 없음

unhandledrejection 이벤트는 HTML명세서에 정의된 표준 이벤트이다. 브라우저 환경에서 에러가 발생했는데 catch가 없다면 브라우저가 이를 감지하고 unhandledrejection 핸들러가 트리거된다. 해당 핸들러는 에러 정보가 담긴 event 객체를 받기 때문에 이 안에서 원하는 작업을 처리할 수 있다.

대개 이러한 에러는 회복할 수 없기 때문에 개발자로서 할 수 있는 최선의 방법은 사용자에게 문제 상황을 알리고, 가능하다면 서버에 에러 정보를 보내 관련 에러 상황을 파악하는 것이다. 이 같은 흐름에 unhandledrejection 이벤트를 활용할 수 있다.

Node.js 호스트 환경에서도 이와 유사한 역할을 하는 여러 가지 방법을 제공한다.

References

  1. https://ko.javascript.info/async
profile
개발잘하고싶다

0개의 댓글