4.4 고차 함수와 함수형 프로그래밍
함수형 프로그래밍(FP)은 순수 함수와 불변 데이터를 기반으로 프로그램을 구성하는 패러다임입니다. JavaScript는 함수가 일급 객체이므로 함수형 스타일을 자연스럽게 지원합니다.
순수 함수 (Pure Function)
순수 함수는 두 가지 조건을 만족합니다.
- 동일한 입력 → 항상 동일한 출력
- 부수 효과(Side Effect) 없음 — 외부 상태를 변경하지 않음
// ✅ 순수 함수 — 예측 가능, 테스트 용이
function add(a, b) { return a + b; }
function double(arr) { return arr.map(x => x * 2); }
const uppercase = (str) => str.toUpperCase();
// ❌ 비순수 함수 — 외부 상태 의존/변경
let tax = 0.1;
function calcPrice(price) {
return price * (1 + tax); // 외부 변수 의존 → 비순수
}
function pushItem(arr, item) {
arr.push(item); // 인자를 직접 변환 → 부수 효과
return arr;
}
// ✅ 순수하게 변환
function calcPricePure(price, taxRate) {
return price * (1 + taxRate); // 모든 의존성을 인자로 받음
}
function pushItemPure(arr, item) {
return [...arr, item]; // 새 배열 반환
}
const original = [1, 2, 3];
const withFour = pushItemPure(original, 4);
console.log(original); // [1, 2, 3] — 변경 없음
console.log(withFour); // [1, 2, 3, 4]
불변성 (Immutability)
데이터를 변경하는 대신 새 데이터를 생성합니다. 버그 추적이 쉬워지고 예측 가능성이 높아집니다.
// Object.freeze — 얕은 동결
const config = Object.freeze({
apiURL: "https://api.example.com",
timeout: 5000,
retries: 3,
});
// config.apiURL = "http://hacker.com"; // TypeError (strict mode)
// config.retries++; // TypeError
// 중첩 객체는 freeze 적용 안 됨 (얕은 동결)
const shallow = Object.freeze({ nested: { value: 1 } });
shallow.nested.value = 99; // 가능! (nested 자체는 frozen이 아님)
// 깊은 동결 (Deep Freeze)
function deepFreeze(obj) {
Object.getOwnPropertyNames(obj).forEach(name => {
const value = obj[name];
if (typeof value === "object" && value !== null) {
deepFreeze(value);
}
});
return Object.freeze(obj);
}
const immutableConfig = deepFreeze({
server: { host: "localhost", port: 3000 },
db: { host: "localhost", port: 5432 },
});
// immutableConfig.server.port = 8080; // TypeError!
스프레드 패턴으로 불변 업데이트
const user = { name: "Alice", age: 30, role: "user" };
// 하나의 필드 업데이트
const updatedUser = { ...user, age: 31 };
console.log(user); // { name: "Alice", age: 30, role: "user" }
console.log(updatedUser); // { name: "Alice", age: 31, role: "user" }
// 중첩 객체 불변 업데이트
const state = {
users: [
{ id: 1, name: "Alice", settings: { theme: "dark" } },
{ id: 2, name: "Bob", settings: { theme: "light" } },
],
};
// Alice의 테마 변경
const newState = {
...state,
users: state.users.map(u =>
u.id === 1
? { ...u, settings: { ...u.settings, theme: "light" } }
: u
),
};
console.log(state.users[0].settings.theme); // "dark" — 원본 유지
console.log(newState.users[0].settings.theme); // "light"
고차 함수 (Higher-Order Functions)
함수를 인수로 받거나 함수를 반환하는 함수입니다.
// 함수를 인수로 받는 고차 함수
function applyTwice(fn, value) {
return fn(fn(value));
}
const double = x => x * 2;
const addTen = x => x + 10;
console.log(applyTwice(double, 3)); // 12 (3 → 6 → 12)
console.log(applyTwice(addTen, 5)); // 25 (5 → 15 → 25)
// 함수를 반환하는 고차 함수
function multiplier(factor) {
return x => x * factor;
}
const triple = multiplier(3);
const fiveTimes = multiplier(5);
console.log([1, 2, 3, 4, 5].map(triple)); // [3, 6, 9, 12, 15]
console.log([1, 2, 3, 4, 5].map(fiveTimes)); // [5, 10, 15, 20, 25]
커링 (Currying)
여러 인수를 받는 함수를 하나씩 인수를 받는 함수의 연쇄로 변환합니다.
// 커링된 함수 — 인수를 하나씩 받음
const curriedAdd = a => b => c => a + b + c;
console.log(curriedAdd(1)(2)(3)); // 6
// 부분 적용으로 재사용 가능한 함수 생성
const add10 = curriedAdd(10);
const add10and20 = add10(20);
console.log(add10(5)(3)); // 18
console.log(add10and20(7)); // 37
// 자동 커링 유틸리티
function curry(fn) {
const arity = fn.length; // 함수의 인수 개수
return function curried(...args) {
if (args.length >= arity) {
return fn(...args);
}
return function(...moreArgs) {
return curried(...args, ...moreArgs);
};
};
}
// 커링된 버전 생성
const add = curry((a, b, c) => a + b + c);
const multiply = curry((a, b) => a * b);
const includes = curry((target, arr) => arr.includes(target));
console.log(add(1)(2)(3)); // 6
console.log(add(1, 2)(3)); // 6
console.log(add(1)(2, 3)); // 6
console.log(add(1, 2, 3)); // 6
const double = multiply(2);
console.log([1, 2, 3, 4, 5].map(double)); // [2, 4, 6, 8, 10]
const hasAdmin = includes("admin");
console.log(hasAdmin(["user", "admin", "guest"])); // true
함수 합성 (Function Composition)
여러 함수를 연결해서 하나의 함수로 만드는 패턴입니다.
// compose: 오른쪽에서 왼쪽으로 적용 (수학적 합성)
const compose = (...fns) => x => fns.reduceRight((v, f) => f(v), x);
// pipe: 왼쪽에서 오른쪽으로 적용 (읽기 쉬움)
const pipe = (...fns) => x => fns.reduce((v, f) => f(v), x);
// 예제 함수들
const trim = str => str.trim();
const lowercase = str => str.toLowerCase();
const replaceSpaces = str => str.replace(/\s+/g, "-");
const addPrefix = str => `post-${str}`;
// pipe: 데이터 변환 파이프라인
const createSlug = pipe(
trim,
lowercase,
replaceSpaces,
addPrefix,
);
console.log(createSlug(" Hello World ")); // "post-hello-world"
console.log(createSlug(" My Blog Post ")); // "post-my-blog-post"
// compose (반대 방향)
const createSlugCompose = compose(
addPrefix,
replaceSpaces,
lowercase,
trim,
);
console.log(createSlugCompose(" Hello World ")); // "post-hello-world"
비동기 파이프라인
// 비동기 함수를 위한 pipeAsync
const pipeAsync = (...fns) => x =>
fns.reduce((p, f) => p.then(f), Promise.resolve(x));
// 예제: 데이터 처리 파이프라인
const fetchUser = async (id) => ({ id, name: "Alice", role: "admin" });
const enrichUser = async (user) => ({ ...user, permissions: ["read", "write"] });
const formatUser = async (user) => ({
...user,
displayName: user.name.toUpperCase(),
canWrite: user.permissions.includes("write"),
});
const processUser = pipeAsync(fetchUser, enrichUser, formatUser);
processUser(1).then(user => {
console.log(user.displayName); // "ALICE"
console.log(user.canWrite); // true
});
부분 적용 (Partial Application)
함수의 일부 인수를 미리 고정해서 더 적은 인수를 받는 새 함수를 만듭니다.
// 부분 적용 유틸리티
function partial(fn, ...presetArgs) {
return function(...laterArgs) {
return fn(...presetArgs, ...laterArgs);
};
}
// HTTP 요청 함수 (실제로는 fetch 사용)
async function request(baseURL, method, endpoint, data) {
const url = `${baseURL}${endpoint}`;
console.log(`${method} ${url}`, data ?? "");
return { method, url, data };
}
// 기본 URL 고정
const apiRequest = partial(request, "https://api.example.com");
// 메서드 고정
const get = partial(apiRequest, "GET");
const post = partial(apiRequest, "POST");
const del = partial(apiRequest, "DELETE");
// 간결하게 사용
get("/users");
post("/users", { name: "Alice" });
del("/users/1");
실전 예제: 함수형 데이터 변환 파이프라인
// 전자상거래 주문 처리 파이프라인
const orders = [
{ id: 1, customer: "Alice", items: ["book", "pen"], total: 15.99, status: "pending" },
{ id: 2, customer: "Bob", items: ["laptop"], total: 999.99, status: "paid" },
{ id: 3, customer: "Carol", items: ["notebook", "pencil", "ruler"], total: 8.50, status: "pending" },
{ id: 4, customer: "Dave", items: ["phone"], total: 599.99, status: "cancelled" },
{ id: 5, customer: "Eve", items: ["tablet", "case"], total: 449.99, status: "paid" },
];
// 순수 변환 함수들
const filterByStatus = curry((status, orders) =>
orders.filter(o => o.status === status)
);
const sortByTotal = (orders) =>
[...orders].sort((a, b) => b.total - a.total);
const addTax = curry((taxRate, orders) =>
orders.map(o => ({ ...o, taxAmount: +(o.total * taxRate).toFixed(2) }))
);
const formatOrder = (order) => ({
orderId: `#${String(order.id).padStart(4, "0")}`,
customer: order.customer,
itemCount: order.items.length,
subtotal: `$${order.total.toFixed(2)}`,
tax: `$${(order.taxAmount ?? 0).toFixed(2)}`,
total: `$${(order.total + (order.taxAmount ?? 0)).toFixed(2)}`,
});
// 파이프라인 조합
const processPaidOrders = pipe(
filterByStatus("paid"),
sortByTotal,
addTax(0.1),
(orders) => orders.map(formatOrder),
);
const result = processPaidOrders(orders);
result.forEach(o => {
console.log(`${o.orderId} | ${o.customer} | ${o.itemCount}개 | ${o.total}`);
});
// #0002 | Bob | 1개 | $1099.99
// #0005 | Eve | 2개 | $494.99
트랜스듀서 (Transducers) 패턴
대용량 데이터에서 map + filter 체이닝의 중간 배열 생성을 피하는 고급 기법입니다.
// 일반 방식: 중간 배열 2개 생성
const result1 = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
.filter(x => x % 2 === 0) // [2, 4, 6, 8, 10]
.map(x => x * x); // [4, 16, 36, 64, 100]
// 트랜스듀서: 중간 배열 없이 단일 패스
const mapping = (fn) => (reducer) => (acc, val) => reducer(acc, fn(val));
const filtering = (pred) => (reducer) => (acc, val) => pred(val) ? reducer(acc, val) : acc;
const xform = compose(
filtering(x => x % 2 === 0),
mapping(x => x * x),
);
const append = (arr, val) => [...arr, val];
const result2 = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
.reduce(xform(append), []);
console.log(result2); // [4, 16, 36, 64, 100]
고수 팁
팁 1: Point-Free 스타일 (Tacit Programming)
// 일반 스타일: 인수 명시
const isEven = x => x % 2 === 0;
const evens = numbers => numbers.filter(x => isEven(x));
// Point-free 스타일: 함수 합성만으로 표현
const not = fn => x => !fn(x);
const isOdd = not(isEven);
// 직접 전달
const getEvens = numbers => numbers.filter(isEven);
const getOdds = numbers => numbers.filter(isOdd);
console.log(getEvens([1,2,3,4,5])); // [2, 4]
console.log(getOdds([1,2,3,4,5])); // [1, 3, 5]
팁 2: Maybe 모나드 패턴 — null 안전 처리
class Maybe {
constructor(value) { this._value = value; }
static of(value) { return new Maybe(value); }
isNothing() { return this._value === null || this._value === undefined; }
map(fn) {
return this.isNothing() ? this : Maybe.of(fn(this._value));
}
getOrElse(defaultValue) {
return this.isNothing() ? defaultValue : this._value;
}
}
// null 안전 데이터 접근
const user = { profile: { address: { city: "Seoul" } } };
const city = Maybe.of(user)
.map(u => u.profile)
.map(p => p.address)
.map(a => a.city)
.getOrElse("Unknown");
console.log(city); // "Seoul"
const noCity = Maybe.of(null)
.map(u => u.profile)
.map(p => p.address)
.getOrElse("Unknown");
console.log(noCity); // "Unknown"
팁 3: 함수형으로 상태 관리
// Redux 스타일 리듀서 — 순수 함수로 상태 변환
const initialState = { count: 0, history: [] };
const reducer = (state = initialState, action) => {
switch (action.type) {
case "INCREMENT":
return {
...state,
count: state.count + (action.payload ?? 1),
history: [...state.history, `+${action.payload ?? 1}`],
};
case "DECREMENT":
return {
...state,
count: state.count - (action.payload ?? 1),
history: [...state.history, `-${action.payload ?? 1}`],
};
case "RESET":
return { ...initialState };
default:
return state;
}
};
// 순수 함수이므로 테스트가 매우 쉬움
let state = reducer(undefined, { type: "INCREMENT" });
state = reducer(state, { type: "INCREMENT", payload: 5 });
state = reducer(state, { type: "DECREMENT", payload: 2 });
console.log(state.count); // 4
console.log(state.history); // ["+1", "+5", "-2"]