본문 바로가기

함수형 유틸리티 라이브러리 사용

함수 조합, 매핑과 필터링, 리듀싱, 부분 적용, 메모이제이션을 타입을 지키는 작은 유틸리티로 직접 구현하면서 흔한 구현의 함정을 확인하고, 함수형 라이브러리를 프로젝트에 들일지 판단하는 기준을 정리합니다.

함수형 방식으로 코드를 쓰다 보면 같은 모양의 도구가 반복해서 필요해집니다. 함수를 이어 붙이는 조합, 배열을 바꾸고 거르고 하나로 모으는 연산, 인수 일부를 정해 두는 부분 적용, 계산 결과를 저장해 두는 메모이제이션입니다. Ramda나 Lodash/fp 같은 라이브러리가 이런 도구를 모아 제공하지만, 작게 직접 만들어 보면 각 도구가 무엇을 받고 돌려주는지와 흔히 빠지는 함정을 알 수 있습니다.

이 글은 각 도구를 타입을 지키는 작은 함수로 구현하고, 마지막에 라이브러리를 들일지 판단하는 기준을 정리합니다. 아래 그림은 이 도구들이 하나의 데이터 변환 흐름에서 어떻게 이어지는지를 보여 주는 확장된 설계입니다.

타입 좁히기와 누산기 계약이 파이프라인을 지킨다 — 상태 추적

별도 설명용 두 사용자 입력에서 active 필터·공개 view 변환·Map 누적의 실제 남는 id를 추적합니다. 본문의 분리된 예제를 연결해 만든 확장 사례입니다.

readonly User 입력이 predicate filter, View projection, typed reduce, Map 결과와 외부 effect의 순서로 이어진다. 원문의 별도 예제와 확장 설계를 구별한다.

FUNCTIONAL PIPELINE / TYPE AND EFFECT

입력·변환·누산 결과와 효과 경계를 명시한다

아래는 원문의 배열 예제를 확장한 User→View 파이프라인입니다. readonly의 정적 제한, predicate의 실제 조건, 각 단계의 출력 타입과 누산기 초기값을 함께 확인합니다. 로그·저장·통신은 명시적 후속 경계에서 실행합니다.

입력·변환·누산 결과와 효과 경계를 명시한다readonly User 입력이 predicate filter, View projection, typed reduce, Map 결과와 외부 effect의 순서로 이어진다. 원문의 별도 예제와 확장 설계를 구별한다.
readonly User[]입력 제한
연결 관계readonly User[] · 입력 제한 → filter(isActive) · ActiveUser[]
filter(isActive)ActiveUser[]
연결 관계filter(isActive) · ActiveUser[] → map(toView) · UserView[]
map(toView)UserView[]
연결 관계map(toView) · UserView[] → typed reduce · Map 초기값
typed reduceMap 초기값
연결 관계typed reduce · Map 초기값 → Map<UserId, View> · 누산 결과
Map<UserId, View>누산 결과
연결 관계Map<UserId, View> · 누산 결과 → 명시적 효과 경계 · 로그·저장·통신
명시적 효과 경계로그·저장·통신
타입을 유지해 누산한 뒤 효과 경계로 전달한다readonly User 입력→predicate filter의 ActiveUser→projection UserView→typed reduce의 Map 초기값→Map<UserId,View> 결과→명시적 효과 경계를 원 wide와 같은 여섯 노드·다섯 간선으로 재배치한다. 원문 products와users의 별도 예제에서 확장한 설계이며 뒤의 기존 여섯 단계 설명과 같은 의미다.
readonly User[]입력 제한
연결 관계readonly User[] · 입력 제한 → filter(isActive) · ActiveUser[]
filter(isActive)ActiveUser[]
연결 관계filter(isActive) · ActiveUser[] → map(toView) · UserView[]
map(toView)UserView[]
연결 관계map(toView) · UserView[] → typed reduce · Map 초기값
typed reduceMap 초기값
연결 관계typed reduce · Map 초기값 → Map<UserId, View> · 누산 결과
Map<UserId, View>누산 결과
연결 관계Map<UserId, View> · 누산 결과 → 명시적 효과 경계 · 로그·저장·통신
명시적 효과 경계로그·저장·통신
  1. 입력 · readonly User[]

    이 타입을 통한 배열 변경을 제한한 사용자 입력을 다음 단계에 전달합니다.

  2. filter(isActive) → ActiveUser[]

    실제 조건을 검사하는 predicate로 활성 사용자만 남기고 타입을 좁힙니다.

  3. map(toView) → UserView[]

    활성 사용자마다 화면에 필요한 View를 만들어 새 배열로 전달합니다.

  4. typed reduce · Map 초기값

    타입을 명시한 새 Map을 초기 누산기로 주고 View 배열을 순회합니다.

  5. Map<UserId, View> · 누산 결과

    사용자 ID를 키로 View를 모읍니다. 빈 입력도 Map 결과이며 같은 키의 set은 앞 값을 덮습니다.

  6. 명시적 효과 경계 · 로그·저장·통신

    누산 결과를 만든 뒤 후속 효과를 명시적으로 실행합니다. 위 변환 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를 추적합니다. 본문의 분리된 예제를 연결해 만든 확장 사례입니다.

