함수형 유틸리티 라이브러리 사용
함수 조합·map·filter·reduce·부분 적용·커링·메모이제이션 유틸리티를 타입 안전한 데이터 변환 파이프라인에 적용합니다.
함수형 프로그래밍은 재사용성, 예측 가능성, 그리고 테스트 용이성이 높은 코드를 만드는 데 집중합니다.
이를 위해 순수 함수, 불변성, 고차 함수 개념을 활용하고, 실무에서는 이를 돕는 유틸리티 도구를 함께 사용합니다.
이 절에서는 자주 쓰이는 함수형 유틸리티를 타입스크립트 환경에서 어떻게 적용할지 살펴보겠습니다.
이러한 유틸리티들은 직접 구현할 수도 있지만, Lodash/fp, Ramda.js, RxJS와 같은 전문 함수형 라이브러리에서 더욱 강력하고 최적화된 형태로 제공됩니다.
여기서는 개념 이해를 돕기 위한 간단한 예시를 제시합니다.
map/filter/reduce를 나열하는 것보다 입력 타입, 콜백 반환 타입, 좁혀진 타입이 파이프라인 끝까지 유지되는지가 TypeScript의 핵심이다.
- 1입력 모델
User[]처럼 원본 배열과 nullable 필드를 명확히 둔다. readonly 여부도 결정
- 2filter/narrow
isActiveUser 같은 type guard를 쓰면 뒤 단계에서 User 타입이 좁혀진다. boolean 함수와 차이
- 3map/project
필요한 필드만 뽑아 ViewModel로 변환하고 반환 타입을 추론시킨다. T → U
- 4reduce/fold
초기값 타입을 명시해 accumulator가 any로 퍼지지 않게 한다. empty array 대비
함수 조합
pipe와 compose는 중간 단계를 이름 붙여 복잡한 변환 로직을 읽기 쉬운 데이터 흐름으로 만든다.
- 1초기 값
input 초기 값 변환 전 데이터 구조를 고정한다.
- 2작은 변환
한 함수가 한 가지 일만 맡는다.
- 3타입 연결
앞 반환 타입이 다음 입력 타입과 맞아야 한다.
- 4최종 결과
output 최종 결과 중간 어디서 값이 바뀌었는지 추적 가능하다.
| 구분 | 의미 | 판단 |
|---|---|---|
| pipe | 왼쪽에서 오른쪽 | 데이터 흐름이 읽기 쉽다. |
| compose | 오른쪽에서 왼쪽 | 수학적 합성 형태와 가깝다. |
| 디버깅 | 단계별 함수명 | 문제 지점을 좁히기 쉽다. |
정의: 함수 조합은 두 개 이상의 함수를 연결하여 하나의 새로운 함수를 만드는 기법입니다.
한 함수의 출력이 다른 함수의 입력이 되는 방식으로 함수들을 파이프라인처럼 연결합니다.
설명:
수학에서 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 함수는 고차 함수로, 함수를 인자로 받아 캐싱 로직이 추가된 새로운 함수를 반환합니다.
함수형 유틸리티를 고를 때는 값의 개수, 유지할 조건, 최종 형태, 재사용할 인자, 캐시 가능성을 기준으로 나누면 실무 코드가 더 읽기 쉬워집니다.
런타임 값을 바꾸지 않고 선택, 제외, 읽기 전용, 고정 키 매핑 같은 계약을 컴파일 단계에서 표현한다.
- 모든 필드 선택 사항
Partial 모든 필드 선택 사항 수정 폼이나 PATCH 요청에 적합하다.
- 필드 일부 사용 또는 제외
Pick / Omit 필드 일부 사용 또는 제외 공개 응답과 민감 정보 제거에 쓴다.
- 키 집합 매핑 또는 변경 금지
Record / Readonly 키 집합 매핑 또는 변경 금지 상태 코드 맵과 설정 객체에 맞다.
| 구분 | 의미 | 판단 |
|---|---|---|
| 필드 일부 | Pick<T, K> | 카드 UI나 공개 DTO처럼 필요한 필드만 남김 |
| 내부 필드 제거 | Omit<T, K> | password, token, 내부 id 차단 |
| 읽기 전용 | Readonly<T> | 반환 DTO와 설정 객체 보호 |
타입스크립트와 함수형 유틸리티
타입스크립트는 함수형 유틸리티를 작성하고 사용하는 데 매우 강력한 지원을 제공합니다.
- 정확한 타입 추론:
map,filter,reduce,compose,pipe와 같은 함수를 사용할 때 입력 및 출력 데이터의 타입을 정확하게 추론하여 안전성을 보장합니다. - 제네릭 (Generics): 다양한 타입의 데이터에 적용될 수 있는 범용적인 함수형 유틸리티를 제네릭을 사용하여 타입 안전하게 작성할 수 있습니다.
- 함수 오버로드 (Function Overloads):
compose나pipe와 같이 인자 수가 가변적인 함수형 유틸리티의 타입을 정확하게 정의하기 위해 함수 오버로드를 활용할 수 있습니다. (위의compose,pipe예시 참고)
핵심 요약
함수형 유틸리티는 함수형 프로그래밍의 핵심 원칙인 순수 함수와 불변성을 기반으로 코드의 모듈성, 재사용성, 가독성, 테스트 용이성을 극대화합니다.
함수 조합, 맵핑, 필터링, 리듀싱, 부분 적용, 메모이제이션은 함수형 프로그래밍 스타일에서 일상적으로 사용하는 도구들입니다.
타입스크립트는 이러한 유틸리티를 더 안전하고 효율적으로 개발하고 활용할 수 있도록 강력한 타입 시스템을 제공하며, 견고하고 유지보수하기 쉬운 애플리케이션 구축에 기여합니다.
함수형 유틸리티는 원본을 바꾸지 않고 새 값을 만들며, TypeScript는 각 단계의 반환 타입이 다음 입력과 맞는지 확인합니다.
- 1타입 흐름
항목 타입을 T에서 U로 바꾸지만 배열 컨테이너는 유지합니다. T[] => U[] 요소 타입은 유지하고 조건을 만족하는 항목만 남깁니다. T[] => T[] 배열을 누산 타입 하나로 접어 합계, Map, 그룹을 만듭니다. T[] => R 순수 함수의 같은 인자 조합을 키로 삼아 계산 결과를 재사용합니다. (...A) => R 원본 데이터 순수 함수 중간 타입 최종 값
- 2map
항목 타입을 T에서 U로 바꾸지만 배열 컨테이너는 유지합니다. T[] => U[]
- 3filter
요소 타입은 유지하고 조건을 만족하는 항목만 남깁니다. T[] => T[]
- 4reduce
배열을 누산 타입 하나로 접어 합계, Map, 그룹을 만듭니다. T[] => R
- 5memoize
순수 함수의 같은 인자 조합을 키로 삼아 계산 결과를 재사용합니다. (...A) => R
이것으로 12장 함수형 프로그래밍을 마칩니다.
순수 함수, 불변성, 합성은 상태 변경 지점을 줄이고 데이터 변환 흐름을 명확히 만들 때 유용합니다.
마지막 선택표는 pipe, map, 조건 선별, reduce, memoize를 데이터 모양과 최종값 기준으로 구분합니다.
배열을 유지할지, 하나의 값으로 접을지, 인자를 미리 고정할지에 따라 필요한 유틸리티가 달라집니다.
- 목적별 선택표
왼쪽에서 오른쪽으로 단계가 이어질 때 데이터 흐름을 읽기 쉽게 만듭니다. A => B => C 항목 수는 유지하고 각 요소의 형태만 새 타입으로 바꿉니다. T[] => U[] 원본 배열은 보존하고 조건을 만족하는 항목만 새 배열에 남깁니다. T[] => T[] 여러 항목을 합계, 그룹, Map 같은 하나의 누산값으로 접습니다. T[] => R 순수 함수의 같은 인자 조합을 기억해 반복 계산을 줄입니다. 동일 입력 재사용 원본 모양 중간 타입 최종값 재사용 기준
- pipe
왼쪽에서 오른쪽으로 단계가 이어질 때 데이터 흐름을 읽기 쉽게 만듭니다. A => B => C
- map
항목 수는 유지하고 각 요소의 형태만 새 타입으로 바꿉니다. T[] => U[]
- 조건 선별
원본 배열은 보존하고 조건을 만족하는 항목만 새 배열에 남깁니다. T[] => T[]
- reduce
여러 항목을 합계, 그룹, Map 같은 하나의 누산값으로 접습니다. T[] => R
- memoize
순수 함수의 같은 인자 조합을 기억해 반복 계산을 줄입니다. 동일 입력 재사용
아래 다이어그램은 함수형 유틸리티를 고를 때 입력 모양, 변환 타입, 누적 결과, 캐시 키를 함께 확인합니다.
literal widening, contextual typing, generic inference, return type 경계를 알면 명시할 타입과 맡길 타입이 갈립니다.
- 리터럴 확장
widening let 변수의 "dark"는 string으로 넓어지고, const와 as const는 리터럴 타입을 보존합니다. as const
- 문맥 기반 추론
context 콜백 매개변수, JSX prop, 배열 메서드는 기대 타입을 받아 자동으로 좁아집니다.
- 제네릭 추론
generic 인자와 반환 위치에서 T를 모으지만, 빈 배열과 null은 타입 힌트가 필요합니다. <T>
- 반환 타입 경계
return 공개 함수는 추론 결과를 점검하고 API 경계에서는 명시해 변경을 드러냅니다.
아래 다이어그램은 함수형 유틸리티가 함수 조합, 매핑, 필터링의 책임 경계와 실행 흐름을 어떻게 바꾸는지 정리합니다.
Ramda, lodash/fp 같은 도구는 조합을 쉽게 만들지만 타입 추론, 번들 크기, 팀 가독성 기준을 먼저 통과해야 한다.
- 반복 조합 확인
프로젝트 곳곳에 같은 변환 파이프라인이 반복되는지 먼저 본다. 필요성 확인
- 타입 추론 테스트
generic 입력과 출력 타입이 any 없이 이어지는지 작은 예제로 확인한다. 타입 추론 확인
- 번들 비용 측정
tree-shaking이 되는 import 방식과 실제 추가 크기를 빌드 분석으로 본다. bundle
- 팀 규칙 작성
pipe, curry, placeholder 사용 범위를 정해 코드 스타일이 흩어지지 않게 한다. convention
선택: Remeda
reason: typed pipe + small import
확인: 번들 크기 분석과 추론 예제