반디북
쏙쏙 들어오는 함수형 코딩
Ch16
우석

타임라인 사이에 자원 공유하기

좋은 타임라인 원칙

1. 타임라인은 적을수록 이해하기 쉽다.
2. 타임라인은 짧을수록 이해하기 쉽다.
3. 공유하는 자원이 적을수록 이해하기 쉽다.
4. 자원을 공유한다면 서로 조율해야 합니다.
5. 시간을 일급으로 다룹니다.
  • 이전 장에서 1, 2, 3을 다뤘고 4번을 통해 현재 남아있는 버그를 해결하려 한다.
  • 현재 문제점
    • DOM이라는 공유 자원을 UPDATE하는 순서에 따라 잘못된 결과가 나올 수 있다.
    • 비동기로 처리되기 때문에 순서가 달라질 수 있고 클릭한 순서와 DOM 업데이트 순서가 다를 수 있다.

Queue를 활용해 순서 보장하기

  • 이런 문제 상황에서 가장 먼저 떠올릴 수 있는 방법이 Queue를 활용한 작업 줄세우기이다.
  • 큐에서 처리할 일과 클릭 핸들러에서 처리할 일을 나누고, 순서 보장이 필요한 일들을 큐에서 순차적으로 처리되도록 보장한다.

Queue에 Task 추가

function add_item_to_cart(item) {
  const cart = add_item(cart, item);
  calc_cart_total(cart, update_total_dom);
}
 
function calc_cart_total(cart, callback) {
  let total = 0;
  cost_ajax(cart, function(cost) {
    total += cost;
    shipping_ajax(cart, function(shipping) {
      total += shipping;
      callback(total);
    });
  });
}
  • 기존 코드가 연달아 2번 실행됐을 때 update_total_dom이 순차적으로 실행된다는 보장이 없다.

var queue_items = [];
 
function runNext() {
  var cart = queue_items.shift();
  calc_cart_total(cart, update_total_queue)
}
 
function update_total_queue(cart) {
  queue_items.push(cart);
  setTimeout(runNext, 0);
}
 
function add_item_to_cart(item) {
  const cart = add_item(cart, item);
  update_total_queue(cart);
}
 
function calc_cart_total(cart, callback) {
  let total = 0;
  cost_ajax(cart, function(cost) {
    total += cost;
    shipping_ajax(cart, function(shipping) {
      total += shipping;
      callback(total);
    });
  });
}
  • Queue를 활용해 Task를 넣고 Queue에 있는 Task를 실행하는 로직을 추가했다.
  • 하지만 문제가 있다면 Queue의 2가지 Task가 동시에 실행될 수 있다.

변수 활용해 Task 동시 실행 막기

let queue_items = [];
let working = false;
 
function runNext() {
  if (working) return;
 
  working = true;
 
  var cart = queue_items.shift();
  calc_cart_total(cart, function(total) {
    update_total_queue(total);
    working = false;
    runNext();
  });
}
 
function update_total_queue(cart) {
  queue_items.push(cart);
  setTimeout(runNext, 0);
}
 
function add_item_to_cart(item) {
  const cart = add_item(cart, item);
  update_total_queue(cart);
}
 
function calc_cart_total(cart, callback) {
  let total = 0;
  cost_ajax(cart, function(cost) {
    total += cost;
    shipping_ajax(cart, function(shipping) {
      total += shipping;
      callback(total);
    });
  });
}
  • working 변수를 활용해 Queue의 Task가 실행 중일 때 다른 Task가 수행되지 않도록 막을 수 있다.

항목이 없을 때 멈추게 하기

let queue_items = [];
let working = false;
 
function runNext() {
  if (working) return;
  if (queue_items.length === 0) return;
 
  working = true;
 
  var cart = queue_items.shift();
  calc_cart_total(cart, function(total) {
    update_total_queue(total);
    working = false;
    runNext();
  });
}
 
function update_total_queue(cart) {
  queue_items.push(cart);
  setTimeout(runNext, 0);
}
 
function add_item_to_cart(item) {
  const cart = add_item(cart, item);
  update_total_queue(cart);
}
 
function calc_cart_total(cart, callback) {
  let total = 0;
  cost_ajax(cart, function(cost) {
    total += cost;
    shipping_ajax(cart, function(shipping) {
      total += shipping;
      callback(total);
    });
  });
}
  • 조건문 추가하여 queue에 item이 없을 때는 로직 실행을 멈추도록 수정
  • 하지만 여전히 아쉬운 점은 working, queue_items가 전역적으로 사용될 수 있다는 점이다.

공유 변수 범위 좁히기

