자바스크립트/모던 자바스크립트

모던 자바스크립트, 프라미스 1 - 기본 개념

Jake Seo 2023. 2. 15. 23:49

프라미스 (Promise) 란?

  • ES2015 에서 생겼다.
  • 비동기 결과를 나타내는 객체이다.
  • 작업을 비동기화하진 않으며, 비동기식의 결과를 관찰하는 방법이다.
  • 여러 언어에서 promise, future, deferred 라는 다양한 이름으로 불리는 패턴이다.
  • Promises/A+ 사양에 크게 의존한다.
  • ES2018 에서 나온 async 와 상호작용하는 방식으로 많이 쓰인다.

Promise 라는 이름은 엔진이 어떠한 비동기 작업을 하겠다고 약속한다는 의미에서 지어졌다고 한다.

프라미스를 사용하는 이유

프라미스는 자바스크립트에서 지원하는 대수타입의 모나드이며, 비동기 작업에서 서로간의 제어를 분리할 수 있도록 도와주는 역할을 한다.
비동기 작업의 제어권은 자바스크립트 엔진의 비동기 큐에게 맡기고, 비동기 작업 이후의 제어는 다시 사용자가 작성한 코드로 넘어온다.

  • 콜백 지옥의 해결을 돕는다.
    • async 키워드와 함께 썼을 때 더 효과적이다.
    • 코드의 가독성을 매우 좋게 만들어준다.
  • 순차적 작업이나 병렬 작업을 명확히 나타내줄 수 있다.
    • async await 을 통해 손쉽게 순차 작업을 진행한다.
    • 병렬 작업은 Promise.all() 을 이용하여 진행한다.
  • 더 나은 에러 핸들링을 제공한다.
    • 일반 콜백 함수에서는 성공/실패를 나타내는 표준 방법이 없어 복잡성을 관리할 수 없었다.
    • 프라미스는 비동기의 작업 결과를 '관리 가능하게 (managable)' 만들어준다.
  • 작업에 여러 콜백을 추가할 수도 있다.
  • 이미 완료된 프로세스에 콜백을 추가하는 것도 이전까지 표준이 없었다.
    • 비동기 요청의 경우엔 외부 라이브러리인 제이쿼리의 ajax 를 이용해왔다.

프라미스와 thenable

  • 자바스크립트 프라미스는 Promises/A+ 사양을 완전히 준수한다.
    • 이 사양의 특징은 Promise 와 구별되는 thenable 개념이다.
    • 이 사양에서 Promisethen() 메서드를 사용하는 객체 혹은 함수(thenable) 라고 본다.
    • Promisethenable 이지만, 모든 thenablePromise 는 아니다.
    • 객체의 then() 메서드가 있다면, Promise 와 관련이 없더라도 thenable 이다.

Promise 에서 메서드 체인으로 연결되는 then() 은 JS에서 마이크로 태스크로 취급되어 처리된다.

프라미스 기본 예제 코드

const example = () => {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      try {
        const succeed = Math.random() < 0.5;

        if (succeed) {
          console.log("resolve 42");
          resolve(42);
          return;
        }

        console.log("new Error('failed')");
        throw new Error("failed");
      } catch (e) {
        reject(e);
      }
    }, 100);
  });
};

example()
  .then((value) => {
    console.log("resolved value: ", value);
  })
  .catch((error) => {
    console.error("rejected error: ", error);
  })
  .finally(() => {
    console.log("finally");
  });
  • Promise 생성자에 전달되는 함수는 실행자 함수 (executor function) 라는 이름을 가지고 있다.
  • 실행자 함수는 콜백의 인자로 받은 resolve()reject() 를 상황에 맞게 동기적으로 호출한다.
    • resolve() 의 인자로 다른 Promise 를 주면, 결과가 다른 Promise 로 확정된다.
    • reject() 를 호출하면, reject() 로 전달된 사유로 Promise 가 실패한다.
      • resolve() 와 달리 인자로 Promise 를 줘도 reject() 의 사유로만 사용된다.
  • Promise 는 비동기 작업을 생성하는 것이 아니라 작업을 관찰한다.
    • 위의 예에서는 setTimeout() 이라는 비동기 작업을 관찰하고 있다.

