- Published on
조합된 즉시 평가 함수들을 자세히 디버깅해보자
- Authors

- Name
- Nostrss
- Github
- Github

지금까지의 여정
지금까지 함수형 프로그래밍을 위한 도구와 같은 기능들을 하나씩 만들어왔다.
- map - 변환
- filter - 거르기
- reduce - 축약
- go, arguments - 순차 실행
- pipe 디버깅 - 합성 함수 추적
- curry - 부분 적용
- range, take - 범위 생성, 자르기
- L.map, L.filter - 지연 평가
각 함수를 개별적으로 이해하는 건 그리 어렵지 않았다. 그런데 이 함수들이 한꺼번에 조합되면? 내부에서 정확히 어떤 일이 벌어지는 걸까?
오늘은 이 함수들이 파이프라인으로 엮였을 때, 실행 흐름을 추적해보자.
오늘의 코드
먼저 오늘 사용할 함수들을 전부 모아보자.
const log = console.log
const curry =
(f) =>
(a, ..._) =>
_.length ? f(a, ..._) : (..._) => f(a, ..._)
const reduce = curry((f, acc, iter) => {
if (!iter) {
iter = acc[Symbol.iterator]()
acc = iter.next().value
}
for (const a of iter) {
acc = f(acc, a)
}
return acc
})
const go = (...args) => reduce((a, f) => f(a), args)
const range = (l) => {
let i = -1
let res = []
while (++i < l) {
res.push(i)
}
return res
}
const map = curry((f, iter) => {
let res = []
for (const a of iter) {
res.push(f(a))
}
return res
})
const filter = curry((f, iter) => {
let res = []
for (const a of iter) {
if (f(a)) res.push(a)
}
return res
})
const take = curry((l, iter) => {
let res = []
for (const a of iter) {
res.push(a)
if (res.length == l) return res
}
return res
})
그리고 이 함수들을 조합한 최종 코드다.
go(
range(10),
map((n) => n + 10),
filter((n) => n % 2),
take(2),
log
)
이 코드가 어떻게 실행되어 최종 결과를 내는지, 한 단계씩 따라가보자.
0단계: range(10) 실행 ⭐
go에 들어가기 전에, 첫 번째 인자인 range(10)부터 실행된다. 함수 호출이니까 당연히 먼저 평가된다.
const range = (l) => {
let i = -1
let res = []
while (++i < l) {
res.push(i)
}
return res
}
상태 변화 추적
l= 10i= -1,res= []while루프가 돈다:++i→i = 0,0 < 10→res.push(0)→res = [0]++i→i = 1,1 < 10→res.push(1)→res = [0, 1]- ...
++i→i = 9,9 < 10→res.push(9)→res = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]++i→i = 10,10 < 10❌ → 루프 종료
결과: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9] — 10개짜리 배열이 즉시 생성됐다.
1단계: go 함수 진입 ⭐⭐
이제 go 함수가 호출된다.
const go = (...args) => reduce((a, f) => f(a), args)
go에 들어온 인자들을 정리해보자.
args[0]:[0, 1, 2, 3, 4, 5, 6, 7, 8, 9](방금 만든 배열)args[1]:map((n) => n + 10)— curry 덕분에 함수를 반환한 상태args[2]:filter((n) => n % 2)— 역시 함수를 반환한 상태args[3]:take(2)— 역시 함수를 반환한 상태args[4]:log함수
curry가 하는 일
잠깐, map((n) => n + 10)이 왜 함수를 반환하는 걸까?
map은 curry로 감싸져 있다. curry의 정의를 다시 보면:
const curry =
(f) =>
(a, ..._) =>
_.length ? f(a, ..._) : (..._) => f(a, ..._)
map((n) => n + 10)을 호출하면, 첫 번째 인자 a에 (n) => n + 10이 들어간다. 나머지 인자 _는 비어있다. _.length는 0이므로 false. 따라서 (..._) => f(a, ..._), 즉 "나중에 이터러블을 받으면 그때 실행할게" 라는 함수가 반환된다.
filter((n) => n % 2)와 take(2)도 같은 원리다.
go → reduce 호출
go는 곧바로 reduce를 호출한다.
f(Reducer):(a, f) => f(a)acc(두 번째 인자 자리):args배열 전체iter: 전달 안 함 (undefined)
reduce 초기화
const reduce = curry((f, acc, iter) => {
if (!iter) {
iter = acc[Symbol.iterator]()
acc = iter.next().value
}
// ...
iter가 undefined이므로 if (!iter) 안으로 들어간다.
acc(=args배열)에서 이터레이터를 뽑아낸다iter.next().value로 첫 번째 값[0, 1, 2, 3, 4, 5, 6, 7, 8, 9]를 꺼내서acc에 넣는다- 이터레이터 포인터는 이제 두 번째 요소(=
map이 반환한 함수)를 가리킨다
이제 for 루프가 시작된다. acc는 배열 [0, ..., 9], 이터레이터에는 함수 4개가 남아있다.
2단계: map 실행 ⭐
reduce의 for 루프, 첫 번째 반복이다.
for (const a of iter) {
acc = f(acc, a)
}
a:map((n) => n + 10)이 반환한 함수acc:[0, 1, 2, 3, 4, 5, 6, 7, 8, 9]- Reducer
f:(a, f) => f(a)→acc를a에게 넘긴다
f(acc, a) → a(acc) → map의 커리된 함수에 배열이 들어간다.
이제 map의 본체가 실행된다.
const map = curry((f, iter) => {
let res = []
for (const a of iter) {
res.push(f(a))
}
return res
})
f:(n) => n + 10iter:[0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
상태 변화 추적
요소 a | f(a) = a + 10 | res |
|---|---|---|
| 0 | 10 | [10] |
| 1 | 11 | [10, 11] |
| 2 | 12 | [10, 11, 12] |
| 3 | 13 | [10, 11, 12, 13] |
| 4 | 14 | [10, 11, 12, 13, 14] |
| 5 | 15 | [10, 11, 12, 13, 14, 15] |
| 6 | 16 | [10, 11, 12, 13, 14, 15, 16] |
| 7 | 17 | [10, 11, 12, 13, 14, 15, 16, 17] |
| 8 | 18 | [10, 11, 12, 13, 14, 15, 16, 17, 18] |
| 9 | 19 | [10, 11, 12, 13, 14, 15, 16, 17, 18, 19] |
10개의 요소를 전부 변환했다. 결과: [10, 11, 12, 13, 14, 15, 16, 17, 18, 19]
새로운 acc는 이 배열이 된다.
3단계: filter 실행 ⭐
reduce의 for 루프, 두 번째 반복이다.
a:filter((n) => n % 2)가 반환한 함수acc:[10, 11, 12, 13, 14, 15, 16, 17, 18, 19]
f(acc, a) → a(acc) → filter의 커리된 함수에 배열이 들어간다.
const filter = curry((f, iter) => {
let res = []
for (const a of iter) {
if (f(a)) res.push(a)
}
return res
})
f:(n) => n % 2iter:[10, 11, 12, 13, 14, 15, 16, 17, 18, 19]
상태 변화 추적
요소 a | f(a) = a % 2 | 통과? | res |
|---|---|---|---|
| 10 | 0 | ❌ | [] |
| 11 | 1 | ✅ | [11] |
| 12 | 0 | ❌ | [11] |
| 13 | 1 | ✅ | [11, 13] |
| 14 | 0 | ❌ | [11, 13] |
| 15 | 1 | ✅ | [11, 13, 15] |
| 16 | 0 | ❌ | [11, 13, 15] |
| 17 | 1 | ✅ | [11, 13, 15, 17] |
| 18 | 0 | ❌ | [11, 13, 15, 17] |
| 19 | 1 | ✅ | [11, 13, 15, 17, 19] |
10개의 요소를 전부 검사했다. 5개가 통과. 결과: [11, 13, 15, 17, 19]
새로운 acc는 이 배열이 된다.
4단계: take 실행 ⭐
reduce의 for 루프, 세 번째 반복이다.
a:take(2)가 반환한 함수acc:[11, 13, 15, 17, 19]
f(acc, a) → a(acc) → take의 커리된 함수에 배열이 들어간다.
const take = curry((l, iter) => {
let res = []
for (const a of iter) {
res.push(a)
if (res.length == l) return res
}
return res
})
l: 2 (2개만 가져가겠다)iter:[11, 13, 15, 17, 19]
상태 변화 추적
요소 a | res | res.length == 2? |
|---|---|---|
| 11 | [11] | ❌ (1) |
| 13 | [11, 13] | ✅ (2) → return |
2개를 채우자마자 조기 종료(early return)! 15, 17, 19는 건드리지도 않았다.
결과: [11, 13]
새로운 acc는 이 배열이 된다.
5단계: log 실행
reduce의 for 루프, 네 번째(마지막) 반복이다.
a:log함수 (console.log)acc:[11, 13]
f(acc, a) → a(acc) → log([11, 13]) → [11, 13] 출력!
reduce의 루프가 끝나고, 최종 acc가 반환된다. go도 이 값을 그대로 반환한다.
전체 흐름 한눈에 보기
range(10) → [0, 1, 2, 3, 4, 5, 6, 7, 8, 9] (10개 즉시 생성)
↓
map(n => n + 10) → [10, 11, 12, 13, 14, 15, 16, 17, 18, 19] (10개 전부 변환)
↓
filter(n => n % 2) → [11, 13, 15, 17, 19] (10개 전부 검사, 5개 통과)
↓
take(2) → [11, 13] (2개만 가져감, 조기 종료)
↓
log → [11, 13] 출력
총 연산 횟수
| 단계 | 함수 | 연산 횟수 | 설명 |
|---|---|---|---|
| 0단계 | range | 10회 | 0~9까지 10개 생성 |
| 2단계 | map | 10회 | 10개 전부 +10 변환 |
| 3단계 | filter | 10회 | 10개 전부 % 2 검사 |
| 4단계 | take | 2회 | 2개만 가져감 |
| 합계 | 32회 |
우리가 최종적으로 필요했던 값은 2개뿐이다. [11, 13].
그런데 range는 10개를 다 만들었고, map은 10개를 다 변환했고, filter는 10개를 다 검사했다. 결국 take에서 2개만 골라갔을 뿐이다.
2개만 필요한데 32번이나 연산한 것이다.
만약 range(10000)이었다면? map이 10,000개를 변환하고, filter가 10,000개를 검사하고... 결국 take(2)에서 2개만 가져간다. 나머지 연산은 전부 낭비다.
이런 경우에는 지연평가 방식으로 바꾸면 훨씬 효율적인 코드를 작성할 수 있다.
출처
인프런 함수형 프로그래밍과 JavaScript ES6+ 강의를 학습하고 정리한 내용입니다.