function Queue() {
  let queue_items = [];
  let working = false;
 
  function runNext() {
    if (working) return;
    if (queue_items.length === 0) return;
 
    working = true;
 
    var cart = queue_items.shift();
    calc_cart_total(cart, function(total) {
      update_total_queue(total);
      working = false;
      runNext();
    });
  }
 
  return function(cart) {
    queue_items.push(cart);
    setTimeout(runNext, 0);
  }
}
 
function add_item_to_cart(item) {
  const cart = add_item(cart, item);
  update_total_queue(cart);
}
 
function calc_cart_total(cart, callback) {
  let total = 0;
  cost_ajax(cart, function(cost) {
    total += cost;
    shipping_ajax(cart, function(shipping) {
      total += shipping;
      callback(total);
    });
  });
}
 
 
const update_total_queue = Queue();
  • 공유 자원을 함수의 지역 변수로 수정하였다.
  • 해당 변수를 다른 곳에서 접근하여 문제가 발생할 수 있는 상황을 제거

재사용성 고려하기

function Queue(worker) {
  let queue_items = [];
  let working = false;
 
  function runNext() {
    if (working) return;
    if (queue_items.length === 0) return;
 
    working = true;
 
    var item = queue_items.shift();
 
    worker(item.data, function(val) {
      working = false;
      setTimeout(item.callback, 0, val)
      runNext();
    })
  };
 
  return function(data, callback) {
    queue_items.push({
      data: data,
      callback : callback || function() {}
    });
    setTimeout(runNext, 0);
  }
}
 
function add_item_to_cart(item) {
  const cart = add_item(cart, item);
  update_total_queue(cart);
}
 
function calc_cart_worker(cart, done) {
  calc_cart_total(cart, function(total) {
    update_total_dom(total);
    done(total);
  })
}
 
function calc_cart_total(cart, callback) {
  let total = 0;
  cost_ajax(cart, function(cost) {
    total += cost;
    shipping_ajax(cart, function(shipping) {
      total += shipping;
      callback(total);
    });
  });
}
 
 
const update_total_queue = Queue(calc_cart_worker);
  • worker 로직을 인자로 주입받을 수 있도록 변경하여 Queue를 여러 용도로 사용이 가능해졌다.
  • 또한 작업 후 callback 메서드를 추가로 수행할 수 있도록 적용하였다.

DroppingQueue를 활용해 효율성 개선하기

function DroppingQueue(max, worker) {
  let queue_items = [];
  let working = false;
 
  function runNext() {
    if (working) return;
    if (queue_items.length === 0) return;
 
    working = true;
 
    var item = queue_items.shift();
 
    worker(item.data, function(val) {
      working = false;
      setTimeout(item.callback, 0, val)
      runNext();
    })
  };
 
  return function(data, callback) {
    queue_items.push({
      data: data,
      callback : callback || function() {}
    });
 
    while(queue_items.length > max) {
      queue_items.shift();
    }
 
    setTimeout(runNext, 0);
  }
}
 
function add_item_to_cart(item) {
  const cart = add_item(cart, item);
  update_total_queue(cart);
}
 
function calc_cart_worker(cart, done) {
  calc_cart_total(cart, function(total) {
    update_total_dom(total);
    done(total);
  })
}
 
function calc_cart_total(cart, callback) {
  let total = 0;
  cost_ajax(cart, function(cost) {
    total += cost;
    shipping_ajax(cart, function(shipping) {
      total += shipping;
      callback(total);
    });
  });
}
 
 
const update_total_queue = DroppingQueue(1, calc_cart_worker); // 1개 이상의 작업은 모두 버리도록
  • 마지막 작업만 수행하면 되는 상황에서는 모든 Task에 대해 로직을 수행할 필요가 없다.
  • 때문에 마지막 Task만 저장하고 Task를 지우는 로직 추가하여 효율성 개선

결론

  • Queue를 활용해 클릭 순서대로 DOM UPDATE가 가능하도록 하여 기존 버그를 수정하였다.
  • Queue처럼 자원을 안전하게 공유할 수 있는 재사용 가능한 코드를 동시성 기본형이라 부른다
만혁

타임라인 사이에 자원 공유하기

좋은 타임라인의 원칙

  1. 타임라인은 적을수록 이해하기 쉽다.
  2. 타임라인은 짧을수록 이해하기 쉽다.
  3. 공유하는 자원이 적을수록 이해하기 쉽다.
  4. 자원을 공유한다면 서로 조율해야 한다.
  5. 시간을 일급으로 다룬다.