프라미스의 3가지 상태

  • 대기 (pending): 비동기 작업의 결과가 나오기 전 초기 상태이다.
    • 프라미스가 아직 확정되지 않은 상태.
  • 이행 (fulfilled): 비동기 작업이 성공적으로 마무리 되었음을 나타낸다.
    • 프라미스가 값으로 정해진 상태.
    • 프라미스가 "resolved" 된 상태라고도 한다.
  • 거부 (rejected): 비동기 작업이 실패했음을 의미한다.
    • 프라미스가 "rejected" 된 상태라고도 한다.

프라미스 상태의 특징

  • 초기엔 대기 (pending) 상태이다.
  • 단 한번만, 이행 (fulfilled) 혹은 거부 (rejected) 로 변화할 수 있다.
    • 일단 한번 변화하면 다시 돌아가거나 다른 상태로 변하는 것은 불가능하다.

프라미스의 3가지 핸들러

  • Promisethen(), catch(), finally() 3가지 핸들러가 있다.

Promise.prototype.then

  • 성공(resolved) 시 호출되는 핸들러이다.
  • catch 를 작성하지 않고, 여기에 인자로 성공과 예외 시 동작할 두 개의 콜백 함수를 받을 수도 있다.
    • 첫번째 인자의 이름은 onFulfilled 이고, 두번째 인자의 이름은 onRejected 이다.
const example = () => {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      resolve("success");
    }, 100);
  });
};

example().then((result) =>
  console.log(`then handler works. result: ${result}`)
);
  • 결과는 then handler works. result: success 이 된다.

Promise.prototype.catch

  • 실패 (rejected) 시 호출되는 핸들러이다.
const example = () => {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      reject("failed");
    }, 100);
  });
};

example()
  .then((result) => console.log(`then handler works. result: ${result}`))
  .catch((result) => console.log(`catch handler works. result: ${result}`));
  • 결과는 catch handler works. result: failed 이 된다.

Promise.prototype.finally

  • 성공과 실패 상관없이 프라미스가 확정되면 호출되는 핸들러이다.
  • ES2018 에 추가되었다.
  • thencatch 모두에 들어갈 내용을 여기에 넣으면 공통으로 들어간다.
const example = () => {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      reject("failed");
    }, 100);
  });
};

example()
  .then((result) => console.log(`then handler works. result: ${result}`))
  .catch((result) => console.log(`catch handler works. result: ${result}`))
  .finally(() => console.log(`finally handler works.`));
  • 결과는 catch handler works. result: failed 후에 finally handler works. 가 된다.
doAsyncJob()
  .then((result) => alert(`the result: ${result}`))
  .catch((result) => alert(`it failed reason is: ${result}`))
  .finally(() => hideLoading());
  • 위와 같이 비동기 작업을 할 때 마지막에 로딩을 숨기는 등의 공통 작업을 finally 로 옮길 수 있다.

핸들러와 메서드 체인

  • 보통 핸들러는 위의 예제코드에서도 보았듯 메서드 체인 형식으로 이용한다.
  • 이는 모든 메서드가 즉시 Promise 객체를 즉시 반환하기 때문이다.

메서드 체인 살펴보기

  • 메서드 체인은 보통 then(), catch(), finally() 로 구성되지만, then() 이 여러번 와도 상관 없다.
const example = () => {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      resolve(100);
    }, 100);
  });
};

example()
  .then((v) => v + 100)
  .then((v) => v + 100)
  .then((v) => v + 100)
  .then((v) => console.log(v)); // 400
  • then() 만 3번 사용해본 예제이다.
  • 만일 기능별 함수 분리가 잘 되어있다면, 체인으로 이어지는 가독성 좋은 코드 작성이 가능하다.
