자바스크립트 고차 함수 완전 정복: map, filter, reduce부터 직접 만드는 once, memoize까지
함수를 인자로 받거나 함수를 반환하는 고차 함수. map, filter, reduce를 제대로 쓰는 법부터 once, memoize, pipe, 커링처럼 실무에서 바로 쓰는 고차 함수를 직접 만들어보고, ['1','2','3'].map(parseInt) 같은 함정까지 정리했다.
['1', '2', '3'].map(parseInt)의 결과는?
먼저 퀴즈 하나. 문자열 배열을 숫자로 바꾸려고 이렇게 썼다.
["1", "2", "3"].map(parseInt);
[1, 2, 3]을 기대했겠지만 실제 결과는 이렇다.
[1, NaN, NaN]
이유는 글 뒷부분에서 풀어보고, 우선 이 코드의 주인공인 고차 함수부터 알아보자. map도, 우리가 매일 쓰는 addEventListener도 전부 고차 함수다.
고차 함수란?
고차 함수(Higher-Order Function)는 함수를 인자로 받거나, 함수를 반환하는 함수다.
자바스크립트에서 이게 가능한 이유는 함수가 일급 객체(First-Class Object)이기 때문이다. 함수를 숫자나 문자열 같은 다른 값과 똑같이 다룰 수 있다는 뜻이다.
| 일급 객체의 조건 | 자바스크립트 예시 |
|---|---|
| 변수에 담을 수 있다 | const greet = () => "hi"; |
| 다른 함수의 인자로 넘길 수 있다 | button.addEventListener("click", greet); |
| 다른 함수의 반환값이 될 수 있다 | const make = () => () => "hi"; |
고차 함수는 크게 두 종류로 나뉜다.
| 종류 | 예시 |
|---|---|
| 함수를 인자로 받는 고차 함수 | map, filter, reduce, setTimeout, addEventListener |
| 함수를 반환하는 고차 함수 | debounce, once, memoize, bind |
1. 함수를 인자로 받는 고차 함수: 배열 메서드
가장 자주 쓰는 고차 함수는 배열 메서드다. 아래 상품 데이터로 연습해보자.
const products = [
{ name: "키보드", price: 89000, category: "전자기기", inStock: true },
{ name: "마우스", price: 39000, category: "전자기기", inStock: false },
{ name: "머그컵", price: 12000, category: "생활용품", inStock: true },
{ name: "모니터", price: 259000, category: "전자기기", inStock: true },
{ name: "텀블러", price: 25000, category: "생활용품", inStock: true },
];
"재고 있는 상품의 이름 목록"과 "재고 있는 상품의 총액"을 구해보자.
// for 문으로 쓰면
const names = [];
let total = 0;
for (let i = 0; i < products.length; i++) {
if (products[i].inStock) {
names.push(products[i].name);
total += products[i].price;
}
}
// 고차 함수로 쓰면
const inStock = products.filter((p) => p.inStock);
const names = inStock.map((p) => p.name); // ['키보드', '머그컵', '모니터', '텀블러']
const total = inStock.reduce((sum, p) => sum + p.price, 0); // 385000
for 문은 어떻게 반복할지(인덱스, 조건, push)를 일일이 적어야 한다. 고차 함수는 무엇을 할지(거르고, 바꾸고, 합친다)만 적는다. 코드가 의도를 그대로 말해준다.
아래 미리보기에서 체크박스와 슬라이더를 움직여보자. 값이 바뀔 때마다 filter → map → reduce가 다시 실행된다.
자주 쓰는 배열 고차 함수 한눈에 보기
| 메서드 | 하는 일 | 반환값 | 예시 결과 |
|---|---|---|---|
| map | 각 요소를 변환 | 같은 길이의 새 배열 | 이름만 뽑은 배열 |
| filter | 조건에 맞는 요소만 | 새 배열 (길이 ≤ 원본) | 재고 있는 상품들 |
| reduce | 하나의 값으로 합침 | 아무 값 (숫자, 객체 등) | 총액 385000 |
| find | 조건에 맞는 첫 요소 | 요소 또는 undefined | "모니터" (10만 원 초과 첫 상품) |
| some | 하나라도 맞는가 | true / false | 품절 상품이 있나? true |
| every | 전부 맞는가 | true / false | 전부 1만 원 초과? true |
| forEach | 각 요소로 무언가 실행 | undefined | (반환값 없음) |
reduce로 그룹 나누기
reduce는 "배열을 받아 어떤 모양의 값이든 만들어내는" 가장 유연한 고차 함수다. 카테고리별로 상품을 묶어보자.
const byCategory = products.reduce((acc, p) => {
(acc[p.category] ??= []).push(p.name);
return acc;
}, {});
// { 전자기기: ['키보드', '마우스', '모니터'], 생활용품: ['머그컵', '텀블러'] }
그룹 나누기는 너무 자주 쓰여서 표준 함수가 생겼다. Object.groupBy(products, (p) => p.category)를 쓰면 같은 일을 한 줄로 할 수 있다 (2024년부터 주요 브라우저와 Node.js 21 이상에서 지원).
2. 함수를 반환하는 고차 함수: 직접 만들어보기
함수를 반환하는 고차 함수는 클로저(Closure)와 함께 쓰인다. 반환된 함수가 자신이 만들어질 때의 변수를 계속 기억하는 성질이다.
함수 공장: 설정값을 기억하는 함수
const multiplier = (n) => (x) => x * n;
const double = multiplier(2);
const triple = multiplier(3);
double(5); // 10
triple(5); // 15
multiplier(2)는 "2를 기억하는 새 함수"를 만들어 돌려준다. 이렇게 설정을 미리 넣어둔 함수를 찍어내는 것이 함수를 반환하는 고차 함수의 핵심 용도다.
once: 딱 한 번만 실행되는 함수
결제 버튼 중복 클릭, 초기화 코드 중복 실행을 막을 때 쓴다.
function once(fn) {
let called = false;
let result;
return (...args) => {
if (!called) {
called = true;
result = fn(...args);
}
return result;
};
}
const init = once(() => {
console.log("초기화 실행!");
return 42;
});
init(); // "초기화 실행!" 출력, 42 반환
init(); // 출력 없이 42만 반환
memoize: 계산 결과를 기억하는 함수
같은 입력이 반복되는 무거운 계산을 캐싱한다.
function memoize(fn) {
const cache = new Map();
return (arg) => {
if (cache.has(arg)) return cache.get(arg);
const result = fn(arg);
cache.set(arg, result);
return result;
};
}
const slowSquare = (n) => {
console.log(`계산 중: ${n}`);
return n * n;
};
const fastSquare = memoize(slowSquare);
fastSquare(4); // "계산 중: 4" 출력 후 16
fastSquare(4); // 출력 없이 바로 16 (캐시에서)
기존 함수에 기능 덧붙이기
원래 함수는 건드리지 않고, 앞뒤로 로그나 권한 체크 같은 기능을 감싸서 붙일 수 있다.
function withLogging(fn) {
return (...args) => {
console.log(`호출: ${fn.name}(${args.join(", ")})`);
const result = fn(...args);
console.log(`결과: ${result}`);
return result;
};
}
function sum(a, b) {
return a + b;
}
const loggedSum = withLogging(sum);
loggedSum(2, 3);
// 호출: sum(2, 3)
// 결과: 5
검색창 입력을 제어하는 debounce와 throttle도 정확히 이 구조다. 함수를 받아서, 호출 시점을 조절하는 새 함수를 돌려준다.
3. 함수 합성: 작은 함수를 이어 붙이기
작은 함수 여러 개를 파이프처럼 연결해서 하나의 함수로 만드는 것을 함수 합성(Function Composition)이라고 한다. reduce로 간단히 만들 수 있다.
const pipe = (...fns) => (x) => fns.reduce((acc, fn) => fn(acc), x);
const trim = (s) => s.trim();
const lower = (s) => s.toLowerCase();
const slug = (s) => s.replace(/\s+/g, "-");
const toSlug = pipe(trim, lower, slug);
toSlug(" Hello World Nuxt "); // "hello-world-nuxt"
각 함수는 한 가지 일만 하고, pipe가 왼쪽부터 순서대로 실행한다. 순서를 바꾸거나 단계를 추가하기도 쉽다.
4. 커링: 인자를 하나씩 받기
커링(Currying)은 여러 인자를 받는 함수를 인자 하나씩 받는 함수의 연속으로 바꾸는 것이다.
const add = (a) => (b) => a + b;
add(1)(2); // 3
const add10 = add(10);
[1, 2, 3].map(add10); // [11, 12, 13]
인자 일부를 미리 채워둔 함수(add10)를 만들 수 있어서, map 같은 고차 함수에 바로 넘기기 좋다.
고차 함수를 쓸 때 빠지기 쉬운 함정
함정 1. map(parseInt): 처음 퀴즈의 정답
map은 콜백에 (요소, 인덱스, 배열) 세 개의 인자를 넘긴다. 그런데 parseInt는 두 번째 인자를 진법(radix) 으로 받는다.
| 호출 | 의미 | 결과 |
|---|---|---|
| parseInt("1", 0) | 진법 0 → 기본값(10진법)으로 처리 | 1 |
| parseInt("2", 1) | 1진법은 존재하지 않음 | NaN |
| parseInt("3", 2) | 2진법에 3이라는 숫자는 없음 | NaN |
해결법은 콜백에 넘어가는 인자를 직접 정하는 것이다.
["1", "2", "3"].map(Number); // [1, 2, 3]
["1", "2", "3"].map((s) => parseInt(s, 10)); // [1, 2, 3]
함수를 고차 함수에 그대로 넘길 때(map(parseInt))는 그 함수가 추가 인자를 어떻게 해석하는지 꼭 확인하자. 헷갈리면 화살표 함수로 한 번 감싸는 게 안전하다.
함정 2. forEach는 아무것도 반환하지 않는다
const result = [1, 2, 3].forEach((x) => x * 2);
console.log(result); // undefined
새 배열이 필요하면 map, 단순히 반복하며 무언가 실행만 하면 forEach다.
함정 3. sort는 원본을 바꾼다
map, filter는 새 배열을 돌려주지만 sort, reverse, splice는 원본 배열 자체를 바꾼다. React나 Vue 상태를 다룰 때 특히 위험하다. 원본을 지키려면 ES2023에 추가된 메서드를 쓴다.
const nums = [3, 1, 2];
const sorted = nums.toSorted((a, b) => a - b);
nums; // [3, 1, 2] (그대로)
sorted; // [1, 2, 3]
| 원본을 바꾸는 메서드 | 새 배열을 돌려주는 버전 |
|---|---|
| sort() | toSorted() |
| reverse() | toReversed() |
| splice() | toSpliced() |
| arr[i] = x | with(i, x) |
함정 4. map 안에서 부작용 일으키기
map의 콜백은 "값을 변환해서 돌려주는" 용도다. 그 안에서 API를 호출하거나 외부 변수를 바꾸면, 읽는 사람이 map만 보고는 그런 일이 일어나는지 예상할 수 없다. 변환은 map, 실행은 forEach나 for...of로 역할을 나누자.
정리: 왜 알아두면 좋은가
| 하고 싶은 것 | 고차 함수 |
|---|---|
| 배열 변환 / 거르기 / 합치기 | map / filter / reduce |
| 그룹 나누기 | reduce 또는 Object.groupBy |
| 설정을 기억하는 함수 찍어내기 | 함수를 반환하는 함수 (multiplier) |
| 한 번만 실행 | once |
| 계산 결과 캐싱 | memoize |
| 기존 함수에 기능 덧붙이기 | 감싸는 함수 (withLogging, debounce) |
| 작은 함수 이어 붙이기 | pipe |
고차 함수의 핵심은 "무엇을 할지"와 "어떻게 반복할지"를 분리하는 것이다. 반복, 캐싱, 실행 횟수 제한 같은 "어떻게"는 고차 함수에 맡기고, 우리는 "무엇을"에 해당하는 작은 함수만 쓰면 된다. 그렇게 만든 작은 함수는 테스트하기 쉽고, 다른 곳에서 다시 쓰기도 쉽다. 다음에 for 문과 임시 배열을 만들려는 순간, map·filter·reduce로 의도를 한 줄에 담을 수 없는지 먼저 떠올려보자.