o = (ta)!/(a!)^t

  • 가능한 실행 순서 = (타임라인 개수 * 타임라인당 액션 수)! / (타임라인당 액션 수!)^타임라인 개수

자바스크립트에서 큐 만들기

자바스크립트에서는 큐 자료 구조가 없기 때문에 만들어야 한다.

큐에서 처리할 작업을 큐에 넣기

// as-is
function addItemToCart(item) {
  const cart = addItem(cart, item);
  calcCartTotal(cart, updateTotalDom);
}
 
function calcCartTotal(cart, callback) {
  let total = 0;
  costAjax(cart, function(cost) {
    total += cost;
    shippingAjax(cart, function(shipping) {
      total += shipping;
      callback(total);
    });
  });
}
 
// to-be
function addItemToCart(item) {
    cart = addItem(cart, item);
    updateTotalQueue(cart);
}
 
function calcCartTotal(cart, callback) {
    let total = 0;
    costAjax(cart, function(cost) {
        total += cost;
        shippingAjax(cart, function(shipping) {
            total += shipping;
            callback(total);
        })
    })
}
 
const queueItems = [];
function updateTotalQueue(cart) {
    queueItems.push(cart)
}
 

큐에 있는 첫 번째 항목을 실행

function addItemToCart(item) {
    cart = addItem(cart, item);
    updateTotalQueue(cart);
}
 
function calcCartTotal(cart, callback) {
    let total = 0;
    costAjax(cart, function(cost) {
        total += cost;
        shippingAjax(cart, function(shipping) {
            total += shipping;
            callback(total);
        })
    })
}
 
const queueItems = [];
 
function runNext() {
    const cart = queueItems.shift();
    calcCartTotal(cart, updateTotalDom);
}
 
function updateTotalQueue(cart) {
    queueItems.push(cart);
    // js 이벤트 루프에 작업을 추가 -> 큐에 항목을 추가하고 워커를 시작
    setTimeout(runNext, 0);
}

두 번째 타임라인이 첫 번째 타임라인과 동시에 실행되는 것 막기

  • 한번에 하나만 실행되도록 만들어야 한다.
function addItemToCart(item) {
    cart = addItem(cart, item);
    updateTotalQueue(cart);
}
 
function calcCartTotal(cart, callback) {
    let total = 0;
    costAjax(cart, function(cost) {
        total += cost;
        shippingAjax(cart, function(shipping) {
            total += shipping;
            callback(total);
        })
    })
}
 
const queueItems = [];
let working = false;
 
function runNext() {
    // 동시에 두 개가 동작하는 것을 막을 수 있다.
    if (working) return;
    working = true;
    const cart = queueItems.shift();
    calcCartTotal(cart, updateTotalDom);
}
 
function updateTotalQueue(cart) {
    queueItems.push(cart);
    setTimeout(runNext, 0);
}

다음 작업을 시작할 수 있도록 calcCartTotal() 콜백 함수를 고친다

function addItemToCart(item) {
    cart = addItem(cart, item);
    updateTotalQueue(cart);
}
 
function calcCartTotal(cart, callback) {
    let total = 0;
    costAjax(cart, function(cost) {
        total += cost;
        shippingAjax(cart, function(shipping) {
            total += shipping;
            callback(total);
        })
    })
}
 
const queueItems = [];
let working = false;
 
function runNext() {
    if (working) return;
    working = true;
    const cart = queueItems.shift();
    calcCartTotal(cart, function(total) {
        updateTotalDom(total);
        // 1. 작업 완료를 표시하고
        working = false;
        // 2. 다음 작업을 시작
        runNext();
    });
}
 
function updateTotalQueue(cart) {
    queueItems.push(cart);
    setTimeout(runNext, 0);
}

항목이 없을 때 멈추게 하기

function addItemToCart(item) {
    cart = addItem(cart, item);
    updateTotalQueue(cart);
}
 
function calcCartTotal(cart, callback) {
    let total = 0;
    costAjax(cart, function(cost) {
        total += cost;
        shippingAjax(cart, function(shipping) {
            total += shipping;
            callback(total);
        })
    })
}
 
const queueItems = [];
let working = false;
 
function runNext() {
    if (working) return;
    // 큐가 비어있으면 멈춤
    if (queueItems.length === 0) return;
    working = true;
    const cart = queueItems.shift();
    calcCartTotal(cart, function(total) {
        updateTotalDom(total);
        working = false;
        runNext();
    });
}
 
function updateTotalQueue(cart) {
    queueItems.push(cart);
    setTimeout(runNext, 0);
}

변수와 함수를 함수 범위로 넣기