readonly User 입력이 predicate filter, View projection, typed reduce, Map 결과와 외부 effect의 순서로 이어진다. 원문의 별도 예제와 확장 설계를 구별한다.

FUNCTIONAL PIPELINE / TYPE AND EFFECT

입력·변환·누산 결과와 효과 경계를 명시한다

아래는 원문의 배열 예제를 확장한 User→View 파이프라인입니다. readonly의 정적 제한, predicate의 실제 조건, 각 단계의 출력 타입과 누산기 초기값을 함께 확인합니다. 로그·저장·통신은 명시적 후속 경계에서 실행합니다.

입력·변환·누산 결과와 효과 경계를 명시한다readonly User 입력이 predicate filter, View projection, typed reduce, Map 결과와 외부 effect의 순서로 이어진다. 원문의 별도 예제와 확장 설계를 구별한다.
readonly User[]입력 제한
연결 관계readonly User[] · 입력 제한 → filter(isActive) · ActiveUser[]
filter(isActive)ActiveUser[]
연결 관계filter(isActive) · ActiveUser[] → map(toView) · UserView[]
map(toView)UserView[]
연결 관계map(toView) · UserView[] → typed reduce · Map 초기값
typed reduceMap 초기값
연결 관계typed reduce · Map 초기값 → Map<UserId, View> · 누산 결과
Map<UserId, View>누산 결과
연결 관계Map<UserId, View> · 누산 결과 → 명시적 효과 경계 · 로그·저장·통신
명시적 효과 경계로그·저장·통신
타입을 유지해 누산한 뒤 효과 경계로 전달한다readonly User 입력→predicate filter의 ActiveUser→projection UserView→typed reduce의 Map 초기값→Map<UserId,View> 결과→명시적 효과 경계를 원 wide와 같은 여섯 노드·다섯 간선으로 재배치한다. 원문 products와users의 별도 예제에서 확장한 설계이며 뒤의 기존 여섯 단계 설명과 같은 의미다.
readonly User[]입력 제한
연결 관계readonly User[] · 입력 제한 → filter(isActive) · ActiveUser[]
filter(isActive)ActiveUser[]
연결 관계filter(isActive) · ActiveUser[] → map(toView) · UserView[]
map(toView)UserView[]
연결 관계map(toView) · UserView[] → typed reduce · Map 초기값
typed reduceMap 초기값
연결 관계typed reduce · Map 초기값 → Map<UserId, View> · 누산 결과
Map<UserId, View>누산 결과
연결 관계Map<UserId, View> · 누산 결과 → 명시적 효과 경계 · 로그·저장·통신
명시적 효과 경계로그·저장·통신
  1. 입력 · readonly User[]

    이 타입을 통한 배열 변경을 제한한 사용자 입력을 다음 단계에 전달합니다.

  2. filter(isActive) → ActiveUser[]

    실제 조건을 검사하는 predicate로 활성 사용자만 남기고 타입을 좁힙니다.

  3. map(toView) → UserView[]

    활성 사용자마다 화면에 필요한 View를 만들어 새 배열로 전달합니다.

  4. typed reduce · Map 초기값

    타입을 명시한 새 Map을 초기 누산기로 주고 View 배열을 순회합니다.

  5. Map<UserId, View> · 누산 결과

    사용자 ID를 키로 View를 모읍니다. 빈 입력도 Map 결과이며 같은 키의 set은 앞 값을 덮습니다.

  6. 명시적 효과 경계 · 로그·저장·통신

    누산 결과를 만든 뒤 후속 효과를 명시적으로 실행합니다. 위 변환 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이 생기며 외부 효과는 이 계산 경계 밖에서 수행한다

