- Published on
게으른 함수로 부지런한 함수 만들기 🐢
- Authors

- Name
- Nostrss
- Github
- Github

L.entries
Object.entries()는 뭘 하나?
자바스크립트에서 객체의 키-값 쌍을 배열로 가져올 때 Object.entries()를 사용한다.
Object.entries({ a: 1, b: 2, c: 3 })
// [["a", 1], ["b", 2], ["c", 3]]
편리하지만, 호출하는 순간 모든 키-값 쌍을 담은 배열이 즉시 생성된다. 이미 익숙한 패턴이다. range가 배열을 즉시 만들었던 것처럼.
L.entries 구현
제너레이터를 사용해서 지연 평가 버전을 만들어보자.
L.entries = function* (obj) {
for (const k in obj) {
yield [k, obj[k]]
}
}
for...in으로 객체의 키를 순회하면서, 각 키-값 쌍을 yield로 하나씩 내보낸다.
const iterator = L.entries({ a: 1, b: 2, c: 3 })
console.log(iterator) // L.entries {<suspended>}
console.log(iterator.next()) // { value: ["a", 1], done: false }
console.log(iterator.next()) // { value: ["b", 2], done: false }
console.log(iterator.next()) // { value: ["c", 3], done: false }
console.log(iterator.next()) // { value: undefined, done: true }
Object.entries()와의 차이는 range와 L.range의 차이와 같다. 호출 즉시 배열을 만드느냐, 요청할 때 하나씩 만들어주느냐의 차이다.
그리고 결과가 이터러블이기 때문에, 우리가 만들어온 map, filter, reduce, pipe 같은 함수들과 자연스럽게 조합할 수 있다. 이것이 핵심이다.
join
Array.prototype.join의 한계
자바스크립트의 Array.prototype.join은 배열에서만 동작한다.
;[1, 2, 3].join(' - ') // "1 - 2 - 3"
만약 배열이 아닌 이터러블(예를 들어 제너레이터의 결과)에서 join을 사용하고 싶다면? 직접 만들어야 한다.
모든 이터러블에서 동작하는 join
curry와 reduce를 활용하면 간단하다.
const join = curry((sep = ',', iter) => reduce((a, b) => `${a}${sep}${b}`, iter))
reduce가 이터러블 프로토콜을 따르기 때문에, 이 join은 배열뿐만 아니라 모든 이터러블에서 동작한다.
// 배열과 함께
join(' - ', [1, 2, 3]) // "1 - 2 - 3"
// 제너레이터 결과와 함께
join(
' | ',
L.map((a) => a * 10, [1, 2, 3])
) // "10 | 20 | 30"
L.entries + join 조합
이 두 함수를 조합하면 재미있는 것을 할 수 있다. 예를 들어, 객체를 쿼리스트링으로 변환해보자.
const queryStr = pipe(
L.entries,
L.map(([k, v]) => `${k}=${v}`),
join('&')
)
queryStr({ limit: 10, offset: 20, type: 'notice' })
// "limit=10&offset=20&type=notice"
L.entries가 객체를 [키, 값] 쌍의 이터러블로 만들고, L.map이 각 쌍을 키=값 문자열로 변환하고, join이 &로 이어붙인다. 중간에 배열이 한 번도 만들어지지 않는다. 모든 것이 이터러블 프로토콜 위에서 흘러간다.
takeAll
take 함수 복습
이전에 만들었던 take 함수를 잠깐 다시 보자.
const take = curry((l, iter) => {
let res = []
for (const a of iter) {
res.push(a)
if (res.length == l) return res
}
return res
})
take는 이터러블에서 앞에서부터 l개만 가져와서 배열로 반환한다. take(3, [1, 2, 3, 4, 5])는 [1, 2, 3]을 반환한다.
takeAll = take(Infinity)
그렇다면 take(Infinity)는 뭘까? 이터러블의 모든 값을 배열로 수집한다.
const takeAll = take(Infinity)
끝이다. take에 curry가 적용되어 있으니, take(Infinity)는 "이터러블을 받아서 모든 값을 배열로 만드는 함수"를 반환한다.
takeAll(L.map((a) => a * 10, [1, 2, 3])) // [10, 20, 30]
takeAll(L.filter((a) => a % 2, [1, 2, 3, 4])) // [1, 3]
takeAll이 왜 중요한지는 바로 다음 섹션에서 드러난다. 지연 평가의 결과를 즉시 평가로 전환하는 다리 역할을 하기 때문이다.
L.map으로 만드는 map
기존 map 코드
지금까지 사용해온 map은 이렇게 생겼다.
const map = curry((f, iter) => {
let res = []
for (const a of iter) {
res.push(f(a))
}
return res
})
for문을 돌면서 결과를 push한다. 직관적이고 잘 동작한다.
L.map + takeAll = map
그런데 잠깐, 위의 map이 하는 일을 분해해보면 이렇다.
- 이터러블의 각 요소에 함수
f를 적용한다 →L.map이 하는 일 - 그 결과를 전부 배열로 모은다 →
takeAll이 하는 일
그렇다면 map을 이렇게 다시 쓸 수 있지 않을까?
const map = curry(pipe(L.map, takeAll))
이게 전부다. L.map으로 지연 이터레이터를 만들고, takeAll로 모든 값을 배열로 수집한다.
실제로 동작하는지 확인해보자.
map((a) => a + 10, [1, 2, 3]) // [11, 12, 13]
기존 map과 완전히 동일한 결과가 나온다.
동작 원리를 풀어보면
map(a => a + 10, [1, 2, 3])이 실행되면 내부적으로 이런 일이 일어난다.
// 1단계: L.map이 지연 이터레이터를 만든다
const iterator = L.map((a) => a + 10, [1, 2, 3])
// 아직 아무 계산도 하지 않았다
// 2단계: takeAll이 이터레이터의 모든 값을 꺼낸다
takeAll(iterator) // [11, 12, 13]
// next()를 반복 호출해서 모든 값을 배열로 모은다
pipe가 L.map의 결과를 takeAll에게 전달하고, curry가 인자를 나눠서 받을 수 있게 해준다. 지금까지 만들어온 도구들이 맞물려서 돌아가는 순간이다.
L.filter로 만드는 filter
기존 filter 코드
filter도 마찬가지다. 기존 코드를 보자.
const filter = curry((f, iter) => {
let res = []
for (const a of iter) {
if (f(a)) res.push(a)
}
return res
})
L.filter + takeAll = filter
filter가 하는 일도 분해하면 똑같은 패턴이다.
- 이터러블의 각 요소를 조건으로 걸러낸다 →
L.filter가 하는 일 - 그 결과를 전부 배열로 모은다 →
takeAll이 하는 일
const filter = curry(pipe(L.filter, takeAll))
동일한 패턴이다. 확인해보자.
filter((a) => a % 2, [1, 2, 3, 4]) // [1, 3]
기존 filter와 완전히 같은 결과다.
패턴이 보인다
두 함수를 나란히 놓으면 패턴이 명확해진다.
const map = curry(pipe(L.map, takeAll))
const filter = curry(pipe(L.filter, takeAll))
지연 함수 + takeAll = 즉시 함수
L.map과 L.filter는 이터러블을 하나씩 처리하는 "엔진"이고, takeAll은 그 엔진이 만들어낸 결과를 전부 모아서 배열로 돌려주는 "수집기"다.
정리
이번에 다룬 내용의 핵심은 하나다.
지연 평가 함수는 즉시 평가 함수의 빌딩 블록이다.
L.map+takeAll=mapL.filter+takeAll=filter
여기서 takeAll은 take(Infinity)다. 그렇다면 take(n)은 무엇인가? "처음 n개만 모으는 것"이다. 즉, take의 인자를 바꾸는 것만으로 지연 평가와 즉시 평가를 자유롭게 오갈 수 있다.
// 지연 함수 + take(5) → 앞에서 5개만
pipe(
L.map((a) => a * 10),
take(5)
)
// 지연 함수 + takeAll → 전부 = 즉시 평가와 동일
pipe(
L.map((a) => a * 10),
takeAll
)
처음에 L.map과 L.filter를 만들었을 때는, 이것들이 "필요할 때 하나씩 계산하는 효율적인 버전"이라고 생각했다. 그것도 맞다. 하지만 더 중요한 것은, 이들이 함수 조합의 기반이 된다는 점이다.
출처
인프런 함수형 프로그래밍과 JavaScript ES6+ 강의를 학습하고 정리한 내용입니다.