function addItemToCart(item) {
    cart = addItem(cart, item);
    updateTotalQueue(cart);
}
 
function calcCartTotal(cart, callback) {
    let total = 0;
    costAjax(cart, function(cost) {
        total += cost;
        shippingAjax(cart, function(shipping) {
            total += shipping;
            callback(total);
        })
    })
}
 
// const queueItems = [];
// let working = false;
 
// function runNext() {
//     if (working) return;
//     if (queueItems.length === 0) return;
//     working = true;
//     const cart = queueItems.shift();
//     calcCartTotal(cart, function(total) {
//         updateTotalDom(total);
//         working = false;
//         runNext();
//     });
// }
 
function updateTotalQueue(cart) {
    queueItems.push(cart);
    setTimeout(runNext, 0);
}
 
// 모든 코드를 Queue() 에 넣는다
function Queue() {
    // 전역변수가  Queue()의 지역변수로 바뀐다
    const queueItems = [];
    let working = false;
 
    function runNext() {
        if (working) return;
        if (queueItems.length === 0) return;
        working = true;
        const cart = queueItems.shift();
        calcCartTotal(cart, function(total) {
            updateTotalDom(total);
            working = false;
            runNext();
        });
    }
 
    return function(cart) {
        queueItems.push(cart);
        setTimeout(runNext, 0);
    }
}
 
const updateTotalQueue = Queue();

큐를 재사용할 수 있도록 만들기

done() 함수 빼내기

// as-is
function Queue() {
    const queueItems = [];
    let working = false;
 
    function runNext() {
        if (working) return;
        if (queueItems.length === 0) return;
        working = true;
        const cart = queueItems.shift();
        calcCartTotal(cart, function(total) {
            updateTotalDom(total);
            working = false;
            runNext();
        });
    }
 
    return function(cart) {
        queueItems.push(cart);
        setTimeout(runNext, 0);
    }
}
 
const updateTotalQueue = Queue();
 
 
// to-be
function Queue() {
    const queueItems = [];
    let working = false;
 
    function runNext() {
        if (working) return;
        if (queueItems.length === 0) return;
        working = true;
        const cart = queueItems.shift();
        // cart 를 인자로 받아 지역적으로 사용
        function worker(cart, done) {
            calcCartTotal(cart, function(total) {
                updateTotalDom(total);
                done(total);
            });
        }
        worker(cart, function() {
            // 두 줄을 새로운 함수로 분리
            working = false;
            runNext();
        })
    }
 
    return function(cart) {
        queueItems.push(cart);
        setTimeout(runNext, 0);
    }
}
 
const updateTotalQueue = Queue();
 

워커 행동을 바꿀 수 있도록 분리

// as-is
function Queue() {
    const queueItems = [];
    let working = false;
 
    function runNext() {
        if (working) return;
        if (queueItems.length === 0) return;
        working = true;
        const cart = queueItems.shift();
        function worker(cart, done) {
            calcCartTotal(cart, function(total) {
                updateTotalDom(total);
                done(total);
            });
        }
        worker(cart, function() {
            working = false;
            runNext();
        })
    }
 
    return function(cart) {
        queueItems.push(cart);
        setTimeout(runNext, 0);
    }
}
 
const updateTotalQueue = Queue();
 
 
// to-be
function Queue(worker) {
    const queueItems = [];
    let working = false;
 
    function runNext() {
        if (working) return;
        if (queueItems.length === 0) return;
        working = true;
        const cart = queueItems.shift();
        worker(cart, function() {
            working = false;
            runNext();
        })
    }
 
    return function(cart) {
        queueItems.push(cart);
        setTimeout(runNext, 0);
    }
}
 
function calcCartWorker(cart, done) {
    calcCartTotal(cart, function(total) {
        updateTotalDom(total);
        done(total);
    });
}
 
const updateTotalQueue = Queue(calcCartWorker);
 

작업이 끝났을 때 실행하는 콜백을 받기

// as-is
function Queue(worker) {
    const queueItems = [];
    let working = false;
 
    function runNext() {
        if (working) return;
        if (queueItems.length === 0) return;
        working = true;
        const cart = queueItems.shift();
        worker(cart, function() {
            working = false;
            runNext();
        })
    }
 
    return function(cart) {
        queueItems.push(cart);
        setTimeout(runNext, 0);
    }
}
 
function calcCartWorker(cart, done) {
    calcCartTotal(cart, function(total) {
        updateTotalDom(total);
        done(total);
    });
}
 
