본문으로 건너뛰기
Advertisement

4.4 고차 함수와 함수형 프로그래밍

함수형 프로그래밍(FP)은 순수 함수와 불변 데이터를 기반으로 프로그램을 구성하는 패러다임입니다. JavaScript는 함수가 일급 객체이므로 함수형 스타일을 자연스럽게 지원합니다.


순수 함수 (Pure Function)

순수 함수는 두 가지 조건을 만족합니다.

  1. 동일한 입력 → 항상 동일한 출력
  2. 부수 효과(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"]
Advertisement