도림.로그

Tags

Series

About

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

2023. 10. 12.

비동기 프로그래밍

▶ Expand post list

    시작하며

    JS를 사용해서 개발을 공부하다 보면, 반드시 비동기라는 개념을 마주하게 됩니다.

    개인적으로 처음 접할 때에는 어려워서 제대로 이해가 되지 않았고, 나중에 익숙해지고 나서는 실제 동작에 대해 고려하지 않고 그저 로직만 작성하곤 했던 것 같습니다.

    대표적으로 잘 알려져 있는 Callback(콜백), Promise(프로미스), async / await 를 사용한 방법들을 정리해보려 합니다.

    현재 백엔드 개발자로 업무를 수행하고 있기 때문에, 익숙하고 간단한 DB 접근 예제를 하나 들어서 정리해보겠습니다.

    Callback은 왜 등장하게 되었을까?

    JavaScript는 싱글 쓰레드로 동작하는 언어입니다. 더 정확히는, JavaScript 엔진은 단일 콜 스택을 사용해서 코드를 실행하고 한 시점에는 하나의 작업만 수행할 수 있습니다. 브라우저에서 동적인 요소를 보여주기 위해 사용되었던 JavaScript가 싱글 쓰레드를 사용하면 여러 문제가 발생할 것으로 생각이 들 것입니다.

    가령 검색 페이지에서 검색을 하는 경우를 생각해 봅시다. 입력값을 넣고, 검색 버튼을 눌렀어요. 싱글 쓰레드 환경이기 때문에 순차적으로 처리를 하게 될 것이고, 그러면 검색 결과를 서버로부터 받아오고, 결과를 렌더링하는 과정을 완료하기 전 까지는 웹 페이지의 어떤 동작도 수행할 수 없을 것입니다.

    그런데 우리가 매일같이 사용하는 페이지들은 그렇지 않죠. 심지어 검색 결과가 나오기도 전에 다른 상호작용을 하는 것도 가능합니다.

    이것이 가능한 이유는 JavaScript가 이벤트 기반으로 동작하고, 이것을 브라우저가 잘 도와주고 있기 때문입니다. 앞서 제시한 상황을 예로 들어보면, 검색 버튼을 누른 순간 해당 동작은 브라우저의 WebAPI가 대신 이행하고, 따라서 JavaScript 엔진은 자유롭게 다시 다른 동작을 수행할 수 있게 되는 것이지요.

    그러면 다 해결된 것일까요? 아닙니다. 검색 버튼을 눌러서 검색 결과를 서버로부터 받아오면 그걸 다시 그려주는 동작을 해줘야겠죠. 이 동작을 전달하기 위해서 해당 동작이 정의된 함수를 브라우저 WebAPI 호출 시에 인자로 담아서 전달하게 됩니다.

    우리는 이것을 함수가 호출하는 함수, 함수가 다시 불러내는 함수라고 해서 callback 함수라고 부릅니다.

    이런 접근이 가능한 이유는 JavaScript에서 함수를 값(value)으로 취급할 수 있기 때문이기도 합니다.

    간단한 예시

    callback을 가장 간단하게 체험해볼 수 있는 예시는 바로 setTimeout() 함수일 것입니다. 이 함수는 일정 시간이 경과한 후 인자로 전달한 callback 함수를 수행합니다. 간단히 예시를 볼까요?

    setTimeout(function () {
      console.log("Hello after 1000 milliseconds!");
    }, 1000);

    위의 코드를 수행하게 되면 1초 후에 출력문이 출력됩니다.

    여기에서, 아까 말씀드린 내용을 기억하셔야 하는데요, setTimeout() 자체는 JavaScript를 통해서 호출할 수 있지만, 이것이 JavaScript로 구현된 함수가 아니라는 점입니다. setTimeout()은 일종의 브라우저 WebAPI 를 호출할 수 있는 브릿지 함수로 볼 수 있습니다.

    DB 접근 예제

    그러면 앞서 말씀드린 대로, DB 접근 상황을 가정해서 callback 함수를 통해 비동기 처리를 하는 예제를 작성해보겠습니다. 시나리오가 필요할 것 같은데요, 약간 억지가 있긴 하겠지만 아무래도 개념 정리를 위한 것이니, 다음과 같이 가보도록 하겠습니다.

    DB에 쿼리를 날려 특정 유저가 작성한 게시글과 그 게시글의 댓글을 불러와보자.

    먼저 간단하게 메모리 DB 역할을 해줄 객체를 하나 구성해보겠습니다.

    const database = {
      users: [
        { id: 1, name: "슬레타" },
        { id: 2, name: "미오리네" },
        { id: 3, name: "구엘" },
      ],
      posts: [
        { id: 1, title: "Post 1", body: "슬레타의 첫 포스팅", userId: 1 },
        { id: 2, title: "Post 2", body: "토마토 키우는 법", userId: 2 },
        { id: 3, title: "Post 3", body: "에어리얼은 나의 가족", userId: 1 },
      ],
      comments: [
        { id: 1, body: "첫 댓글이에요!", postId: 1 },
        { id: 2, body: "정보 잘 보고가요", postId: 2 },
        { id: 3, body: "반가워요~", postId: 1 },
      ],
    };

    댓글에 작성자에 대한 정보가 없긴 하지만, 최대한 간단한 예제를 위해 넘어가도록 하겠습니다.

    이제 이 DB에서 유저를 찾기 위한 함수를 구성해보겠습니다.

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

    findUser() 함수는 두 번째 인자로 callback 함수를 하나 전달받는데요, 내부 동작이 완료된 이후 callback에 인자를 전달하도록 되어 있습니다.

    구현을 보면 DB에서 특정 userId를 가진 유저를 조회해오도록 작성했습니다. 또한 찾은 유저에 대한 정보를 콜백의 두 번째 인자로 넘기고 있습니다. 만약 에러가 발생했을 경우, 첫 인자로 에러 메시지를 넘겨주고 있습니다.

    그리고 이 전체 로직이 setTimeout()으로 감싸져 있습니다. DB 쿼리를 통해 불러오는 것을 시뮬레이팅하기 위해 또한 외부 IO 상황을 가정하기 위해 엔진 입장에서 외부 실행 코드인 브라우저 WebAPI 를 호출하는 setTimeout()을 사용했습니다.

    이 함수를 작성한 이유는 간단한 예제이지만 그래도 유저가 존재할 경우에만 우리의 시나리오를 만족시키기 위함입니다.

    이와 비슷하게 유저를 통해서 게시글을 불러오는 코드, 그리고 게시글을 통해 댓글을 불러오는 코드를 작성하면 다음과 같이 구성해볼 수 있을 것 같습니다.

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

    이제 이 함수들을 조합해서 우리의 시나리오를 만족시킬 수 있는 실제 비즈니스 로직을 담은 함수를 작성해봅시다.

    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 });
              }
            });
          });
        });
      });
    }

    아무래도 위에 구성한 함수들에 비해서 복잡해보이는데요, 찬찬히 뜯어보도록 합시다.

    일단 이 getUsersPostsCommentsCallback() 함수는 대상 유저의 id와 callback을 인자로 전달받는데요, 앞으로 callback 이란 표현이 많이 나올 것이기 때문에 getUsersPostsCommentsCallback()의 인자 callback은 root callback 이라고 지칭하겠습니다.

    그럼 로직을 하나씩 따라가보겠습니다.

    일단 에러 처리 부분은 모두 동일한데요, 에러가 있을 경우엔 root callback에 에러를 전달하고, early return 하여 실행을 종료합니다.

    실제 로직을 살펴보면, 먼저 유저의 id를 이용해서 유저를 먼저 찾습니다. 이 작업이 완료되면 두 번째 인자로 전달된 callback 함수를 수행하게 됩니다.

    우리가 전달한 함수는 유저 id를 활용하여 findPostsByUser()를 호출하고, 그 작업이 완료되면 다시 두 번째 인자로 전달된 callback 함수를 수행하게 됩니다.

    findPostsByUser()의 인자로 전달한 callback의 구현이 약간 복잡한데요. 직접 completed 라는 변수를 외부에 선언해두고 callback 내부에서 성공 시에 결과값을 저장하고 completed 값을 증가시키다가, 전체 posts.length와 같아지면 마지막으로 root callback 함수를 호출하게 되어 있습니다.

    언뜻 보면 직관적으로 이해하기 어렵습니다. 어차피 root callback을 수행하게 될 것인데, 그냥 다음과 같이 할 수 없을까요?

    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 = [];
    
          posts.forEach((post, index) => {
            findCommentsByPost(post.id, (error, comments) => {
              if (error) {
                callback(error, null);
                return;
              }
              postsWithComments[index] = { ...post, comments };
            });
          });
          // 그냥 다 수행하고, root callback을 호출하면 안될까?
          callback(null, { user, posts: postsWithComments });
        });
      });
    }

    아쉽게도 이렇게 하면 마지막에 root callback이 실행될 때 postsWithComment에 데이터가 완성되지 않은 상태로 호출될 수 있습니다. 예를 들어 우리가 기대하는 결과는 10개인데, 실제로는 1개만 들어있을 수도 있다는 것이죠.

    이것은 posts.forEach() 부분이 우리가 생각한대로 수행을 모두 기다리는 동기 실행이 아니기 때문에 그렇습니다. post.forEach()findCommentsByPost()를 모두 호출하고, 이것의 수행이 끝나든 끝나지 않든 다음 절차로 넘어가게 되는 것이지요.

    그래서 callback을 정확히 한번 호출하기 위해 번거롭지만 completed 변수를 선언해서 findCommentsByPost()의 인자로 전달한 callback의 내에서 호출하게 하여 타이밍을 지킬 수 있게 하는 것입니다.

    이제 이 로직을 호출하는 실제 수행 로직을 간단히 구성해보겠습니다.

    getUsersPostsCommentsCallback(1, (error, result) => {
      if (error) {
        console.error(error);
      } else {
        console.log(result);
      }
    });

    간단하게 userId 1을 전달하고, 결과나 에러를 콘솔에 로깅하도록 구성했습니다.

    전체 코드를 한판에 정리하면 다음과 같습니다.

    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, callback) {
      setTimeout(() => {
        const user = database.users.find(user => user.id === userId);
        if (!user) {
          callback(`Invalid user id: ${userId}`, null);
          return;
        }
        callback(null, user);
      }, 1000);
    }
    
    function findPostsByUser(userId, callback) {
      setTimeout(() => {
        const posts = database.posts.filter(post => post.userId === userId);
        if (posts.length === 0) {
          callback(`No posts found for user id: ${userId}`, null);
          return;
        }
        callback(null, posts);
      }, 1000);
    }
    
    function findCommentsByPost(postId, callback) {
      setTimeout(() => {
        const comments = database.comments.filter(
          comment => comment.postId === postId
        );
        callback(null, comments);
      }, 1000);
    }
    
    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 });
              }
            });
          });
        });
      });
    }
    
    getUsersPostsCommentsCallback(1, (error, result) => {
      if (error) {
        console.error(error);
      } else {
        console.log(result);
      }
    });

    어떤가요? 아무래도 가독성이 많이 부족하죠? 이런 식으로 callback의 깊이가 깊어져서 가독성이 떨어지는 문제를 callback hell, 콜백 지옥이라고 하는 문제를 마주할 가능성이 높습니다.

    callback-hell.png

    심지어 비동기 콜을 반복하여 수행하는 것을 기다리지 않는다는 점 때문에 실수를 할 가능성도 높다는 단점까지 가지고 있습니다.

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

    실제로 제가 처음 Node.js 를 공부할 때에는 이런 부분을 해결하기 위해 async.js라는 라이브러리를 사용하기도 했습니다. (이 라이브러리는 최근에도 유지보수 되고 있어 선호하시는 분들이 있는 것으로 알고 있습니다.)

    하지만 ES6 표준에서 소개된 Promise의 등장 이후로 대부분 소스코드에서 Promise를 기반으로 비동기를 구현하고 있습니다.

    다음엔 Promise를 활용해서 동일한 예제를 구현하며 내용을 정리해보도록 하겠습니다.

    끝.

    #async#javascript#callback

    © 2024, Built with Gatsby