그림에서 readonly 사용자 배열은 활성 사용자만 남기는 타입 가드로 걸러지고, 화면에 필요한 값만 담은 View로 바뀐 뒤, 빈 Map을 초기값으로 받은 reduce에서 ID별로 모입니다. 로그나 저장 같은 효과는 이 계산이 끝난 뒤에 따로 실행합니다. 이 글의 예제는 이 흐름의 각 연산을 따로 보여 주며, 그림의 사용자 파이프라인 전체를 그대로 구현한 것은 아닙니다.

함수 조합

함수 조합(function composition)은 한 함수의 출력을 다음 함수의 입력으로 넘겨, 여러 함수를 하나의 함수로 잇는 기법입니다. 수학의 f(g(x))처럼 오른쪽 함수부터 적용하는 순서로 적는 것을 compose, 데이터가 흐르는 순서대로 왼쪽부터 적는 것을 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;

여러 함수를 한 번에 받는 pipe를 만들 때 흔히 쓰는 모양은 다음과 같습니다. 처음 인수 목록을 배열로 받아 첫 함수에 펼쳐 넘기려고, 중간 결과가 배열이면 펼치도록 만든 구현입니다.

function naivePipe(...fns: Function[]) {
  return (...args: any[]) =>
    fns.reduce((res, fn) => (Array.isArray(res) ? fn(...res) : fn(res)), args);
}

naivePipe(toUpperCase, addExclamation, splitBySpace, joinWithDash)("hello functional programming");
TypeError: arr.join is not a function

이 구현에는 두 가지 문제가 있습니다. 첫째, 처음 인수 목록과 중간 결과의 배열을 구분하지 못해서, splitBySpace가 돌려준 배열이 joinWithDash에 펼쳐져 들어갑니다. 그러면 arr에는 첫 원소인 문자열 "HELLO"만 들어가고, 문자열에는 join이 없어 실행 중 오류가 납니다. 둘째, 함수들을 Function과 any로 받기 때문에 앞 함수의 출력과 뒤 함수의 입력이 맞는지 타입스크립트가 검사하지 못하며, 오버로드를 덧붙여도 선언한 개수와 다른 호출은 지원되지 않습니다.

함수 하나가 값 하나를 받는다고 정하면, 중간 값이 배열이든 아니든 그대로 넘기면 되고 단계마다 타입을 이을 수 있습니다.

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 shout = pipe4(toUpperCase, addExclamation, splitBySpace, joinWithDash);
const shoutComposed = compose4(joinWithDash, splitBySpace, addExclamation, toUpperCase);

console.log(shout("hello functional programming"));
console.log(shoutComposed("hello functional programming"));
console.log(pipe2(toUpperCase, truncate(10))("super long string example"));
HELLO-FUNCTIONAL-PROGRAMMING!
HELLO-FUNCTIONAL-PROGRAMMING!
SUPER LONG...

pipe4에서 타입 매개변수는 string, string, string, string[], string으로 추론되어, splitBySpace가 만든 배열이 값 하나로 joinWithDash에 넘어갑니다. 함수의 순서를 잘못 적어 앞 함수의 출력이 뒤 함수의 입력과 맞지 않으면 호출하는 자리에서 진단됩니다. pipe2의 두 번째 인수로 넘긴 truncate(10)은 2편에서 본 커링을 쓴 예로, 길이를 정해 둔 (str: string) => string 함수입니다.

이 구현은 함수 개수마다 따로 선언해야 한다는 한계가 있습니다. 라이브러리는 여러 개수의 오버로드를 미리 선언해 두는 방식으로 이를 해결하며, compose보다 데이터의 흐름을 따라 읽기 쉬운 pipe를 더 자주 씁니다.

매핑 및 필터링

map과 filter는 배열 메서드로 이미 익숙하지만, 원래 배열을 그대로 두고 새 배열을 만든다는 점에서 함수형 연산의 기본입니다. map은 모든 요소를 바꾸고, filter는 조건을 만족하는 요소만 남깁니다.

interface Product {
  id: number;
  name: string;
  price: number;
  category: string;
}

const products: readonly 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" },
];

const productNames = products.map((product) => product.name);
const electronics = products.filter((product) => product.category === "Electronics");
const expensiveElectronicsNames = products
  .filter((product) => product.category === "Electronics")
  .filter((product) => product.price > 50)
  .map((product) => product.name);

