[JavaScript] 지연성을 위한 ES6+ 함수형 프로그래밍 - 1

Hyuk·2023년 2월 18일
0
post-thumbnail

제너레이터

지연성을 위한 함수형 프로그래밍을 설명하기에 앞서 제너레이터 함수를 먼저 언급할 필요가 있다.

제너레이터는 이터레이터이자 이터러블을 생성하는 함수이다.

function *gen() {
	yield 1;
  	yield 2;
  	yield 3;
}

let iter = gen();

for (const a of iter) {
	console.log(a)
}

제너레이터는 위와 같이 * 을 함수명 앞에 붙여서 생성한다.
또한 제너레이터 함수는 yield 를 통해 값을 생성한다.

이후 제너레이터 함수를 실행하면 for ... of 등 문법을 사용할 수 있는 이터러블 함수를 만들어낸다.

참고로 제너레이터 함수에 yield 가 아닌 return 을 사용할 수 있는데 return 값은 순회하지 않는다.

또한 다음과 같이 Symbol.iterator 메소드를 실행한 값은 자기자신을 뜻하는
well-formed-iterator 이다.

console.log(iter[Symbol.iterator]() === iter)  // true

즉, 제너레이터 함수는 순회할 수 있는 값들을 yield 라는 문장으로 만들어 낸다라는 점이 특징이다.

range와 L.range

매개변수로 전달된 값의 개수만큼 배열을 만들어 내는 range 함수는 다음과 같다.

const range = (length) => {
	let i = 0;
  	let list = [];
  	while (i++ < length) {
		list.push(i)
	}
  	return list
}

console.log(range(4))  // [1, 2, 3, 4]

똑같은 기능을 하지만 다르게 동작을 하는 L.range 함수는 다음과 같다.

const L = {};
L.range = function *(length) {
	let i = 0;
  	while (i++ < length) {
		yield i
	}
}

const list = L.range(4);
console.log(list)

사실 [1, 2, 3, 4] 과 같은 배열의 값을 기대했지만 <suspended> 라는 알 수 없는 값이 출력이 되었다.
이는 알고보면 next 메소드를 가지고 있는 이터레이터이다.

즉 다음과 같이 순회하는 데엔 문제가 없다.

for (const a of list){
	console.log(a)  //  1, 2, 3, 4
}

지연성을 위한 함수형 프로그래밍을 하는 이유

그럼 왜 굳이 제너레이터 함수를 통해 L.range 함수를 만들어 순회를 할까?
이는 효율성과 밀접한 관계가 있다.

다음의 range 코드를 보자.

const range = (length) => {
	let i = 0;
  	let list = [];
  	while (i++ < length) {
      	console.log(i)   // 변경된 부분
		list.push(i)
	}
  	return list
}

console.log(range(4))

출력은 다음과 같이 나온다.

그럼 이제 L.range 함수의 코드를 보자.

const L = {};
L.range = function *(length) {
	let i = 0;
  	while (i++ < length) {
      	console.log(i)   // 변경된 부분
		yield i
	}
}

const list = L.range(4);
console.log(list)

분명 range 함수와 L.range 함수 while 문 중간에 console.log(i) 코드를 동일하게 삽입을 했는 데에도 L.range 함수는 출력이 되지 않는 걸 볼 수가 있다.

그럼 언제 yield 가 언제 평가가 되는지, 이 부분이 제대로 동작하는 것이 맞는지 여러 의문투성이이다.


정답은 순회할 때이다.
이터레이터 내부를 순회할 때마다 하나씩 값을 평가한다.

const L = {};
L.range = function *(length) {
	let i = 0;
  	while (i++ < length) {
      	console.log(i)
		yield i
	}
}

const list = L.range(4);
console.log(list.next());  // 변경된 부분

next 를 통해 값을 순회하면서 while 문에 있는 문장을 평가하게 되고 1 이라는 값도 출력되는 걸 볼 수가 있다.

자, 그럼 이 부분이 왜 효율성와 밀접한 관계가 있을까?

생각해보면 const list = [1, 2, 3, 4] 는 당장 필요한 값이라고 생각하지 않을 것이다.

해당 값들을 순회하면서 데이터를 추출하거나 변경해서 출력할 때에 비로소 필요한 값이라고 생각한다.

예를 들어 이커머스 웹사이트에서 사용자가 장바구니 페이지에 접속해 있을 때,
개발자는 사용자의 장바구니에 담겨 있는 상품들의 총합을 계산해 보여줘야 할 것이다.

이때 다음과 같이 map 함수와 reduce 함수를 통해 코드를 작성할 수 있다.

const checkItems = [
  {id: 1, product: '상의', price: 5000, quantity: 2},
  {id: 2, product: '하의', price: 15000, quantity: 3},
  {id: 3, product: '양말', price: 2500, quantity: 1},
]

const totalPrice = checkItems
  .map(item => item.price * item.quantity)
  .reduce((acc, price) => acc + price, 0)

여기에서 checkItems 라는 배열값은 사용자의 장바구니에 담겨 있는 정보들을 담은 배열일 뿐이고, 해당 값에서 필요한 부분을 추출해야 한다.

특히 checkItems 의 배열을 프론트단에서 즉시 생성해야하는 배열이라는 효용성은 더욱 커질 것이다.

지연성을 고려하지 않은 방법의 동작 순서는 다음과 같다.

  1. checkItems 이라는 배열을 만든다.
  2. totalPrice 함수를 통해 값을 추출한다.

하지만 지연성을 고려한 방법의 동작 순서는 다음과 같다.

  1. 배열 형태가 아닌 채로 (평가가 완벽히 되지 않는 상태) 대기
  2. map 함수를 만나면 필요한 값만 평가를 진행한다.

지연성 평가를 통한 효율성 체크

그럼 얼마나 효율성 차이가 있을지 평가해보자.
효율성 평가를 위해 다음과 같이 reduce 함수와 acc 함수를 만들었다.

const add = (a, b) => a + b;

const reduce = (f, acc, iter) => {
    if (!iter) {
        iter = acc[Symbol.iterator]();
    }
    for(const a of iter) {
        acc = f(acc, a)
    }
    return acc;
}
const test = (name, time, f) => {
	console.time(name)
  	while(--time) f();
 	console.timeEnd(name)
}

test('L.range', 10, () => reduce(add, L.range(1000000)))
test('range', 10, () => reduce(add, range(1000000)))

결과는 다음과 같다.

profile
프론트엔드 개발자

0개의 댓글