const example = () => {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      resolve(100);
    }, 100);
  });
};

const plus100 = (v) => v + 100;
const multiply100 = (v) => v * 100;
const log = (v) => console.log(v);

example().then(plus100).then(multiply100).then(log); // 20000
  • 보기엔 별 것 아닌것처럼 보여도 잘 추상화된 콜백 함수를 녹여내면 가독성 좋은 코드가 된다.

핸들러 사용 시 주의점

  • 이름과 사용 방법이 꽤 직관적인 핸들러이지만, 가끔 혼동을 일으키기도 한다.

throwcatch

try {
  throw new Error("에러!!!");
} catch (error) {
  console.log("에러가 발생했습니다. 에러: " + error);
}
  • 기존의 try/catch 구문을 이용하여 에러 처리를 작성해보았다.
  • 결과는 에러가 발생했습니다. 에러: Error: 에러!!! 라는 로그를 콘솔에 남긴다.
const example = () => {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      throw new Error("ERROR!!!!!");
    }, 100);
  });
};

example()
  .then((result) => console.log(`then handler works. result: ${result}`))
  .catch((result) => console.log(`catch handler works. result: ${result}`));
  • 위의 코드 결과는 어떻게 될까?
    • 정답은 Uncaught Error: ERROR!!!!! 라는 에러 로그만 띄운다.
  • 헷갈리면 안되는 게 try/catch 처럼 에러를 잡는 것이 아니라 reject() 메서드로 넘어온 값을 catch 로 처리할 뿐이다.

의도치 않게 에러 가리기

const example = () => {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      const person = {
        name: "jake",
      };

      resolve(person);
    }, 100);
  });
};

example()
  .catch((result) => console.log(`catch handler works. result: ${result}`))
  .then((result) => console.log(`the name is ${result.name}`));

/*
the name is jake
*/
  • 위와 같이 코드를 작성하였다.
  • 정상적인 경우에는 별 탈 없이 잘 동작하는 코드이다.
const example = () => {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      const person = {
        name: "jake",
      };

      reject("ERROR");
    }, 100);
  });
};

example()
  .catch((result) => console.log(`catch handler works. result: ${result}`))
  .then((result) => console.log(`the name is ${result.name}`));

/*
catch handler works. result: ERROR
Uncaught (in promise) TypeError: Cannot read properties of undefined (reading 'name')
    at <anonymous>:15:55
*/
  • 하지만 이렇게 reject() 메서드가 동작하는 경우에는 then() 메서드의 역할이 달라진다.
  • catch() 메서드 가 반환하는 값을 받아 그것을 처리하는 then() 메서드가 되어버린다.
  • 위의 경우엔 코드가 간단하고 일부러 이 상황을 의도했기에 알아보기 쉽지만, 실무에서 이러한 상황이라면 생각보다 알아보기 어려울 수 있다.

try/catch/finally 와 비교하기

try {
  // ...
} catch (error) {
  // ...
} finally {
  // ...
}
  • then/catch/finallytry/catch/finally 구문과 흡사하다.
  • 사용하다보면 엄청난 차이가 있다는 것을 깨닫는다.
    • try/catch/finally 가 중첩될 수 밖에 없는 구문을 생각해보자.
try {
  try {
    try {
      // ...
    } catch (error) {
      console.error(error);
    } finally {
      // ...
    }
  } catch (error) {
    console.error(error);
  } finally {
    // ...
  }
} catch (error) {
  console.error(error);
} finally {
  // ...
}
  • 위 코드를 Promise 로 변경하면 어떻게 될까?
someAsyncJob()
  .then(...)
  .then(...)
  .then(...)
  .catch(...)
  .finally(...);
  • 훨씬 알아보기 쉬운 코드를 작성할 수 있다..

finally() 에서 값을 반환하기

const example = () =>
  new Promise((resolve, reject) => {
    setTimeout(resolve, 500, 100);
  });