console.log(productNames.join(","));
console.log(electronics.map((product) => product.name).join(","));
console.log(expensiveElectronicsNames.join(","));
console.log(electronics[0] === products[0]);
Laptop,Keyboard,Book,Mouse,Pen
Laptop,Keyboard,Mouse
Laptop,Keyboard
true

products는 그대로 다섯 개이고, 각 연산은 새 배열을 돌려줍니다. 다만 filter가 만든 새 배열 안의 객체는 원래 배열의 객체와 같은 참조이므로, 결과의 객체를 고치면 원래 데이터도 바뀝니다. 또 map과 filter가 새 배열을 만든다고 해서 콜백 안의 부수 효과까지 막아 주지는 않으므로, 콜백도 순수하게 유지합니다.

filter에 타입 가드를 넘기면 결과의 타입도 좁혀집니다. TypeScript 5.5부터는 value !== undefined처럼 단순한 조건의 화살표 함수에서 타입 가드를 스스로 추론합니다.

const discounts: (number | undefined)[] = [10, undefined, 5];
const knownDiscounts = discounts.filter((discount) => discount !== undefined);
console.log(knownDiscounts.join(","));
10,5

knownDiscounts의 타입은 (number | undefined)[]가 아니라 number[]입니다. 콜백이 discount is number라는 타입 술어를 가진 함수로 추론되어, filter의 타입 가드 오버로드가 선택되기 때문입니다. 직접 (discount): discount is number => ...처럼 적을 때는 선언한 조건과 실제로 검사하는 조건이 같은지 사람이 확인해야 하며, 컴파일러는 그 일치를 검사하지 않습니다.

리듀싱

reduce는 배열의 요소를 차례로 돌며 누산기(accumulator)에 모아, 배열 전체를 하나의 값으로 줄이는 연산입니다. 다른 언어에서는 fold라고도 부르며, 콜백과 함께 누산기의 초기값을 받습니다.

const numbers = [1, 2, 3, 4, 5];
const total = numbers.reduce((acc, current) => acc + current, 0);
const multiplied = numbers.reduce((acc, current) => acc * current, 1);

interface User {
  id: number;
  name: string;
}

const users: readonly User[] = [
  { id: 1, name: "Alice" },
  { id: 2, name: "Bob" },
  { id: 3, name: "Charlie" },
];

const usersById = users.reduce((map, user) => map.set(user.id, user), new Map<number, User>());

console.log(total, multiplied);
console.log(usersById.size, usersById.get(2)?.name);
15 120
3 Bob

합은 0에서, 곱은 1에서 시작하므로 각각 15와 120입니다. 사용자 예제는 초기값으로 빈 Map<number, User>를 주어 누산기의 타입을 정하고, 각 사용자를 ID를 키로 넣습니다. Map의 set은 그 Map을 돌려주므로 콜백이 바로 누산기를 돌려줄 수 있고, 같은 ID가 다시 나오면 앞의 값을 덮습니다.

이 콜백은 누산기를 직접 바꾸지만, 그 Map은 이 reduce를 위해 방금 만든 지역 값이어서 바깥의 누구도 바뀌는 중간 상태를 보지 않습니다. 매번 new Map(map).set(...)으로 복사하면 요소가 n개일 때 복사 비용이 n의 제곱에 비례해 늘어나므로, 입력 배열과 바깥 상태만 바꾸지 않으면 됩니다. 또 초기값을 생략하면 빈 배열에서 TypeError가 나므로, 빈 입력도 정상적인 결과를 내도록 초기값을 주는 편이 좋습니다.

부분 적용과 인수의 순서

고차 함수와 커링에서 본 것처럼, 커링된 함수에 앞 인수를 넘기면 그 인수를 정해 둔 새 함수가 만들어집니다. 이때 어떤 인수가 앞에 오는지에 따라 만들 수 있는 함수가 달라집니다.

const divide = (a: number) => (b: number) => a / b;
const tenDividedBy = divide(10);
console.log(tenDividedBy(2), tenDividedBy(5));

const half = divide(2);
console.log(half(6));

const divideBy = (divisor: number) => (value: number) => value / divisor;
const realHalf = divideBy(2);
console.log(realHalf(6));
5 2
0.3333333333333333
3

divide(2)는 나눠지는 수인 a를 2로 정하므로, half(6)은 6의 절반이 아니라 2 / 6입니다. 나누는 수를 정해 두고 싶다면 divideBy처럼 정해 둘 인수를 앞에, 처리할 데이터를 마지막에 받도록 순서를 바꿔야 합니다. Ramda가 함수를 자동으로 커링하면서 데이터를 마지막 인수로 받는 것도 이 때문이며, 순서를 바꿀 수 없는 함수에는 R.__ 같은 자리 표시자로 건너뛸 인수를 표시합니다.

