도림.로그

Tags

Series

About

비동기 프로그래밍 - JavaScript - Promise

2023. 12. 22.

비동기 프로그래밍

▶ Expand post list

    이어서

    이전 포스팅에서 JavaScript의 Callback을 알아봤습니다. 이번 시간에는 Promise에 대해서 알아보도록 하겠습니다. 이전과 동일한 예제를 가지고 진행해보도록 하겠습니다.

    Promise

    Promise, 한글로 직역하면 약속이라고 볼 수 있습니다. JavaScript에서는 실행 시점에는 값에 대한 평가가 완료되지 않았을 수도 있는 값을 감싸서 나타내는 객체입니다. 즉, 어떤 값이 될 것이라는 약속을 담고 있는 객체라고 볼 수 있습니다. Promise를 사용하면 비동기에 대한 로직을 더 효율적이고 직관적으로 표현할 수 있게 됩니다.

    Promise의 상태

    Promise는 다음 3가지 종류 중 하나의 상태로 존재합니다.

    • pending : 대기 중 (미완료)
    • fulfilled : 충족됨 (완료 / 성공)
    • rejected : 거부됨 (완료 / 실패)

    이 상태들의 전이를 나타내보면 다음과 같습니다.

    promise-status

    Promise 하나가 어떤 상태로 전이하는 것은 간단한 것으로 보이는데, 하나 눈여겨봐야 할 것은 fulfilled / rejected 상태에서 새로운 Promise를 반환하여 작업을 이어갈 수 있다는 점입니다.

    DB 접근 예제

    이전 Callback 포스팅과 마찬가지로, DB 접근 예제를 통해서 알아보도록 하겠습니다.

    동일하게 메모리 DB 역할을 해줄 객체를 하나 생성하겠습니다.

    const database = {
      users: [
        { id: 1, name: "John", age: 30 },
        { id: 2, name: "Jane", age: 25 },
        { id: 3, name: "Bob", age: 40 },
      ],
      posts: [
        { id: 1, title: "Post 1", body: "Lorem ipsum", userId: 1 },
        { id: 2, title: "Post 2", body: "Dolor sit amet", userId: 2 },
        { id: 3, title: "Post 3", body: "Consectetur adipiscing elit", userId: 1 },
      ],
      comments: [
        { id: 1, body: "Great post!", postId: 1 },
        { id: 2, body: "Thanks for sharing", postId: 2 },
        { id: 3, body: "I learned a lot from this", postId: 1 },
      ],
    };

    유저를 찾기 위한 함수를 정의해보도록 하겠습니다.

    function findUser(userId) {
      return new Promise((resolve, reject) => {
        setTimeout(() => {
          const user = database.users.find((user) => user.id === userId);
          if (!user) {
            reject(`Invalid user id: ${userId}`);
            return;
          }
          resolve(user);
        }, 1000);
      });
    }

    이전 Callback 구현에서는 두 번째 인자로 다음에 수행할 Callback 함수를 인자로 전달받았었는데, 인자가 1개로 줄어들었습니다. 대신 새로운 Promise를 생성하여 반환하고 있습니다. 해당 Promise는 생성자로 resolvereject 를 사용하는 함수를 전달 받는데요. 구현 내부에서는 1초 이후에 DB에서 유저를 찾고, 찾아온 유저를 resolve(user)와 같이 처리하는 것을 보실 수 있습니다. 여기서 알 수 있다시피, resolve 역시 함수이며 이 함수는 Promise를 fulfilled 상태로 전이할 때 사용됩니다. 그리고 reject도 함수이며, 이 함수는 Promise를 rejected 상태로 전이할 때 사용됩니다. resolve의 인자값은 이후 then(f(resolved))의 인자로 전달되는 함수 f의 첫 인자 resolved로 사용됩니다. 그리고 rejected의 인자값은 이후 catch(f(rejected))의 인자로 전달되는 함수 f의 첫 인자 rejected로 사용됩니다.

    이와 비슷한 유저 활용해서 포스트 찾기, 포스트 활용해서 댓글 찾기 함수 구현도 살펴보겠습니다.

    function findPostsByUser(userId) {
      return new Promise((resolve, reject) => {
        setTimeout(() => {
          const posts = database.posts.filter((post) => post.userId === userId);
          if (posts.length === 0) {
            reject(`No posts found for user id: ${userId}`);
            return;
          }
          resolve(posts);
        }, 1000);
      });
    }
    
    function findCommentsByPost(postId) {
      return new Promise((resolve, reject) => {
        setTimeout(() => {
          const comments = database.comments.filter((comment) => comment.postId === postId);
          resolve(comments);
        }, 1000);
      });
    }

    특별한 로직은 없고, 모두 1초 이후에 DB에서 값을 찾아오는 로직으로 돼있습니다.

    1초 setTimeout 을 걸어둔 것은 비동기 로직을 시뮬레이팅하기 위한 목적입니다.

    위의 함수들을 조합해서, 예전과 비슷하게 우리가 원하는 비즈니스 로직을 작성해보겠습니다.

    function getUsersPostsCommentsPromise(userId) {
      return findUser(userId)
        .then((user) => {
          return findPostsByUser(userId)
            .then((posts) => {
              const promises = posts.map((post) => {
                return findCommentsByPost(post.id)
                  .then((comments) => {
                    return { ...post, comments };
                  });
              });
              return Promise.all(promises)
                .then((postsWithComments) => {
                  return { user, posts: postsWithComments };
                });
            });
        })
        .catch((error) => {
          console.error(error);
        });
    }

    한눈에 비교하실 수 있게 Callback으로 작성했던 로직도 보여드리겠습니다.

    // This do not work because function implementation is based on Promise
    function getUsersPostsCommentsCallback(userId, callback) {
      findUser(userId, (error, user) => {
        if (error) {
          callback(error, null);
          return;
        }
        findPostsByUser(userId, (error, posts) => {
          if (error) {
            callback(error, null);
            return;
          }
          const postsWithComments = [];
          let completed = 0;
          posts.forEach((post, index) => {
            findCommentsByPost(post.id, (error, comments) => {
              if (error) {
                callback(error, null);
                return;
              }
              postsWithComments[index] = { ...post, comments };
              completed++;
              if (completed === posts.length) {
                callback(null, { user, posts: postsWithComments });
              }
            });
          });
        });
      });
    }

    참고로, 사용하는 findUser 등의 메서드가 Promise 기반으로 되어있기 때문에 Callback 코드와 함께 사용하지 않으면 동작하지 않습니다. 전체 코드가 궁금하시면 이전 포스팅을 참조해주세요!

    함수의 호출 구조는 Callback 구현에 비해 크게 다르지 않습니다.

    기존 Callback 구현에 비해서 가장 눈에 띄는 부분은 일단 에러 처리에 대한 if 조건문이 없어졌다는 것입니다. 또한 Callback 구현에서는 다소 직관적이지 않다고 느껴졌던 게시글 별로 댓글을 조회하여 통합하는 부분이 Promise.all()라는 메서드를 통해서 훨씬 깔끔해졌는데요. 이 Promise.all()의 경우 인자로 전달한 Promise 배열에 담긴 모든 Promise를 동시에 수행하고 각 결과를 합쳐서 하나의 배열로 반환하는 메서드입니다. 인자로 전달된 Promise 배열의 순서가 보장되기 때문에, 조금 더 쉽게 로직을 처리할 수 있습니다. 심지어 Callback에서는 주의해야 했던 로직 조기 종료에 대한 위험도 사라졌고 로직의 순서도 직관적으로 확인할 수 있습니다.

    또한 에러 처리를 .catch()를 활용하여 통합할 수 있게 됐습니다. .catch()의 경우 예외가 발생했을 경우 수행됩니다.

    그럼에도 한 번 Promise로 감싸지면 해당 Promise에서 값을 꺼내어오는 등의 구현은 불가능하기 때문에, .then()을 이어가며 마지막 로직까지 작성해주어야 합니다. 이는 코드의 전반에 Promise가 전염되게 만들고, 코드의 직관성을 많이 떨어뜨립니다. 심지어 Promise에는 Reactive 패러다임과는 다르게 풍부한 연산자가 제공되지 않기 때문에 그 한계가 더 크게 다가올 수 있습니다.

    어떻게 개선할 방법이 없을까?

    Promise는 비동기 로직을 Callback에 비해 훨씬 직관적으로 표현할 수 있게 하는 강력한 도구입니다. 이에 더하여 비동기 로직을 동기 코드처럼 표현할 수 있다면 코드를 더 직관적으로 개선할 수 있지 않을까요?

    다음 포스팅에서는 asyncawait에 대해서 알아보도록 하겠습니다.

    끝.

    #async#javascript#promise

    © 2024, Built with Gatsby