example()
  .then((result) => console.log(result))
  .finally(() => {
    return 50000;
  })
  .then((result) => console.log(result)); // undefined
  • finally() 콜백에서 반환된 50000 은 아무런 의미를 갖지 못한다.
  • finally() 에서 값을 반환하는 것은 early return 이외의 의미는 없다.
  • finally() 의 주 사용 사례는 성공/실패 여부에 상관 없이 리소스를 정리하는 것이다.

then() 속 두 개의 콜백 파라미터

  • 첫번째 자리엔 onResolved 에 대한 콜백 함수 작성이 가능하다.
  • 두번째 자리엔 onRejectd 에 대한 콜백 함수 작성이 가능하다.
  • 여기서 오해하기 쉬운 부분이 "그럼 catch() 메서드를 작성하지 않고 onRejected 자리에 콜백을 넣으면 같은 동작을 하겠지?" 생각하는 것이다.
    • 그러나 실제로 둘의 동작은 다르다.
const example = () =>
  new Promise((resolve, reject) => {
    setTimeout(resolve, 500, 100);
  });

example().then(
  (onResolvedResult) => {
    throw new Error("ERROR!!!");
    console.log(`onResolvedResult: ${onResolvedResult}`);
  },
  (onRejectedResult) => {
    console.log(`onRejectedResult: ${onRejectedResult}`);
  }
);

/*
Uncaught (in promise) Error: ERROR!!!
*/
  • then() 의 두번째 인자로 reject 시에 동작할 핸들러를 작성하면, then() 의 첫번째 인자 핸들러에서 에러가 던져진 경우를 처리하는 것이 불가능하다.
  • 오직 최초의 Promise 객체 내부 블록에서 reject() 가 호출된 경우에만 처리가 가능하다.
const example = () =>
  new Promise((resolve, reject) => {
    setTimeout(resolve, 500, 100);
  });

example()
  .then((onResolvedResult) => {
    console.log(`onResolvedResult: ${onResolvedResult}`);
  })
  .then(() => {
    throw new Error("ERROR!!!");
  })
  .catch((onCatchResult) => {
    console.log(`onCatchResult: ${onCatchResult}`);
  });

/*
onResolvedResult: 100
onCatchResult: Error: ERROR!!!
*/
  • then() 의 두번째 인자 대신에 catch() 를 명시적으로 작성하는 경우는 위의 어떤 체인에서 에러가 던져지더라도 에러를 받아낼 수 있다.
  • 상황에 따라 다르게 사용된다.

Promise 의 좋은 예제 Fetch API

  • 자바스크립트에서 HTTP 요청을 보낼 때 원래는 XMLHttpRequest API 를 사용했다.
  • ES6 부터는 Fetch API 라는 것이 나와 더 손쉽게 HTTP 비동기 요청을 처리할 수 있게 되었다.
  • fetch() 메서드는 Promise 객체를 반환한다.
fetch("https://api.publicapis.org/entries").then((res) => {
  console.log(res);
});

/*
Response {type: 'basic', url: 'https://api.publicapis.org/entries', redirected: false, status: 200, ok: true, …}
body: (...)
bodyUsed: false
headers: Headers {}
ok: true
redirected: false
status: 200
statusText: ""
type: "basic"
url: "https://api.publicapis.org/entries"
[[Prototype]]: Response
*/
  • 위의 예제는 https://api.publicapis.org/entries 라는 무료 공개 api 를 제공해주는 웹사이트를 이용했다.
  • fetch() 의 반환 값은 Promise 객체이다.
  • resolve() 에 인자로 들어가는 값은 Response 객체이다.
  • Response 객체는 실질적으로 반환된 데이터보다는 반환된 HTTP 응답에 대한 모든 정보를 포괄한다.
    • 그래서 보통 여기서 status 정보 등을 한번 더 체크하고 데이터를 이용한다.
fetch("https://api.publicapis.org/entries")
  .then((res) => {
    if (res.ok) {
      return res.json();
    }
  })
  .then((json) => {
    console.log(json); // {count: 1425, entries: Array(1425)}
  });
  • Response 에서 올바른 HTTP 응답을 주었는지 확인 후에 then() 을 한번 더 수행한다.