메모이제이션

메모이제이션은 함수의 결과를 인수별로 저장해 두었다가, 같은 인수로 다시 부르면 계산하지 않고 저장한 결과를 돌려주는 기법입니다. 같은 인수에 언제나 같은 결과를 내는 순수 함수에만 안전하며, 캐시 키(cache key)는 결과에 영향을 주는 모든 입력을 구별해야 합니다.

간단히 만들면 if (cache[key])로 저장 여부를 검사하기 쉬운데, 이 방식은 결과가 0이나 빈 문자열 같은 falsy 값이면 저장해 두고도 없는 것으로 보고 다시 계산합니다. 다음 구현은 Map의 has로 키의 존재를 검사합니다.

function memoize<A extends readonly unknown[], R>(fn: (...args: A) => R): (...args: A) => R {
  const cache = new Map<string, R>();
  return (...args) => {
    const key = JSON.stringify(args);
    if (cache.has(key)) {
      return cache.get(key) as R;
    }
    const result = fn(...args);
    cache.set(key, result);
    return result;
  };
}

let calls = 0;
const fibonacci: (n: number) => number = memoize((n: number): number => {
  calls++;
  return n <= 1 ? n : fibonacci(n - 1) + fibonacci(n - 2);
});

console.log(fibonacci(10), calls);
console.log(fibonacci(10), calls);
console.log(fibonacci(15), calls);
55 11
55 11
610 16

fibonacci(10)은 0부터 10까지의 값을 한 번씩 계산하므로 실제 계산은 11번입니다. 같은 호출을 다시 하면 저장한 결과를 돌려주므로 횟수가 늘지 않고, fibonacci(15)는 11부터 15까지의 다섯 값만 새로 계산해 16번이 됩니다. 재귀 호출이 메모이제이션된 fibonacci를 거치므로 중간 결과도 모두 저장됩니다. 자기 초기화 식 안에서 자기를 부르므로, 이렇게 변수의 타입이나 콜백의 반환 타입을 직접 적어 두어야 타입 추론이 순환하지 않습니다.

cache.get은 R | undefined를 돌려주므로, has로 확인한 뒤 as R로 단언합니다. 이 캐시에도 한계가 있습니다. JSON.stringify는 [undefined]와 [null]을 똑같이 "[null]"로 바꾸고 함수나 Map 같은 값을 제대로 구별하지 못하며, 저장한 결과는 지우지 않으므로 오래 쓰는 캐시라면 저장할 개수나 기간을 제한해야 합니다.

더 깊이: 객체를 키로 삼는 캐시와 WeakMap

인수가 객체 하나인 함수라면, 객체를 문자열로 바꾸지 않고 그 객체 자체를 캐시의 키로 쓸 수 있습니다. 이때 Map 대신 WeakMap을 쓰면 캐시가 키를 붙잡아 두지 않습니다. MDN의 WeakMap 설명에 따르면 WeakMap은 키에 대한 강한 참조를 만들지 않으므로, 어떤 객체가 키로 들어 있다는 사실만으로 그 객체가 가비지 컬렉션(garbage collection)에서 살아남지는 않습니다. 키로는 객체와 등록되지 않은 심벌만 쓸 수 있고, 키가 언제 사라질지는 가비지 컬렉터에 달려 있으므로 키의 목록을 꺼내거나 개수를 세는 방법도 없습니다.

MDN은 이 방식의 한계도 함께 적습니다. 입력이 객체일 때만 쓸 수 있고, 같은 객체가 다시 들어오지 않더라도 그 객체가 살아 있는 동안에는 결과가 캐시에 계속 남습니다. 객체가 같은 내용을 담았더라도 다른 객체라면 다른 키이므로, 이 캐시는 앞 절의 JSON.stringify 키와 달리 내용이 아니라 참조로 결과를 찾습니다. 그래서 불변 갱신으로 새 객체를 만들 때마다 캐시를 다시 계산하게 되는데, 바뀌지 않은 객체는 같은 참조로 남는 불변 갱신의 성질 덕분에 바뀐 부분만 다시 계산된다는 장점도 됩니다.

라이브러리를 들일지 판단하기

