ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • 비동기 흐름 정리 feat. Element.animate()
    FrontEnd/JavsScript 2021. 5. 3. 23:03

    자바스크립트의 Async Flow에는 여러 가지가 있다.

    각 flow는 주어진 작업을 병렬로 처리할 것이냐, 직렬로 처리할 것이냐, 인자는 언제 어떻게 넘길 것이냐 등에서 차이를 보이는데,

    추상적인 개념이다보니 바로 와닿지 않아서 직접 시각화해보기로 했다.

     

    flow 설명은 understanding-node-js-async-flows-parallel-serial-waterfall-and-queues를 참고했고,

    node의 async 모듈을 이용했다.

    (큐와 우선순위 큐는 아직 이해가 부족해서 이번 포스팅에서는 생략한다.)


    1. Parallel

    독립적인 task 여러 개를 병렬적으로 수행하고, 모든 task가 완료되면 최종 callback을 호출한다.

    최종 callback에는 각 task의 결과가 전달된다. 이때 최종 callback에 넘겨주는 결과는 실행한 task의 순서와 동일하게 보장된다.

    Parallel

    2. Series

    이전 작업이 끝나야 실행할 수 있는 task 여러 개를 수행하고, 모든 task가 완료되면 최종 callback을 호출한다.

    최종 callback에는 모든 task의 결과가 전달된다.

     

    Series

    3. Waterfall

    이전 작업이 끝나야 실행할 수 있는 task 여러 개를 수행하고, 모든 task가 완료되면 최종 callback을 호출한다.

    각 task는 자기 작업이 끝나면 다음 task에 결과를 넘긴다. 최종 callback은 마지막 task의 결과를 받게 된다.

     

    *바로 위의 Series와는 결과 전달 방법에서 차이가 난다. Series의 최종 callback은 각 task의 결과가 담긴 array를 인자로 받지만, Waterfall의 최종 callback은 마지막 task의 결과만을 받는다.

     

     

    4. Race

    각 task는 병렬(parallel)로 처리되지만, 어떤 task 하나가 끝나거나 에러가 나는 즉시 최종 callback이 호출된다.

     

    Race

     


    Race 개념을 이해하는 과정에서 1등 task가 끝나면 나머지 진행 중인 task들은 어떻게 되는지에 대한 의문이 들었다.

    완료되지 않았더라도 더 이상 진행하지 않고 멈추는 로직을 추가해야 하나? 싶어 MDN을 찾아보니 아래와 같은 예제 코드가 있다.

    var p1 = new Promise(function(resolve, reject) {
        setTimeout(() => resolve('하나'), 500);
    });
    var p2 = new Promise(function(resolve, reject) {
        setTimeout(() => resolve('둘'), 100);
    });
    
    Promise.race([p1, p2])
    .then(function(value) {
      console.log(value); // "둘"
      // 둘 다 이행하지만 p2가 더 빠르므로
    });
    

    둘다 이행하지만 이라고 되어있으니 resolve 이전의 작업은 수행하는 것으로 추측되었다.

    resolve 앞쪽에 각각 console.log 구문을 끼워넣고 출력을 해보니 둘다 진행되는 것을 확인할 수 있었다.

    resolve 이전의 작업은 race 결과와 관계없이 모두 완료하는 것으로 보여서, 나머지 task들도 자기 작업은 완료하도록 처리했다.

    polyfillnode async 소스코드를 참고했다.

     

     


    시각화 과정

    먼저 task와 finalCallback에 해당하는 DOM 요소를 만들고, 색 변화 애니메이션 효과로 작업 진행을 표현했다.

    그리고 작업이 완료되고 결과를 넘겨줄 때, 결과값이 될 DOM 요소를 콜백 task 오른쪽으로 옮기고 holder로 감싸주었다.

    holder가 있는 경우, 작업 진행을 표현할 때 holder도 색이 변하도록 해서 인자와 함께 처리된다는 의미를 더했다.

     

    각 flow는 함수로 호출하고, 함수의 인자로 위에서 만든 DOM 요소(또는 요소를 처리하는 함수)를 주었다.

     


    element: task

    제일 먼저 task 색 변화 애니메이션을 구현했다.

    Web API의 Element.animate()를 이용하면 인자로 넘긴 애니메이션을 Animation 객체로 만들어준다.

    애니메이션이 완료되었는지를 알고 싶다면 Animation 객체의 finished 프로퍼티를 확인하면 된다. 완료 상황이 Promise로 들어간다.

     

    색이 변하는 애니메이션이 끝나면 실제 요소 배경색을 바꿔주어야 하기 때문에, finished 이후 색 변화를 추가하고, callback으로 해당 요소를 넘겼다.

    function doTask({ element, color, delay, callback }) {
      const animation = element.animate({
        backgroundColor: color.start,
      }, delay);
        animation.finished.then(() => {
        element.style.backgroundColor = color.end;
        callback(element);
      });
    }

     

    그리고 task에 인자로 들어갈 element를 만드는 함수와

    function makeTask(content) {
      const task = document.createElement('div');
      task.classList.add('task');
      task.textContent = content;
    
      return task;
    }

    그걸 여러 개 만드는 함수를 만들었다.

    function makeTasks(n) {
      const taskList = [];
      for (let i = 0; i < n; i++) {
        const task = makeTask(`Task${i+1}`);
        taskList.push(task);
      }
    
      return taskList;
    }

    task class CSS는 이렇게 줬다.

    .task {
      display: inline-block;
      padding: 1rem 0.5rem;
      background-color: rgb(247, 196, 196);
      margin: 1rem;
      vertical-align: middle;
      border-radius: 4px;
    }

     

    color는 task, holder, finalCallback에 따라 달라져서 따로 객체로 분리했다.

    const colors = {
      task: {
        start: 'salmon',
        end: '#8cf3a4',
      },
      holder: {
        start: '#8cf3a4',
        end: '#d3fcdc',
      },
      argsHolder: {
        start: '#d39d9d',
        end: '#c0e3c0',
      },
      final: {
        start: '#eaf8ed',
        end: 'rgb(247, 196, 196)',
      }
    };

    delay에 넣어줄 작업시간이 필요하니 MDN을 참고해서 함수를 추가해주고,

      function generateRandomInt(min = 400, max = 5000) {
        min = Math.ceil(min);
        max = Math.floor(max);
        return Math.floor(Math.random() * (max - min)) + min;
      }
    

     

    잘 되는지 확인해본다. task를 그냥 만들기만 하기 때문에 만든 다음에 다른 요소에 추가해주어야 한다.

    const tasks = makeTasks(2);
    tasks.forEach(task => {
      document.querySelector('body').appendChild(task);
      doTask({
        element: task,
        color: colors.task,
        delay: generateRandomInt(500, 1500),
        callback: (element) => {
          element.textContent = `${element.textContent} is Done`;
        }
      });
    });


    element: holder

    함수가 인자를 받아서 처리하는 경우, 인자와 자기 자신을 외부와 구분해줄 holder가 필요하다.

    holder는 element 여러 개를 담을 수도 있고, 하나를 담을 수도 있으니 array와 HTMLElement가 들어오는 경우 각각에 대해 처리를 해줬다.

    function makeArgumentHolder(elements) {
      if (!Array.isArray(elements) && !(elements instanceof HTMLElement)) {
        throw new Error("1st parameter of makeArgumentHolder accepts an array or an element");
      }
    
      const holder = document.createElement('div');
      holder.classList.add('argument-holder');
    
      if (Array.isArray(elements)) {
        elements.forEach((element) => {
          holder.appendChild(element);
        });
    
        return holder;
      }
    
      holder.appendChild(elements);
      return holder;
    }

     

    잘 되나 확인해본다. 마찬가지로 그냥 holder를 만들기만 하기 때문에 만든 다음에 다른 요소에 추가해주어야 한다.

    이 경우 taskHolder가 body에 추가되기 때문에, 각 task를 직접 추가할 필요가 없어진다.

    const tasks = makeTasks(2);
    const taskHolder = makeArgumentHolder(tasks);
    document.querySelector('body').appendChild(taskHolder);
    tasks.forEach(task => {
      doTask({
        element: task,
        color: colors.task,
        delay: generateRandomInt(500, 1500),
        callback: (element) => {
          element.textContent = `${element.textContent} is Done`;
        }
      });
    });


    element: final Callback

    final Callback은 인자를 받아야하므로 holder에 감싸져 있다.

    그리고 최종 호출이 있다는 건 어떤 플로우 안에 있다는 뜻이니까, 바로 parent를 받아서 거기에 추가해주기로 했다.

    function makeFinalCallback(parent) {
      const task = makeTask('Final');
      const holder = makeArgumentHolder(task);
      parent.appendChild(holder);
    
      return {
        task,
        holder,
      };
    }

    이제 task1의 작업이 끝나면 final holder의 자식에 추가하고, final을 실행하면 된다. (아직 parallel 적용 전이라서 task를 1개로 줄였다.)

    const tasks = makeTasks(1);
    const taskHolder = makeArgumentHolder(tasks);
    const $body = document.querySelector('body');
    const { task: final, holder: finalHolder } = makeFinalCallback($body);
    $body.appendChild(taskHolder);
    tasks.forEach(task => {
      doTask({
        element: task,
        color: colors.task,
        delay: generateRandomInt(500, 1500),
        callback: (element) => {
          element.textContent = `${element.textContent} is Done`;
          finalHolder.appendChild(taskHolder);
          doTask({
            element: final,
            color: colors.task,
            delay: 1000,
            callback: (element) => {
              element.textContent = `${element.textContent} is Done`;
            }
          });
        }
      });
    });

    이제 여기에 parallel을 적용해서 인자 개수를 늘려본다.

     


    flow: parallel.gif

    parallel은 함수가 담긴 배열인 tasks를 받아서 작업을 돌리고, 모든 task가 완료됐는지 확인해서, 완료되면 finalCallback을 호출하고 결과값을 넘겨준다.

    위에서는 tasks에 DOMelement를 담았지만, 이제 parallel에는 함수를 넘겨줘야 하므로 map으로 익명함수들로 변경해줬다.

    const taskElements = makeTasks(5);
    const taskHolder = makeArgumentHolder(taskElements);
    const $body = document.querySelector('body');
    const { task: final, holder: finalHolder } = makeFinalCallback($body);
    $body.appendChild(taskHolder);
    
    const tasks = taskElements.map((element) => {
      return (callback) => doTask({
        element,
        color: colors.task,
        delay: generateRandomInt(),
        callback,
      });
    });
    
    async.parallel(tasks, () => {
      finalHolder.appendChild(taskHolder);
      doTask({
        element: final,
        color: colors.task,
        delay: 1000,
        callback: (element) => {
          element.textContent = `${element.textContent} is Done`;
        }
      });
    });

    async 모듈의 waterfall에서는 callback을 호출할 때 첫번째 인자로 error를 넘겨주는데,

    if (error) 가 참이면 callback에 error를 인자로 넘겨주기 때문에 callback 호출시 첫번째 인자는 null을 주어야 한다.

    내 경우는 doTask에서 이 부분을 처리하기 때문에 맞춰서 바꿔주고

    function doTask({ element, color, delay, callback }) {
      const animation = element.animate({
        backgroundColor: color.start,
      }, delay);
      animation.finished.then(() => {
        element.style.backgroundColor = color.end;
        callback(null, element);
      });
    }
    

    기초적인 parallel 애니메이션이 완성되었다.

     

    하지만 현재 상태로는 인자를 받았다는 표시가 불분명하니, 인자를 받아서 처리한다는 뜻으로 holder에 표시를 해주면 좋겠다.

    holder와 element를 둘다 받아서 지정한 색상으로 바꿔주는 함수를 만들고

    function doTaskWithArguments({ element, holder, callback: finalCallback }) {
      doTask({
        element: holder,
        color: colors.final,
        delay: 300,
        callback: () => {
          doTask({
            element,
            color: colors.task,
            delay: 1000,
            callback: () => {
              doTask({
                element: holder,
                color: colors.holder,
                delay: 300,
                callback: finalCallback,
              });
            }
          });
        }
      });
    }

    task가 끝나면 taskHolder 색을 바꿔주도록 추가했다.

    const taskElements = makeTasks(5);
    const taskHolder = makeArgumentHolder(taskElements);
    const $body = document.querySelector('body');
    const { task: final, holder: finalHolder } = makeFinalCallback($body);
    $body.appendChild(taskHolder);
    
    const tasks = taskElements.map((element) => {
      return (callback) => doTask({
        element,
        color: colors.task,
        delay: generateRandomInt(500, 1500),
        callback,
      });
    });
    
    parallel(tasks, (element) => {
      finalHolder.appendChild(taskHolder);
      doTask({                   // taskHolder highlighting
        element: taskHolder,
        color: colors.argsHolder,
        delay: 500,
        callback: () => {
          finalHolder.appendChild(taskHolder);
          doTaskWithArguments({  // finalCallback 처리
            element: final,
            holder: finalHolder,
            delay: 1000,
            callback: () => {
              console.log('All is done.');
            }
          });
        }
      });
    });

    원하는 대로 색깔 지정은 잘 됐는데, tasks를 처리한 이후 holder의 색을 바꾸는 부분에서 callback 지옥이 생기고 있다.

    이걸 해결하려면 순차 실행인 waterfall flow가 필요하다.


    waterfall - 순차적용

    waterfall은 함수가 담긴 배열인 tasks를 받아서 순서대로 작업을 돌리고, 한 task가 끝나면 다음 task에 결과를 넘겨주면서 호출한다.

    모든 task가 완료되면 finalCallback을 호출한다.

    waterfall을 이용하면 tasks 완료 -> argument holder 컬러링 -> final holder로 이동 -> final callback 작업을 순차적으로 처리할 수 있다. waterfall을 이용하면 바로 위의 콜백지옥 코드를 이렇게 표현할 수 있다.

    const taskElements = makeTasks(5);
    const taskHolder = makeArgumentHolder(taskElements);
    const $body = document.querySelector('body');
    const { task: final, holder: finalHolder } = makeFinalCallback($body);
    $body.appendChild(taskHolder);
    
    const tasks = taskElements.map((element) => {
      return (callback) => doTask({
        element,
        color: colors.task,
        delay: generateRandomInt(500, 1500),
        callback,
      });
    });
    
    async.waterfall(
      [
        function doTasks(callback) {
          parallel(tasks, callback);
        },
        function highlightTaskHolder(_, callback) {
          doTask({
            element: taskHolder,
            color: colors.argsHolder,
            delay: 500,
            callback,
          });
        },
        function moveTasksToFinalCallback(_, callback) {
          finalHolder.appendChild(taskHolder);
          callback(null); // 인자를 안 넘기면 undefined가 되니까 상관 없을 것 같기도 하다.
        },
        function doFinalTask(callback) { // 위에서 error 외의 인자를 넘기지 않아서 더 받지 않았다
          doTaskWithArguments({
            element: final,
            holder: finalHolder,
            callback,
          });
        }
      ],
      function showFinalMessage() {
        console.log('All is done.');
      }
    );

    waterfall을 적용하니 순차적으로 실행되는 함수들을 Array에 담을 수 있고,

    함수 기능을 설명해주는 이름을 붙여줄 수도 있어서 가독성이 향상되었다.

    원래는 finalCallback에서 마지막 함수의 실행 결과를 인자를 받지만 나는 더 이상 진행할 작업이 없어서 인자는 받지 않았다.

     


    flow: series.gif

    parallel 구조를 응용하면 series도 쉽게 만들 수 있다.

    series는 이전 작업이 끝나야 실행할 수 있는 task 여러 개를 수행하고, 모든 task가 완료되면 finalCallback을 실행하면 된다.

    parallel과 나머지 부분은 같고, task를 처리하는 순서만 다르다. 그러니 doTasks에서 parallel를 series로 교체하면 된다.

    flow: race.gif

    race도 parallel과 거의 흡사하지만, 제일 먼저 완료된 task만 final에 인자로 넘어간다는 점이 다르다.

    나머지 task는 자기 자리에서 완료되어야 하므로 기존 taskHolder는 그대로 두고, 새로 elementHolder를 만들어주었다.

    elementHolder는 로직 중간에 새로 생성되는 요소라서 인자로 넘겨서 다음 함수가 처리할 수 있도록 했다.

    const taskElements = makeTasks(5);
    const taskHolder = makeArgumentHolder(taskElements);
    const $body = document.querySelector('body');
    const { task: final, holder: finalHolder } = makeFinalCallback($body);
    $body.appendChild(taskHolder);
    
    const tasks = taskElements.map((element) => {
      return (callback) => doTask({
        element,
        color: colors.task,
        delay: generateRandomInt(500, 1500),
        callback,
      });
    });
    
    async.waterfall(
      [
        function doTasks(callback) {
          race(tasks, callback);
        },
        function addElementHolder(element, callback) {
          const elementHolder = makeArgumentHolder(element);
          callback(null, elementHolder);  // 첫번째 인자로 error - null
        },
        function moveTasksToFinalCallback(elementHolder, callback) {
          finalCallbackHolder.appendChild(elementHolder);
          callback(null, elementHolder);  // 첫번째 인자로 error - null
        },
        function highlightTaskHolder(elementHolder, callback) {
          doTask({
            element: elementHolder,
            color: colors.argsHolder,
            delay: 500,
            callback,
          });
        },
        function doFinalTask(_, callback) {
          doTaskWithArguments({
            element: finalCallback,
            holder: finalCallbackHolder,
            callback,
          });
        }
      ],
      function showFinalMessage() {
        console.log('All is done.');
      }
    );

     


    flow: waterfall.gif

    parallel, series, race에서는 각 task가 개별적으로 실행되었지만,

    waterfall에서는 직전 task 결과를 받아서 실행해야 하기 때문에 tasks에 들어가는 익명함수도 parallel과는 달라야 한다.

     

    매 task마다 인자가 새로 결정되기 때문에, 위 race에서 첫 번째 task가 끝나고 finalCallback 실행전에 했던 작업들,

    addElementHolder, ... 를 매번 수행해야 한다.

     

    다만 완료된 직전 task 입장에서는 자신을 받을 다음 타자가 누구인지 모르니까 그대로 넘기고,

    다음 task에서 작업 전에 그걸 받아서 holder로 감싸고, 위에서 만든 doTaskWithArgument로 작업을 했다.

    색 변화가 많아서 hightlightTaskHolder는 생략했다.

     

    const tasks = taskElements.map((element) => {
      return (args, callback) => {
        if (typeof args === "function") {  // 인자 없이 넘긴 경우 ex) 첫번째 실행
          doTask({
            element,
            color: colors.task,
            delay: generateRandomInt(500, 1200),
            callback: args,
          });
          return;
        }
        const elementHolder = makeArgumentHolder(element);
        elementHolder.appendChild(args);
        taskHolder.appendChild(elementHolder);
        doTaskWithArguments({
          element,
          holder: elementHolder,
          callback,
        });
      }
    });
    
    async.waterfall(
      [
        function doTasks(callback) {
          waterfall(tasks, callback);
        },
        function highlightTaskHolder(_, callback) {
          doTask({
            element: taskHolder,
            color: colors.argsHolder,
            delay: 500,
            callback,
          });
        },
        function moveTasksToFinalCallback(_, callback) {
          finalHolder.appendChild(taskHolder);
          callback();
        },
        function doFinalTask(callback) {
          doTaskWithArguments({
            element: final,
            holder: finalHolder,
            callback,
          });
        }
      ],
      function showFinalMessage() {
        console.log('All is done.');
      }
    );

     


    github.com/hayjo/async_visualization

    댓글

Designed by Tistory.