함수형 유틸리티 라이브러리 사용
함수 조합·map·filter·reduce·부분 적용·커링·메모이제이션 유틸리티를 타입 안전한 데이터 변환 파이프라인에 적용합니다.
함수형 프로그래밍은 재사용성, 예측 가능성, 그리고 테스트 용이성이 높은 코드를 만드는 데 집중합니다.
이를 위해 순수 함수, 불변성, 고차 함수 개념을 활용하고, 실무에서는 이를 돕는 유틸리티 도구를 함께 사용합니다.
이 절에서는 자주 쓰이는 함수형 유틸리티를 타입스크립트 환경에서 어떻게 적용할지 살펴보겠습니다.
이러한 유틸리티들은 직접 구현할 수도 있지만, Lodash/fp, Ramda.js, RxJS와 같은 전문 함수형 라이브러리에서 더욱 강력하고 최적화된 형태로 제공됩니다.
여기서는 개념 이해를 돕기 위한 간단한 예시를 제시합니다.
그림은 readonly 입력·type predicate·명시적 누산기를 함께 쓴 확장 설계입니다. 아래 배열 예제는 각각의 연산을 설명하며 이 전체 파이프라인을 그대로 구현하지는 않습니다.
타입 좁히기와 누산기 계약이 파이프라인을 지킨다 — 상태 추적
별도 설명용 두 사용자 입력에서 active 필터·공개 view 변환·Map 누적의 실제 남는 id를 추적합니다. 본문의 분리된 예제를 연결해 만든 확장 사례입니다.
FUNCTIONAL PIPELINE / TYPE AND EFFECT
입력·변환·누산 결과와 효과 경계를 명시한다
아래는 원문의 배열 예제를 확장한 User→View 파이프라인입니다. readonly의 정적 제한, predicate의 실제 조건, 각 단계의 출력 타입과 누산기 초기값을 함께 확인합니다. 로그·저장·통신은 명시적 후속 경계에서 실행합니다.
입력 · readonly User[]
이 타입을 통한 배열 변경을 제한한 사용자 입력을 다음 단계에 전달합니다.
filter(isActive) → ActiveUser[]
실제 조건을 검사하는 predicate로 활성 사용자만 남기고 타입을 좁힙니다.
map(toView) → UserView[]
활성 사용자마다 화면에 필요한 View를 만들어 새 배열로 전달합니다.
typed reduce · Map 초기값
타입을 명시한 새 Map을 초기 누산기로 주고 View 배열을 순회합니다.
Map<UserId, View> · 누산 결과
사용자 ID를 키로 View를 모읍니다. 빈 입력도 Map 결과이며 같은 키의 set은 앞 값을 덮습니다.
명시적 효과 경계 · 로그·저장·통신
누산 결과를 만든 뒤 후속 효과를 명시적으로 실행합니다. 위 변환 callback의 부수 효과도 별도로 검토합니다.
INPUT
readonly·callback의 한계
readonly 배열은 해당 타입을 통한 배열 변경을 제한합니다. 중첩 객체·다른 가변 참조를 동결하지 않습니다. map/filter callback도 외부 상태를 바꿀 수 있으므로 별도 검토합니다.
PREDICATE
실제 조건과 타입 선언 일치
ActiveUser로 좁히는 predicate는 그 조건을 실제 검사해야 합니다. 타입 선언만으로 잘못된 predicate 구현이 증명되지는 않습니다.
ACCUMULATOR
초기값·키 의미를 명시
초기 Map을 주면 빈 입력도 Map 결과입니다. 같은 key의 set은 앞 값을 덮습니다. 새로 만든 지역 누산기 변경과 입력·외부 상태 변경을 구분합니다.
ACTUAL LESSON
원문 예제와 확장 그래프
products의 filter/map은 Laptop·Keyboard 이름을 만들고 users reduce는 별도 Map 예제입니다. 위 User→View 전체 연결은 원문 그대로의 실행 결과가 아닙니다.
IMPLEMENTATION LIMITS
합성·부분 적용·캐시도 검토
원문의 compose/pipe는 overload·중간 배열 전달 보완이 필요합니다. divide(2)는 분자를 고정하며, memoize는 falsy 적중·JSON 키 한계가 있습니다. 연쇄 문법만으로 정확성이 보장되지는 않습니다.
각 단계의 타입 관계와 실제 callback 동작을 함께 검토합니다. 타입 검사와 입력·효과의 runtime 검증은 다른 책임입니다.
이 예의 조건
- 원도표의 전체 구조와 설명은 위 관계도와 표에서 유지합니다. 아래 인쇄 표에도 원도표의 관계·조건·라벨·설명·주석을 보존합니다.
- 두 관찰 칸의 위치는 고정입니다. 관찰 상태는 해당 순간의 값이며, 동작·이유는 그 조건에서의 판정과 변화 원인입니다. 독립 비교와 실패 분기는 별도 조건으로 읽고 모든 분기를 한 요청의 필수 실행 순서로 이어 읽지 않습니다.
- 원래 본문의 구현과 도표가 설명한 설계 보강을 분리한다. 서사에 포함한 확장 동작은 assumptions에 명시하고 이미 구현된 안전장치로 주장하지 않는다.
- 새 설명용 입력은 {id:1,name:"Alice",active:true}, {id:2,name:"Bob",active:false} 두 명입니다. 원래 본문 User에 active 필드를 덧붙인 확장 모델이며 원래 본문 코드의 실제 실행 결과로 주장하지 않습니다. readonly는 타입의 쓰기 제한이며 런타임 freeze가 아닙니다. 이 사례 callback은 원본을 수정하지 않고 새 view 목록·빈 Map 누산기를 만듭니다.
관계도의 보충 설명
| 구분 | 내용 |
|---|---|
| 전체 관계·조건 설명 | readonly User 입력→predicate filter의 ActiveUser→projection UserView→typed reduce의 Map 초기값→Map<UserId,View> 결과→명시적 효과 경계를 원래 전체 그림과 같은 여섯 노드·다섯 간선으로 재배치한다. 원문 products와 users의 별도 예제에서 확장한 설계이며 뒤의 기존 여섯 단계 설명과 같은 의미다. |
| 원도표 조건·주석 | 입력 · readonly User[]: 이 타입을 통한 배열 변경을 제한한 사용자 입력을 다음 단계에 전달합니다. |
| 원도표 조건·주석 | filter(isActive) → ActiveUser[]: 실제 조건을 검사하는 predicate로 활성 사용자만 남기고 타입을 좁힙니다. |
| 원도표 조건·주석 | map(toView) → UserView[]: 활성 사용자마다 화면에 필요한 View를 만들어 새 배열로 전달합니다. |
| 원도표 조건·주석 | typed reduce · Map 초기값: 타입을 명시한 새 Map을 초기 누산기로 주고 View 배열을 순회합니다. |
| 원도표 조건·주석 | Map<UserId, View> · 누산 결과: 사용자 ID를 키로 View를 모읍니다. 빈 입력도 Map 결과이며 같은 키의 set은 앞 값을 덮습니다. |
| 원도표 조건·주석 | 명시적 효과 경계 · 로그·저장·통신: 누산 결과를 만든 뒤 후속 효과를 명시적으로 실행합니다. 위 변환 callback의 부수 효과도 별도로 검토합니다. |
| 장면 | 현재 관찰값 | 설명 | 변화 이유 |
|---|---|---|---|
| 새 비교 입력 | 입력 id/active: 1:true, 2:false / 원본 정책: callback이 수정하지 않음 | 설명용 두 사용자 중 active=true인 항목을 읽는다 | 필터는 id 1만 통과시킬 조건을 검사하며 readonly를 런타임 freeze로 해석하지 않는다 |
| 필터와 공개 view | 남은 id: [1] / 새 view: [{id:1,name:"Alice"}] | id 2는 제외하고 id 1의 id·name만 새 공개 view로 만든다 | active=false인 id 2가 빠지고 필드 변환은 id 1의 공개 id·name을 새 객체로 만든다 |
| 빈 Map에서 누적 | Map 키: [] → [1] / Map.get(1): {id:1,name:"Alice"} | 새 빈 Map에 남은 view의 id 1 키를 저장한다 | id 1 view를 누산기에 넣어 결과 Map이 생기며 외부 효과는 이 계산 경계 밖에서 수행한다 |
타입 좁히기와 누산기 계약이 파이프라인을 지킨다 — 상태 추적
별도 설명용 두 사용자 입력에서 active 필터·공개 view 변환·Map 누적의 실제 남는 id를 추적합니다. 본문의 분리된 예제를 연결해 만든 확장 사례입니다.
FUNCTIONAL PIPELINE / TYPE AND EFFECT
입력·변환·누산 결과와 효과 경계를 명시한다
아래는 원문의 배열 예제를 확장한 User→View 파이프라인입니다. readonly의 정적 제한, predicate의 실제 조건, 각 단계의 출력 타입과 누산기 초기값을 함께 확인합니다. 로그·저장·통신은 명시적 후속 경계에서 실행합니다.
입력 · readonly User[]
이 타입을 통한 배열 변경을 제한한 사용자 입력을 다음 단계에 전달합니다.
filter(isActive) → ActiveUser[]
실제 조건을 검사하는 predicate로 활성 사용자만 남기고 타입을 좁힙니다.
map(toView) → UserView[]
활성 사용자마다 화면에 필요한 View를 만들어 새 배열로 전달합니다.
typed reduce · Map 초기값
타입을 명시한 새 Map을 초기 누산기로 주고 View 배열을 순회합니다.
Map<UserId, View> · 누산 결과
사용자 ID를 키로 View를 모읍니다. 빈 입력도 Map 결과이며 같은 키의 set은 앞 값을 덮습니다.
명시적 효과 경계 · 로그·저장·통신
누산 결과를 만든 뒤 후속 효과를 명시적으로 실행합니다. 위 변환 callback의 부수 효과도 별도로 검토합니다.
INPUT
readonly·callback의 한계
readonly 배열은 해당 타입을 통한 배열 변경을 제한합니다. 중첩 객체·다른 가변 참조를 동결하지 않습니다. map/filter callback도 외부 상태를 바꿀 수 있으므로 별도 검토합니다.
PREDICATE
실제 조건과 타입 선언 일치
ActiveUser로 좁히는 predicate는 그 조건을 실제 검사해야 합니다. 타입 선언만으로 잘못된 predicate 구현이 증명되지는 않습니다.
ACCUMULATOR
초기값·키 의미를 명시
초기 Map을 주면 빈 입력도 Map 결과입니다. 같은 key의 set은 앞 값을 덮습니다. 새로 만든 지역 누산기 변경과 입력·외부 상태 변경을 구분합니다.
ACTUAL LESSON
원문 예제와 확장 그래프
products의 filter/map은 Laptop·Keyboard 이름을 만들고 users reduce는 별도 Map 예제입니다. 위 User→View 전체 연결은 원문 그대로의 실행 결과가 아닙니다.
IMPLEMENTATION LIMITS
합성·부분 적용·캐시도 검토
원문의 compose/pipe는 overload·중간 배열 전달 보완이 필요합니다. divide(2)는 분자를 고정하며, memoize는 falsy 적중·JSON 키 한계가 있습니다. 연쇄 문법만으로 정확성이 보장되지는 않습니다.
각 단계의 타입 관계와 실제 callback 동작을 함께 검토합니다. 타입 검사와 입력·효과의 runtime 검증은 다른 책임입니다.
이 예의 조건
- 원도표의 전체 구조와 설명은 위 관계도와 표에서 유지합니다. 아래 인쇄 표에도 원도표의 관계·조건·라벨·설명·주석을 보존합니다.
- 두 관찰 칸의 위치는 고정입니다. 관찰 상태는 해당 순간의 값이며, 동작·이유는 그 조건에서의 판정과 변화 원인입니다. 독립 비교와 실패 분기는 별도 조건으로 읽고 모든 분기를 한 요청의 필수 실행 순서로 이어 읽지 않습니다.
- 원래 본문의 구현과 도표가 설명한 설계 보강을 분리한다. 서사에 포함한 확장 동작은 assumptions에 명시하고 이미 구현된 안전장치로 주장하지 않는다.
- 새 설명용 입력은 {id:1,name:"Alice",active:true}, {id:2,name:"Bob",active:false} 두 명입니다. 원래 본문 User에 active 필드를 덧붙인 확장 모델이며 원래 본문 코드의 실제 실행 결과로 주장하지 않습니다. readonly는 타입의 쓰기 제한이며 런타임 freeze가 아닙니다. 이 사례 callback은 원본을 수정하지 않고 새 view 목록·빈 Map 누산기를 만듭니다.
관계도의 보충 설명
| 구분 | 내용 |
|---|---|
| 전체 관계·조건 설명 | readonly User 입력→predicate filter의 ActiveUser→projection UserView→typed reduce의 Map 초기값→Map<UserId,View> 결과→명시적 효과 경계를 원래 전체 그림과 같은 여섯 노드·다섯 간선으로 재배치한다. 원문 products와 users의 별도 예제에서 확장한 설계이며 뒤의 기존 여섯 단계 설명과 같은 의미다. |
| 원도표 조건·주석 | 입력 · readonly User[]: 이 타입을 통한 배열 변경을 제한한 사용자 입력을 다음 단계에 전달합니다. |
| 원도표 조건·주석 | filter(isActive) → ActiveUser[]: 실제 조건을 검사하는 predicate로 활성 사용자만 남기고 타입을 좁힙니다. |
| 원도표 조건·주석 | map(toView) → UserView[]: 활성 사용자마다 화면에 필요한 View를 만들어 새 배열로 전달합니다. |
| 원도표 조건·주석 | typed reduce · Map 초기값: 타입을 명시한 새 Map을 초기 누산기로 주고 View 배열을 순회합니다. |
| 원도표 조건·주석 | Map<UserId, View> · 누산 결과: 사용자 ID를 키로 View를 모읍니다. 빈 입력도 Map 결과이며 같은 키의 set은 앞 값을 덮습니다. |
| 원도표 조건·주석 | 명시적 효과 경계 · 로그·저장·통신: 누산 결과를 만든 뒤 후속 효과를 명시적으로 실행합니다. 위 변환 callback의 부수 효과도 별도로 검토합니다. |
| 장면 | 현재 관찰값 | 설명 | 변화 이유 |
|---|---|---|---|
| 새 비교 입력 | 입력 id/active: 1:true, 2:false / 원본 정책: callback이 수정하지 않음 | 설명용 두 사용자 중 active=true인 항목을 읽는다 | 필터는 id 1만 통과시킬 조건을 검사하며 readonly를 런타임 freeze로 해석하지 않는다 |
| 필터와 공개 view | 남은 id: [1] / 새 view: [{id:1,name:"Alice"}] | id 2는 제외하고 id 1의 id·name만 새 공개 view로 만든다 | active=false인 id 2가 빠지고 필드 변환은 id 1의 공개 id·name을 새 객체로 만든다 |
| 빈 Map에서 누적 | Map 키: [] → [1] / Map.get(1): {id:1,name:"Alice"} | 새 빈 Map에 남은 view의 id 1 키를 저장한다 | id 1 view를 누산기에 넣어 결과 Map이 생기며 외부 효과는 이 계산 경계 밖에서 수행한다 |
함수 조합
정의: 함수 조합은 두 개 이상의 함수를 연결하여 하나의 새로운 함수를 만드는 기법입니다.
한 함수의 출력이 다른 함수의 입력이 되는 방식으로 함수들을 파이프라인처럼 연결합니다.
설명:
수학에서 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); // 앞 호출의 오버로드·배열 전달 오류로 이 결과에 도달하지 못함
// 사용 예시: pipe
const transformAndFormatPipe = pipe(
toUpperCase, // 1. 대문자로 변환
addExclamation, // 2. 느낌표 추가
splitBySpace, // 3. 공백으로 분리
joinWithDash // 4. 배열을 대시로 연결
);
const resultPipe = transformAndFormatPipe("hello functional programming");
console.log("Pipe Result:", resultPipe); // 의도한 합성이지만 현재 오버로드·배열 전달 구현 보완 필요
// 커링된 함수와 조합
const truncatedUpperCase = pipe(
toUpperCase,
truncate(10) // truncate는 커링된 함수
);
console.log("Truncated UpperCase:", truncatedUpperCase("super long string example")); // 2함수 오버로드 추가 후 의도한 결과: SUPER LONG...위 직접 구현은 원리를 보여주는 불완전한 초안입니다. 선언된 오버로드에 함수 4개를 조합하는 호출과 함수 2개를 조합하는 pipe 호출이 없고, 구현부는 중간 배열까지 인자로 펼치므로 splitBySpace의 결과를 joinWithDash에 배열로 전달하지 못합니다. 예시의 의도한 출력과 현재 구현의 실패를 구분해야 합니다. 배열 결과와 최초 인자 목록을 구분하고 실제 호출 수를 지원하는 타입 계약이 필요합니다.
배열도 하나의 중간 값으로 전달하는 합성
새 파일에 원래 첫 예제의 toUpperCase·addExclamation·splitBySpace·joinWithDash·truncate 보조 함수 정의만 먼저 옮기고 아래 코드를 이어 붙입니다. 원래 불완전한 compose/pipe 정의와 호출은 비교용으로 보존합니다. 아래 변형은 인자 하나를 받는 함수 두 개 또는 네 개의 합성만 지원하며, 임의 개수·다중 인자 호출을 지원하는 범용 라이브러리는 아닙니다.
function pipe2<A, B, C>(
f: (value: A) => B,
g: (value: B) => C
): (value: A) => C {
return value => g(f(value));
}
function pipe4<A, B, C, D, E>(
f: (value: A) => B,
g: (value: B) => C,
h: (value: C) => D,
k: (value: D) => E
): (value: A) => E {
return value => k(h(g(f(value))));
}
function compose4<A, B, C, D, E>(
k: (value: D) => E,
h: (value: C) => D,
g: (value: B) => C,
f: (value: A) => B
): (value: A) => E {
return pipe4(f, g, h, k);
}
const safePipe = pipe4(toUpperCase, addExclamation, splitBySpace, joinWithDash);
const safeCompose = compose4(joinWithDash, splitBySpace, addExclamation, toUpperCase);
console.log(safePipe("hello functional programming"));
console.log(safeCompose("hello functional programming"));
console.log(pipe2(toUpperCase, truncate(10))("super long string example"));첫 두 출력은 HELLO-FUNCTIONAL-PROGRAMMING!이고 마지막은 SUPER LONG...입니다. 타입 흐름은 string → string → string → string[] → string입니다. splitBySpace가 배열을 만들어도 다음 함수에는 그 배열을 값 하나로 넘깁니다. joinWithDash(...배열)로 원소를 여러 인자로 펼치지 않으므로 원래 초안의 배열 전달 실패를 피합니다.
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는 새 배열을 반환하지만 콜백의 부수 효과까지 막지는 않습니다. 위 콜백들은 원본을 변경하지 않으며, 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); // 이번 reduce에서 만든 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)); // 실제 식은 2 / 6; 값은 약 0.3333333333333333 (출력 라벨은 원문)위 half = divide(2)는 2 / b를 계산하므로 half(6)은 1 / 3입니다. 분모를 2로 고정하려면 별도 함수나 플레이스홀더로 두 번째 인자를 고정해야 합니다. 원문 코드는 두 인자의 고정 위치를 비교하기 위해 보존했습니다.
메모이제이션
정의: 메모이제이션은 함수의 결과를 캐싱하여 동일한 입력이 주어졌을 때 재계산 대신 캐시된 결과를 반환하여 성능을 최적화하는 기법입니다.
결과만 재사용해도 의미가 유지되려면 같은 캐시 키에 대응하는 계산 결과가 같아야 합니다. 여기서는 명시한 숫자 입력만 사용하는 순수 계산에 한정합니다. 외부 상태가 바뀌는 계산에는 의존 입력이나 무효화 정책까지 필요합니다.
설명: 순수 함수는 동일한 입력에 대해 항상 동일한 출력을 보장하기 때문에, 이전에 계산했던 결과를 저장해두고 재사용해도 문제가 없습니다.
// 간단한 메모이제이션 함수 유틸리티
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)); // 캐시에서 가져옴이 간단한 캐시는 if (cache[key])를 사용하므로 0·false 같은 falsy 결과를 캐시 적중으로 처리하지 못합니다. JSON.stringify도 모든 입력을 구별하는 키가 아니며, 타입 단언은 이 한계를 검증하지 않습니다.
0 결과도 캐시하는 제한된 숫자 예제
입력 범위를 음수가 아닌 안전한 정수로 제한하고, -0도 제외한 별도 구현입니다. 이 범위에서 입력 숫자를 Map 키로 직접 쓰고 has로 존재 여부를 검사합니다. 호출 대상은 같은 입력에 같은 숫자를 반환하며 외부 효과가 없는 계산으로 가정합니다. 무제한 입력이나 객체 직렬화 키 문제를 해결한 일반 캐시는 아닙니다.
function memoizeNumber(fn: (value: number) => number): (value: number) => number {
const cache = new Map<number, number>();
return value => {
if (!Number.isSafeInteger(value) || value < 0 || Object.is(value, -0)) {
throw new Error("음수가 아닌 안전한 정수만 받습니다.");
}
if (cache.has(value)) return cache.get(value)!;
const result = fn(value);
cache.set(value, result);
return result;
};
}
let calculations = 0;
const identityWithCount = memoizeNumber(value => {
calculations++; // 캐시 호출 횟수를 보기 위한 이 실습의 관찰 효과
return value;
});
console.log(identityWithCount(0), identityWithCount(0), calculations); // 0 0 1실제 캐시할 계산은 순수해야 한다는 범위와, 이 실습에서 몇 번 계산했는지 관찰하려고 넣은 카운터를 구별합니다. 첫 호출은 결과 0을 저장하고 둘째 호출은 존재하는 키를 찾아 0을 돌려줍니다. 결과의 truthy 여부로 검사하지 않습니다. 계산이 예외를 던지면 저장 줄에 도달하지 않아 그 실패는 캐시되지 않습니다. 오래 유지하는 캐시에는 입력 수와 저장 기간을 제한하는 정책도 필요합니다.
memoize 함수는 고차 함수로, 함수를 인자로 받아 캐싱 로직이 추가된 새로운 함수를 반환합니다.
함수형 유틸리티를 고를 때는 값의 개수, 유지할 조건, 최종 형태, 재사용할 인자, 캐시 가능성을 기준으로 나누면 실무 코드가 더 읽기 쉬워집니다.
타입스크립트와 함수형 유틸리티
타입스크립트는 함수형 유틸리티를 작성하고 사용하는 데 매우 강력한 지원을 제공합니다.
- 타입 추론과 검사:
map,filter,reduce,compose,pipe의 제네릭과 오버로드가 입력·출력 관계를 올바르게 표현하면 호출의 타입을 추론하고 호환성을 검사할 수 있습니다. 위 직접 구현 초안은 이 계약이 아직 완성되지 않았습니다. - 제네릭 (Generics): 다양한 타입의 데이터에 적용될 수 있는 범용적인 함수형 유틸리티를 제네릭을 사용하여 타입 안전하게 작성할 수 있습니다.
- 함수 오버로드 (Function Overloads):
compose나pipe와 같이 인자 수가 가변적인 함수형 유틸리티의 타입을 정확하게 정의하기 위해 함수 오버로드를 활용할 수 있습니다. (위의compose,pipe예시는 불완전한 초안이므로 실제 호출 수를 지원하는 오버로드가 필요합니다.)
핵심 요약
함수형 유틸리티는 함수형 프로그래밍의 핵심 원칙인 순수 함수와 불변성을 기반으로 코드의 모듈성, 재사용성, 가독성, 테스트 용이성을 극대화합니다.
함수 조합, 매핑, 필터링, 리듀싱, 부분 적용, 메모이제이션은 함수형 프로그래밍 스타일에서 일상적으로 사용하는 도구들입니다.
타입스크립트는 이러한 유틸리티를 더 안전하고 효율적으로 개발하고 활용할 수 있도록 강력한 타입 시스템을 제공하며, 견고하고 유지보수하기 쉬운 애플리케이션 구축에 기여합니다.
이것으로 12장 함수형 프로그래밍을 마칩니다.
순수 함수, 불변성, 합성은 상태 변경 지점을 줄이고 데이터 변환 흐름을 명확히 만들 때 유용합니다.
라이브러리 도입 그림은 이 절의 예제를 실행해 얻은 평가 결과가 아니라, 프로젝트에서 별도로 확인할 선택 기준입니다.
FUNCTIONAL LIBRARY / EVALUATION
함수형 라이브러리의 이득과 비용을 실제 사용으로 비교한다
내장 배열 API와 작은 함수로 요구를 해결할 수 있는지 먼저 확인합니다. 추가 라이브러리가 필요하면 호출 계약·타입 추론·산출 크기·유지보수·팀 가독성을 실제 프로젝트 조건으로 평가합니다.
BASELINE
내장 API로 충분한가?
반복 요구를 실제 예제로 적고 map/filter/reduce·작은 지역 함수와 비교합니다. 단순 구현으로 유지가 쉬우면 의존성 추가 없이 해결할 수 있습니다.
CALL CONTRACT
인자·커링·callback을 읽는다
Ramda/Lodash/fp 등의 데이터 위치·부분 적용·callback 인자 계약을 확인합니다. API 이름이 같아도 내장 함수와 호출 계약이 같지는 않습니다.
TYPE CHECK
strict 설정에서 추론 확인
실제 설치 버전의 선언과 generic·overload·부재 타입을 확인합니다. 함수 합성은 원문 초안처럼 지원 호출 수·배열 전달이 맞는지도 검사합니다.
BUILD OUTPUT
번들 결과로 비용을 확인
실제 import 방식과 bundler 설정에서 산출 크기·tree-shaking을 확인합니다. API 수나 문서의 일반 설명만으로 제거·성능을 보장하지 않습니다.
MAINTENANCE
운영과 팀 이해 비용
선택 버전의 지원·업데이트·프로젝트 호환성, 팀이 실제 파이프라인을 읽고 수정할 수 있는지 확인합니다.
DECISION
도입 범위를 정하고 기록
확인한 이득과 비용을 비교해 도입·범위 축소·보류를 정합니다. 이 그림은 평가 절차이며 원문의 예제를 실행한 라이브러리 순위가 아닙니다.
도입 판단은 프로젝트의 실행·타입·빌드 근거를 필요로 합니다. 여기서는 실제 번들 측정이나 라이브러리 benchmark를 수행하지 않았습니다.
한 단계의 계약을 바꾸어 확인하기
safePipe에 "hi ts"처럼 공백 두 개를 가진 문자열을 넣으면 무엇이 나오나요? split과 join 사이 배열을 적어 설명해 보세요. 이어 새 memoizeNumber로 같은 계산을 구성한 뒤 0·0·2·2를 순서대로 호출하면 결과 네 개와 계산 횟수는 얼마인가요? 계산 카운터는 0에서 시작하고 다른 호출은 없다고 가정합니다.
배열 전달과 캐시 존재 검사의 풀이
대문자화·느낌표 추가 뒤 문자열은 HI TS!입니다. splitBySpace는 ['HI', '', 'TS!']를 만들고 joinWithDash는 HI--TS!를 만듭니다. 빈 항목을 제거하는 함수는 없으므로 공백 두 개를 하나로 정리한 결과라고 해석하지 않습니다.
반환값은 0·0·2·2이고 실제 계산은 서로 다른 입력 0과 2에 대해 두 번입니다. cache.has가 결과 0도 존재하는 키로 구별합니다. 키 범위나 계산 결과가 같은 입력마다 달라진다는 조건을 바꾸면 이 캐시의 계약도 다시 설계해야 합니다.