함수형 라이브러리는 이런 유틸리티를 더 많이, 더 다듬어진 형태로 제공하지만, 의존성을 하나 더하는 일이기도 합니다. 들이기 전에 실제 프로젝트에서 무엇을 얻고 무엇을 치르는지 비교합니다.

기본 구현, 호출 계약, 타입, 번들, 유지보수, 결정의 6카드로 라이브러리 도입 판단을 정리한다.

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를 수행하지 않았습니다.

그림의 순서대로, 먼저 내장 배열 메서드와 작은 지역 함수로 충분한지 확인합니다. 라이브러리가 필요하다면 데이터를 어느 인수로 받는지, 자동 커링을 하는지 같은 호출 방식을 읽고, 설치할 버전의 타입 선언이 strict 설정에서 제대로 추론되는지 확인합니다. 그다음 실제 가져오기 방식과 번들러 설정에서 결과물의 크기가 얼마나 늘어나는지, 그 버전이 계속 유지되는지, 팀이 그 스타일의 코드를 읽고 고칠 수 있는지를 봅니다. 그림은 판단의 순서를 정리한 것이며, 이 글에서 라이브러리의 번들 크기나 성능을 측정한 것은 아닙니다.

정리

  • 함수 조합은 한 함수의 출력을 다음 함수의 입력으로 넘기며, 값 하나를 받는 함수끼리 이으면 단계마다 타입을 검사할 수 있습니다.
  • map과 filter는 새 배열을 만들지만 안의 객체는 함께 쓰이며, 타입 가드를 넘긴 filter는 결과 타입을 좁힙니다.
  • reduce는 초기값으로 누산기의 타입과 빈 입력의 결과를 정하고, 그 안에서 만든 지역 누산기는 바꾸어도 바깥에 영향이 없습니다.
  • 커링된 함수의 인수 순서는 부분 적용으로 만들 수 있는 함수를 정하므로, 정해 둘 인수를 앞에, 데이터를 마지막에 둡니다.
  • 메모이제이션은 순수 함수에만 쓰고, 키의 존재는 has로 검사하며, 캐시 키가 입력을 제대로 구별하는지 확인합니다.
  • 라이브러리는 내장 기능으로 충분한지 확인한 뒤, 호출 방식, 타입, 번들 크기, 유지보수를 실제 프로젝트에서 비교해 들입니다.

연습 문제

1. 공백이 두 개인 문자열

shout("hi ts")처럼 공백이 두 개 연속인 문자열을 넣으면 무엇이 출력되나요? splitBySpace가 만드는 배열과 함께 설명하세요.

해설 보기

대문자로 바꾸고 느낌표를 더하면 HI TS!가 됩니다. 공백 하나로 나누면 두 공백 사이의 빈 문자열까지 원소가 되어 ["HI", "", "TS!"]가 되고, 이것을 대시로 이으면 HI--TS!가 출력됩니다. 빈 원소를 지우는 단계가 없으므로 공백 두 개가 하나로 합쳐지지 않습니다.

2. falsy 결과의 캐시

같은 인수에 언제나 0을 돌려주는 함수를 if (cache[key])로 저장 여부를 검사하는 간단한 메모이제이션으로 감싸고, 같은 인수로 세 번 불렀습니다. 실제 계산은 몇 번 일어나나요? 이 글의 memoize로 감싸면 어떻게 되나요?

해설 보기

cache[key]에 0이 저장되어도 0은 falsy이므로 검사를 통과하지 못해, 세 번 모두 다시 계산합니다. 이 글의 memoize는 has로 키가 있는지를 검사하므로 첫 호출에서만 계산하고, 나머지 두 번은 저장한 0을 돌려줍니다.

3. 4로 나누는 함수

divide와 divideBy로 각각 4를 정해 둔 함수를 만들어 10을 넘기면 결과는 무엇인가요? 어느 쪽이 10을 4로 나눈 값인가요?

해설 보기

divide(4)(10)은 4 / 10이므로 0.4이고, divideBy(4)(10)은 10 / 4이므로 2.5입니다. 10을 4로 나눈 값은 나누는 수를 먼저 받는 divideBy 쪽입니다.

이것으로 12장의 함수형 프로그래밍을 마칩니다. 다음 장의 React 컴포넌트와 타입스크립트 통합에서는 React의 함수 컴포넌트에 props와 state, 이벤트의 타입을 붙여, 컴포넌트를 잘못 쓰는 코드를 컴파일 단계에서 찾는 방법을 다룹니다.