함수형 유틸리티 라이브러리 사용
함수형 프로그래밍은 코드의 재사용성을 높이고, 예측 가능하며 테스트하기 쉬운 코드를 작성하는 데 중점을 둡니다. 이를 위해 순수 함수, 불변성, 고차 함수와 같은 개념을 활용하는데, 이러한 개념들을 실제로 적용하는 과정에서 자주 사용되는 유용한 도구들이 있습니다. 이 절에서는 함수형 프로그래밍에서 흔히 사용되는 함수형 유틸리티와 이를 타입스크립트 환경에서 어떻게 활용할 수 있는지 살펴보겠습니다.
이러한 유틸리티들은 직접 구현할 수도 있지만, Lodash/fp, Ramda.js, RxJS와 같은 전문 함수형 라이브러리에서 더욱 강력하고 최적화된 형태로 제공됩니다. 여기서는 개념 이해를 돕기 위한 간단한 예시를 제시합니다.
함수 조합
정의: 함수 조합은 두 개 이상의 함수를 연결하여 하나의 새로운 함수를 만드는 기법입니다. 한 함수의 출력이 다른 함수의 입력이 되는 방식으로 함수들을 파이프라인처럼 연결합니다.
설명:
수학에서 f(g(x))
는 g(x)
의 결과를 f
함수의 입력으로 사용하는 것을 의미합니다. 함수 조합은 이 아이디어를 프로그래밍에 적용합니다. 여러 작은 순수 함수들을 조합하여 더 복잡한 기능을 가진 함수를 만들 수 있으며, 이는 코드의 가독성을 높이고 유지보수성을 향상시킵니다.
타입스크립트 구현 및 예시
함수 조합은 보통 compose
또는 pipe
함수를 사용하여 구현됩니다.
compose
: 오른쪽에서 왼쪽으로 함수를 적용 (수학적 표기f(g(x))
와 유사)pipe
: 왼쪽에서 오른쪽으로 함수를 적용 (데이터 흐름을 시각적으로 따라가기 쉬움)
// 예시 함수들 (모두 순수 함수여야 합니다!)
const toUpperCase = (str: string): string => str.toUpperCase();
const addExclamation = (str: string): string => str + '!';
const splitBySpace = (str: string): string[] => str.split(' ');
const joinWithDash = (arr: string[]): string => arr.join('-');
const truncate = (length: number) => (str: string): string =>
str.length > length ? str.substring(0, length) + '...' : str;
// compose 함수 구현
// T1 -> T2 -> T3 -> ... -> TN
// compose(f, g, h) => f(g(h(x)))
function compose<TArgs extends any[], TResult1, TResult2, TResult3>(
f: (a: TResult2) => TResult1,
g: (a: TResult3) => TResult2,
h: (...args: TArgs) => TResult3
): (...args: TArgs) => TResult1;
function compose<TArgs extends any[], TResult1, TResult2>(
f: (a: TResult2) => TResult1,
g: (...args: TArgs) => TResult2
): (...args: TArgs) => TResult1;
function compose(...fns: Function[]): Function {
return (...args: any[]) =>
fns.reduceRight((res, fn) => (Array.isArray(res) ? fn(...res) : fn(res)), args);
}
// pipe 함수 구현
// T1 -> T2 -> T3 -> ... -> TN
// pipe(f, g, h) => h(g(f(x)))
function pipe<TArgs extends any[], TResult1, TResult2, TResult3>(
f: (...args: TArgs) => TResult1,
g: (a: TResult1) => TResult2,
h: (a: TResult2) => TResult3
): (...args: TArgs) => TResult3;
function pipe(...fns: Function[]): Function {
return (...args: any[]) =>
fns.reduce((res, fn) => (Array.isArray(res) ? fn(...res) : fn(res)), args);
}
// 사용 예시: compose
const transformAndFormatCompose = compose(
joinWithDash, // 4. 배열을 대시로 연결
splitBySpace, // 3. 공백으로 분리
addExclamation, // 2. 느낌표 추가
toUpperCase // 1. 대문자로 변환
);
const resultCompose = transformAndFormatCompose("hello functional programming");
console.log("Compose Result:", resultCompose); // HELLO-FUNCTIONAL-PROGRAMMING!
// 사용 예시: pipe
const transformAndFormatPipe = pipe(
toUpperCase, // 1. 대문자로 변환
addExclamation, // 2. 느낌표 추가
splitBySpace, // 3. 공백으로 분리
joinWithDash // 4. 배열을 대시로 연결
);
const resultPipe = transformAndFormatPipe("hello functional programming");
console.log("Pipe Result:", resultPipe); // HELLO-FUNCTIONAL-PROGRAMMING!
// 커링된 함수와 조합
const truncatedUpperCase = pipe(
toUpperCase,
truncate(10) // truncate는 커링된 함수
);
console.log("Truncated UpperCase:", truncatedUpperCase("super long string example")); // SUPER LONG...
pipe
함수는 데이터가 왼쪽에서 오른쪽으로 흐르는 것처럼 보이기 때문에 많은 개발자에게 더 직관적으로 받아들여집니다. Lodash/fp나 Ramda 같은 라이브러리에서는 이런 함수 조합 유틸리티를 더욱 강력하게 제공합니다.
이점
- 가독성 향상: 여러 작은 함수가 연결되어 데이터가 변환되는 과정을 한눈에 파악할 수 있습니다.
- 재사용성: 작은 유틸리티 함수들을 조합하여 새로운 기능을 쉽게 만들 수 있습니다.
- 디버깅 용이성: 각 함수는 순수하므로, 문제가 발생하면 파이프라인의 어느 단계에서 오류가 발생했는지 쉽게 추적할 수 있습니다.
맵핑 및 필터링
이러한 연산은 배열 메서드(map
, filter
)로 이미 익숙하지만, 함수형 프로그래밍에서는 데이터를 불변적으로 다루는 일반적인 패턴으로 간주됩니다.
interface Product {
id: number;
name: string;
price: number;
category: string;
}
const products: Product[] = [
{ id: 1, name: 'Laptop', price: 1200, category: 'Electronics' },
{ id: 2, name: 'Keyboard', price: 75, category: 'Electronics' },
{ id: 3, name: 'Book', price: 20, category: 'Books' },
{ id: 4, name: 'Mouse', price: 30, category: 'Electronics' },
{ id: 5, name: 'Pen', price: 5, category: 'Stationery' },
];
// 맵핑 (map): 각 요소를 변환하여 새 배열 생성
const productNames = products.map(product => product.name);
console.log("Product Names:", productNames); // ['Laptop', 'Keyboard', 'Book', 'Mouse', 'Pen']
// 필터링 (filter): 특정 조건에 맞는 요소만 포함하는 새 배열 생성
const electronicsProducts = products.filter(product => product.category === 'Electronics');
console.log("Electronics Products:", electronicsProducts);
/*
[
{ id: 1, name: 'Laptop', price: 1200, category: 'Electronics' },
{ id: 2, name: 'Keyboard', price: 75, category: 'Electronics' },
{ id: 4, name: 'Mouse', price: 30, category: 'Electronics' }
]
*/
// 조합된 예시: 전자제품 중 가격이 50을 초과하는 제품의 이름만 추출
const expensiveElectronicsNames = products
.filter(product => product.category === 'Electronics') // 먼저 필터링
.filter(product => product.price > 50) // 다시 필터링
.map(product => product.name); // 이름만 맵핑
console.log("Expensive Electronics Names:", expensiveElectronicsNames); // ['Laptop', 'Keyboard']
map
과 filter
는 모두 원본 배열을 변경하지 않고 항상 새로운 배열을 반환하는 순수 함수입니다.
리듀싱
정의: reduce
(또는 fold
)는 배열의 각 요소를 순회하면서 하나의 "누적된(Accumulated)" 값으로 줄이는(reduce) 연산입니다.
설명:
reduce
는 배열 요소를 가지고 단일 값을 계산하거나, 새로운 복잡한 객체를 만들 때 사용됩니다. 초기값(initial value)과 누산기(accumulator)를 업데이트하는 콜백 함수를 인자로 받습니다.
const numbers = [1, 2, 3, 4, 5];
// 모든 숫자의 합 계산
const sum = numbers.reduce((acc, current) => acc + current, 0); // 초기값 0
console.log("Sum:", sum); // 15
// 배열의 모든 요소를 곱하기
const product = numbers.reduce((acc, current) => acc * current, 1); // 초기값 1
console.log("Product:", product); // 120
// 객체 배열을 Map으로 변환
interface User {
id: number;
name: string;
}
const users: User[] = [
{ id: 1, name: "Alice" },
{ id: 2, name: "Bob" },
{ id: 3, name: "Charlie" },
];
const usersMap = users.reduce((map, user) => {
map.set(user.id, user); // Map 객체를 직접 변경하지만, 새로운 Map을 반환하여 불변성 유지 가능
return map;
}, new Map<number, User>()); // 초기값으로 빈 Map 생성
console.log("Users Map:", usersMap.get(2)); // { id: 2, name: 'Bob' }
// 불변성을 더 강력하게 지키려면
const usersMapImmutable = users.reduce((map, user) => {
return new Map(map).set(user.id, user); // 매번 새로운 Map 생성 (성능 오버헤드 주의)
}, new Map<number, User>());
reduce
는 매우 강력하고 다재다능한 함수형 유틸리티입니다. 초기값과 콜백 함수의 정의에 따라 다양한 변환을 수행할 수 있습니다.
부분 적용 및 커링
앞서 12.2절에서 다룬 커링은 함수형 유틸리티의 한 종류로 볼 수 있습니다. 커링은 부분 적용을 통해 특정 인자를 미리 채워 넣은 새로운 함수를 생성할 수 있게 합니다.
// 커링된 divide 함수
const divide = (a: number) => (b: number) => a / b;
// 부분 적용: 10을 나누는 함수 생성
const divideBy10 = divide(10);
console.log("10 / 2:", divideBy10(2)); // 5
console.log("10 / 5:", divideBy10(5)); // 2
// 또 다른 부분 적용: 어떤 수를 2로 나누는 함수
const half = divide(2); // Ramda 같은 라이브러리에서는 (__, 2) 같은 플레이스홀더 사용
console.log("6 / 2:", half(6)); // 3
메모이제이션
정의: 메모이제이션은 함수의 결과를 캐싱하여 동일한 입력이 주어졌을 때 재계산 대신 캐시된 결과를 반환하여 성능을 최적화하는 기법입니다. 순수 함수에만 적용할 수 있습니다.
설명: 순수 함수는 동일한 입력에 대해 항상 동일한 출력을 보장하기 때문에, 이전에 계산했던 결과를 저장해두고 재사용해도 문제가 없습니다.
// 간단한 메모이제이션 함수 유틸리티
function memoize<T extends Function>(fn: T): T {
const cache: { [key: string]: any } = {};
return ((...args: any[]) => {
const key = JSON.stringify(args); // 인자를 문자열 키로 사용 (복잡한 객체는 주의)
if (cache[key]) {
console.log(`Getting from cache for key: ${key}`);
return cache[key];
} else {
console.log(`Calculating for key: ${key}`);
const result = fn(...args);
cache[key] = result;
return result;
}
}) as any as T; // 타입 캐스팅
}
// 예시: 피보나치 수열 (재귀 호출이 많아 최적화에 유리)
const fibonacci = memoize(function(n: number): number {
if (n <= 1) return n;
return fibonacci(n - 1) + fibonacci(n - 2);
});
console.log(fibonacci(10)); // 계산 -> 캐시
console.log(fibonacci(10)); // 캐시에서 가져옴
console.log(fibonacci(15)); // 계산 -> 캐시
console.log(fibonacci(10)); // 캐시에서 가져옴
console.log(fibonacci(15)); // 캐시에서 가져옴
memoize
함수는 고차 함수로, 함수를 인자로 받아 캐싱 로직이 추가된 새로운 함수를 반환합니다.
타입스크립트와 함수형 유틸리티
타입스크립트는 함수형 유틸리티를 작성하고 사용하는 데 매우 강력한 지원을 제공합니다.
- 정확한 타입 추론:
map
,filter
,reduce
,compose
,pipe
와 같은 함수를 사용할 때 입력 및 출력 데이터의 타입을 정확하게 추론하여 안전성을 보장합니다. - 제네릭 (Generics): 다양한 타입의 데이터에 적용될 수 있는 범용적인 함수형 유틸리티를 제네릭을 사용하여 타입 안전하게 작성할 수 있습니다.
- 함수 오버로드 (Function Overloads):
compose
나pipe
와 같이 인자 수가 가변적인 함수형 유틸리티의 타입을 정확하게 정의하기 위해 함수 오버로드를 활용할 수 있습니다. (위의compose
,pipe
예시 참고)
결론
함수형 유틸리티는 함수형 프로그래밍의 핵심 원칙인 순수 함수와 불변성을 기반으로 코드의 모듈성, 재사용성, 가독성, 그리고 테스트 용이성을 극대화합니다. 함수 조합, 맵핑, 필터링, 리듀싱, 부분 적용, 메모이제이션 등은 함수형 프로그래밍 스타일을 채택할 때 일상적으로 사용되는 도구들입니다. 타입스크립트는 이러한 유틸리티들을 더욱 안전하고 효율적으로 개발하고 활용할 수 있도록 강력한 타입 시스템을 제공하여, 견고하고 유지보수하기 쉬운 애플리케이션을 구축하는 데 기여합니다.
이것으로 12장 "함수형 프로그래밍"을 마칩니다. 함수형 프로그래밍은 처음에는 낯설게 느껴질 수 있지만, 익숙해질수록 코드의 품질을 한 단계 높여줄 수 있는 강력한 패러다임입니다.