필요한 만큼만 계산하라
“일부 결과만” 필요하다면, 배열을 끝까지 가공하는 대신 지연 평가(Lazy Evaluation) 파이프라인을 만들어 조건을 만족하는 순간 즉시 멈추는 구조가 더 유리하다.
필요한 만큼만 계산하라: Lazy Evaluation로 조기 종료
한 문장 결론: take(2)처럼 “일부 결과만” 필요하다면, 배열을 끝까지 가공하는 대신 지연 평가(Lazy Evaluation) 파이프라인을 만들어 조건을 만족하는 순간 즉시 멈추는 구조가 더 유리하다.
요약
- “조건을 만족하는 2명만” 뽑는데도 전체 데이터를
map → filter → map → filter로 끝까지 훑으면 불필요한 계산이 커진다. Generator function(function*)으로LazyMap,LazyFilter를 만들면take(2)가 파이프라인 전체를 조기 종료(Early Termination) 시킬 수 있다.- 진짜 이득을 내려면 “중간 배열 생성”을 없애야 한다.
range부터 데이터 생성까지 이터레이터로 흘려 보내야 한다. - 상황에 따라 함수형 파이프라인보다 단일
for...of루프가 더 단순하고 빠를 수도 있다.
배경/문제
요구사항이 이런 식으로 왔다고 치자.
- 사용자 데이터에서 포인트 × 가족 수를 계산한다.
- 그 값이 1000 이상인 사람들 중,
- 그 값의 제곱근을 구해 소수점을 버린 값이 홀수인 사람을 찾는다.
- 그중 두 사람의 마지막 계산된 포인트(여기서는 “조건을 만족하는 순서대로 2개”)를 뽑는다.
문제는 결과가 2개뿐인데도, 전체를 끝까지 변환/필터링하는 흐름이라면:
- 계산량이 커지고
- 중간 배열/객체가 늘어나 GC 부담이 커지고
- “2개만 필요”한 요구치고는 처리 시간이 길어질 수 있다
핵심 개념
Iterable/Iterator 프로토콜
for...of로 순회 가능한 객체는 이터러블(Iterable)이고, 실제 순회 상태를 갖는 객체가 이터레이터(Iterator)다. 이 구조를 따르면 “하나씩 꺼내서 처리하다가 멈추기”가 가능해진다.
Generator function(function*)
function*는 호출 시점에 값을 전부 만들지 않고, next()가 호출될 때마다 값을 하나씩 만들어 내는 이터레이터 생성기다.
해결 접근
핵심은 “필요한 만큼만 계산”하게 만드는 것.
take(2)가 소비자(consumer)가 되어 “2개가 모이면 즉시 반환”한다.map/filter는 생산자(producer)로서 “요청받을 때만 값 1개를 계산”한다.- 이 둘을 이터레이터 체인으로 연결하면, 조건을 만족하는 2개를 찾는 순간 전체가 멈춘다.
구현(코드)
1) curry, go, take 만들기
const curry = (f) => (a, ...rest) =>
rest.length ? f(a, ...rest) : (...rest2) => f(a, ...rest2);
const go = (...args) => args.reduce((acc, f) => f(acc));
const take = curry((count, iter) => {
const res = [];
for (const item of iter) {
res.push(item);
if (res.length === count) return res;
}
return res;
});기대 결과: take(2)는 2개를 모으는 순간 순회를 멈추며, 이후 단계는 더 계산되지 않는다.
2) eager(즉시) map/filter (비교용)
배열 기반으로 중간 결과를 계속 생성하는 방식이다.
const map = curry((func, iter) => {
const ret = [];
for (const item of iter) ret.push(func(item));
return ret;
});
const filter = curry((pred, iter) => {
const ret = [];
for (const item of iter) if (pred(item)) ret.push(item);
return ret;
});기대 결과: 파이프라인을 구성하기 쉽지만, 데이터가 많을수록 중간 배열 생성 비용이 커진다.
3) 데이터 생성도 이터레이터로: rangeL, genMember
성능 이야기를 할 때 자주 놓치는 포인트가 있다.
members = map(genMember, range(...))처럼 배열을 먼저 만들면, 이후 단계가 lazy여도 “데이터 생성 비용”은 이미 선불로 다 낸다.
그래서 range부터 지연 평가로 만든다.
const rangeL = function* (limit) {
let i = 0;
while (i < limit) yield i++;
};
const genMember = () => ({
family: Math.ceil(Math.random() * 8),
point: Math.ceil(Math.random() * 8000),
});기대 결과: rangeL는 값을 요청받을 때마다 하나씩 만들어 내며, 메모리에 큰 배열을 쌓지 않는다.
4) LazyMap / LazyFilter 구현
이제 map/filter를 generator로 만든다.
const L = {};
L.map = curry(function* (func, iter) {
for (const item of iter) yield func(item);
});
L.filter = curry(function* (pred, iter) {
for (const item of iter) if (pred(item)) yield item;
});기대 결과: 아직 아무것도 계산되지 않는다. take 같은 소비자가 값을 요구할 때만 yield로 흘러간다.
5) 요구사항 파이프라인: 2개 나오면 바로 종료
const calcPointList = go(
rangeL(1_000_000),
// 1) 멤버 생성도 lazy로 흘린다
L.map(() => genMember()),
// 2) calPoint 계산
L.map(({ family, point }) => {
const calPoint = family * point;
return { family, point, calPoint };
}),
// 3) calPoint >= 1000
L.filter(({ calPoint }) => calPoint >= 1000),
// 4) floor(sqrt(calPoint)) 추출
L.map(({ calPoint }) => Math.floor(Math.sqrt(calPoint))),
// 5) 홀수만
L.filter((sqrtPoint) => (sqrtPoint % 2) === 1),
// 6) 2개만
take(2)
);
console.log(calcPointList);기대 결과: 조건을 만족하는 값 2개를 찾는 순간, 그 뒤 데이터는 더 만들지도/계산하지도 않는다.
검증 방법
take(2)에서 실제로 연산이 멈추는가? (로그를 넣어 호출 횟수를 확인)members처럼 대규모 배열을 만들지 않는가?rangeL부터 데이터 생성까지 이터레이터 체인으로 이어져 있는가?흔한 실수/FAQ
Q1. lazy로 바꿨는데도 왜 느리지?
대개 “데이터를 이미 배열로 만들어버린 경우”다.
members = map(genMember, range(...))처럼 큰 배열을 먼저 만들면, lazy는 후반 단계에만 적용된다. 데이터 생성부터 소비까지 이터레이터로 이어야 조기 종료 효과가 확실하다.
Q2. generator는 여러 번 순회할 수 있나?
대부분의 generator iterator는 한 번 소비하면 끝이다. 같은 흐름을 다시 돌리려면 “이터레이터를 반환하는 함수” 형태로 매번 새로 만들어야 한다.
Q3. “마지막 2개”가 필요하면 어떻게 하지?
“마지막”은 의미상 끝까지 봐야 할 가능성이 크다. 이 경우 take(2)처럼 조기 종료가 아니라, 고정 크기 버퍼로 마지막 N개만 유지하는 방식이 안전하다.
const takeLast = curry((count, iter) => {
const buf = [];
for (const item of iter) {
buf.push(item);
if (buf.length > count) buf.shift();
}
return buf;
});기대 결과: 전체를 순회하되, 메모리는 항상 count개만 유지한다.
Next.js에서의 주의사항
- 이 로직은 순수 자바스크립트 연산이라 서버/클라이언트 어디서든 실행 가능하지만, “어디에서 돌리느냐”에 따라 성능/부하 지점이 달라진다.
- 클라이언트에서 큰 반복 연산을 돌리면 UI가 버벅일 수 있다. 필요하면 서버에서 계산하거나, 클라이언트에서는 작업 분할/워커 같은 전략을 고려한다.
- 참고: Next.js Docs: Server and Client Components
결론
take(2)처럼 “조금만 필요”한 요구라면, 정답은 단순하다.
- 생산자는 lazy(이터레이터)로
- 소비자는 take로
- 2개가 모이면 즉시 종료
이 구조를 갖추면 “전체를 다 만들고 다 걸러서 마지막에 조금만 쓰는” 낭비를 줄일 수 있다.