fetch() 에서 then()catch() 를 이용해 에러의 종류 구분해보기

  • 테스트용으로 2가지 API 를 만들었다.
    • /correct-json 에서는 파싱 가능한 JSON ("{\"data\": 100}") 을 응답한다.
    • /wrong-json 에서는 파싱 불가능한 JSON ("{\"data\": 100";) 을 응답한다.
  • Promise 에서는 에러 핸들링이 중요하다. 비동기 작업이 어떤 단계에서 실패했는지 잘 나오는지 확인해보자.
    • 에러 핸들링이 되지 않는 경우 계속 똑같은 자리에서 맴돌 위험이 있다.
class FetchError extends Error {
  constructor(response, message = "HTTP error " + response.status) {
    super(message);
    this.response = response;
  }
}

const myFetch = (...args) => {
  return fetch(...args).then((response) => {
    if (!response.ok) {
      throw new FetchError(response);
    }

    return response;
  });
};
myFetch("http://localhost:8080/correct-json")
  .then(
    (response) => response.json(),
    (rejected) => console.log(`rejected in myFetch method: ${rejected}`)
  )
  .then((json) => {
    console.log(json);
  })
  .catch((error) => console.log(`rejected after fetch method: ${error}`));

/*
{data: 100}
*/
  • 정상적으로 응답했다.
myFetch("http://localhost:8080/wrong-json")
  .then(
    (response) => response.json(),
    (rejected) => console.log(`rejected in myFetch method: ${rejected}`)
  )
  .then((json) => {
    console.log(json);
  })
  .catch((error) => console.log(`rejected after fetch method: ${error}`));

/*
rejected after fetch method: SyntaxError: Expected ',' or '}' after property value in JSON at position 12
*/
  • fetch() 메서드 다음에서 reject 되었다고 알려주고 있다.
myFetch("http://localhost:8080/wrong-jsona")
  .then(
    (response) => response.json(),
    (rejected) => console.log(`rejected in myFetch method: ${rejected}`)
  )
  .then((json) => {
    console.log(json);
  })
  .catch((error) => console.log(`rejected after fetch method: ${error}`));

/*
GET http://localhost:8080/wrong-jsona 404
rejected in myFetch method: Error: HTTP error 404
*/
  • myFetch() 메서드에서 reject 되었다고 알려주고 있다.
  • fetch() 중 에러가 난 것들 (HTTP Status 가 OK 가 아닌 것들) 과 JSON Parse 중 에러가 난 것들을 잘 구분해주고 있다.

Promise 의 정적 팩터리 메서드

  • 정적 팩터리처럼 사용할 수 있는 static method 가 존재한다.

Promise.resolve()

  • Promise.resolve()x instanceof Promise ? x : new Promise(resolve => resolve(x)) 와 같다.
    • x 가 Promise 의 인스턴스라면 x 를 반환하고, 아니라면 새 Promise 오브젝트를 생성하고 resolve(x) 를 한 결과를 가져온다.
Promise.resolve(100).then((value) => {
  console.log(value); // 100
});

Promise.reject()

  • Promise.reject()x instanceof Promise ? x : new Promise(reject => reject(x)) 와 같다.
    • x 가 Promise 의 인스턴스라면 x 를 반환하고, 아니라면 새 Promise 오브젝트를 생성하고 reject(x) 를 한 결과를 가져온다.
  • 몇몇 프로그래머들은 throw new Error() 보다 Promise.reject(new Error()) 를 선호한다.
    • then((value) => value == null ? Promise.reject(new Error()) : value);
      • 위 스타일을 사용하면 딱히 코드 블록을 만들 필요 없이 에러를 식으로 처리 가능하다.
Promise.reject(100)
  .then((v) => console.log(v + 100)) // then 에는 걸리지 않는다.
  .catch((v) => console.log(v * 100)); // 10000
반응형