const updateTotalQueue = Queue(calcCartWorker);
 
 
// to-be
function Queue(worker) {
    const queueItems = [];
    let working = false;
 
    function runNext() {
        if (working) return;
        if (queueItems.length === 0) return;
        working = true;
        const item = queueItems.shift();
        // worker 에 data 전달
        worker(item.data, function() {
            working = false;
            runNext();
        })
    }
 
    return function(data, callback) {
        queueItems.push({
            data: data,
            // 만약 콜백이 없다면 아무것도 하지 않는 함수 사용
            callback: callback || function() {} 
        });
        setTimeout(runNext, 0);
    }
}
 
function calcCartWorker(cart, done) {
    calcCartTotal(cart, function(total) {
        updateTotalDom(total);
        done(total);
    });
}
 
const updateTotalQueue = Queue(calcCartWorker);

작업이 완료되었을 때 콜백 부르기

//as-is
function Queue(worker) {
    const queueItems = [];
    let working = false;
 
    function runNext() {
        if (working) return;
        if (queueItems.length === 0) return;
        working = true;
        const item = queueItems.shift();
        worker(item.data, function() {
            working = false;
            runNext();
        })
    }
 
    return function(data, callback) {
        queueItems.push({
            data: data,
            callback: callback || function() {} 
        });
        setTimeout(runNext, 0);
    }
}
 
function calcCartWorker(cart, done) {
    calcCartTotal(cart, function(total) {
        updateTotalDom(total);
        done(total);
    });
}
 
const updateTotalQueue = Queue(calcCartWorker);
 
// to-be
function Queue(worker) {
    const queueItems = [];
    let working = false;
 
    function runNext() {
        if (working) return;
        if (queueItems.length === 0) return;
        working = true;
        const item = queueItems.shift();
        // done() 이 인자를 받도록 만듬
        worker(item.data, function(value) {
            working = false;
            // 1. item.callback 을 비동기로 호출
            // 2. callback 에 인자 전달
            setTimeout(item.callback, 0, value);
            runNext();
        })
    }
 
    return function(data, callback) {
        queueItems.push({
            data: data,
            callback: callback || function() {} 
        });
        setTimeout(runNext, 0);
    }
}
 
function calcCartWorker(cart, done) {
    calcCartTotal(cart, function(total) {
        updateTotalDom(total);
        done(total);
    });
}
 
const updateTotalQueue = Queue(calcCartWorker);
 

Queue() 는 액션에 새로운 능력을 줄 수 있는 고차함수이다.

const updateTotalQueue = Queue(calcCartWorker);
// 함수 = 함수(함수)

큐를 건너뛰도록 만들기

// as-is
function Queue(worker) {
    const queueItems = [];
    let working = false;
 
    function runNext() {
        if (working) return;
        if (queueItems.length === 0) return;
        working = true;
        const item = queueItems.shift();
        worker(item.data, function(value) {
            working = false;
            setTimeout(item.callback, 0, value);
            runNext();
        })
    }
 
    return function(data, callback) {
        queueItems.push({
            data: data,
            callback: callback || function() {} 
        });
        setTimeout(runNext, 0);
    }
}
 
function calcCartWorker(cart, done) {
    calcCartTotal(cart, function(total) {
        updateTotalDom(total);
        done(total);
    });
}
 
const updateTotalQueue = Queue(calcCartWorker);
 
// to-be
// 이름 변경, 보관 할 수 있는 최대 큐 크기를 전달
function DroppingQueue(max, worker) {
    const queueItems = [];
    let working = false;
 
    function runNext() {
        if (working) return;
        if (queueItems.length === 0) return;
        working = true;
        const item = queueItems.shift();
        worker(item.data, function(value) {
            working = false;
            setTimeout(item.callback, 0, value);
            runNext();
        })
    }
 
    return function(data, callback) {
        queueItems.push({
            data: data,
            callback: callback || function() {} 
        });
        // 큐에 추가한 후에 항목이 max 를 넘긴다면 보두 버림
        while(queueItems.length > max) queueItems.shift();
        setTimeout(runNext, 0);
    }
}
 
function calcCartWorker(cart, done) {
    calcCartTotal(cart, function(total) {
        updateTotalDom(total);
        done(total);
    });
}
 
const updateTotalQueue = DroppingQueue(1, calcCartWorker);
연습 문제

드로핑 큐를 사용해 문제를 해결해라

const document = {};
function saveAjax(document, callback) {}
 
 
// as-is
saveButton.addEventListener('click', function() {
    saveAjax(document)
})
 
// to-be
const saveAjaxQueued = DroppingQueue(1, saveAjax);
saveButton.addEventListener('click', function() {
    saveAjaxQueued(document)
})