
해당 포스트는 멀티패러다임 프로그래밍 (유인동 저)을 읽고 개인적으로 정리한 글입니다.
FxTS 라이브러리
FxTS는 한국인 개발자 조현우님이 주도하여 개발한 함수형 라이브러리다. 이 라이브러리는 실제 업무에서 발생하는 복잡한 데이터 처리 문제를 Iterable/AsyncIterable 프로토콜 기반으로 해결한다.
다양한 리스트 처리 함수 세트와 비동기, 병렬, 동시성 프로그래밍을 강력하게 지원하여 실제 업무 환경에서 유용하게 활용할 수 있다. 또한, 지금까지 멀티패러다임 프로그래밍 예제에서 구현한 함수들 또한 전부 구현되어 있다.
pipe 함수
pipe
함수는 왼쪽에서 오른쪽으로 함수를 합성(composition)하는 함수다. pipe | FxTS 첫 번째 인자는 어떤 값이든 될 수 있고, 나머지 인자들은 단항 함수(하나의 인자만 받는 함수)여야 한다.
const result = pipe(
10,
a => a + 4, // a = 10
a => a / 2, // a = 14
);
console.log(result); // 7
커링과 함께 사용하기
pipe
는 커링을 지원하는 함수와 결합될때도 강력한 타입 추론을 제공한다.
유연한 코드 구성 제공
pipe
는 체이닝보다 유연한 코드 구성을 제공한다. 체이닝은 주로 클래스의 메서드를 통해 확장되지만 pipe
는 라이브러리가 제공하지 않는 함수나 사용자 정의 로직을 자유롭게 조합할 수 있다.
pipe(
['1', '2', '3', '4', '5'],
map(a => parseInt(a)), // [a: string]
filter(a => a % 2 === 1), // [a: number]
forEach(console.log),
);
console.log
는 라이브러리에서 제공하는 함수가 아니지만 자연스럽게 조합해서 사용할 수 있다. 이처럼 pipe
는 라이브러리와 무관한 일반 함수도 유연하게 통합할 수 있다.
또한, 비동기 함수 또한 자연스럽게 결합할 수 있다.
await pipe(
Promise.resolve(1),
(a /*: Awaited<number>*/) => a + 1,
async (b /*: Awaited<number>*/) => b + 1,
(c /*: Awaited<number>*/) => c + 1,
); // 4
이처럼 pipe
함수는 동기 함수, 비동기 함수, 이터러블 기반의 리스트 프로세싱, 커링된 함수, 그리고 라이브러리 외부 함수 등과 자연스럽게 조합할 수 있다.
fx 함수 (메서드 체이닝)
FxTS에서는 pipe
함수뿐만 아니라 메서드 체이닝 방식으로도 Iterable/AsyncIterable을 처리할 수 있다. Method Chaining | FxTS fx
함수를 사용해서 메서드를 연결해서 쓸 수 있다.
fx([0, 1, 2, 3, 4, 5, 6, 7, 8, 9])
.filter((a) => a % 2 === 0) // [0, 2, 4, 6, 8]
.map((a) => a * a) // [0, 4, 16, 36, 64]
.take(2) // [0, 4]
.reduce(sum); // 4
지연 평가(Lazy Evaluation)
fx
는 기본적으로 지연 평가를 사용하기 때문에, toArray
, groupBy
, indexBy
, some
같은 엄격한 평가 메서드가 실행될 때까지 실제로는 평가되지 않는다.
즉, 메서드를 체이닝해도 바로 실행되는 게 아니라 최종적으로 값이 필요할 때 한 번에 처리된다.
zip 함수
zip
함수는 여러 배열의 값들을 같은 위치끼리 묶어서 합치는 함수다. 배열 인덱스를 통해 조율되는 별도의 데이터 소스들이 있을 때 사용할 수 있다.
const keys = ['name', 'job', 'location'];
const values = ['Marty', 'Programmer', 'New York'];
const iterator = zip(keys, values);
console.log(iterator.next()); // { done: false, value: [ 'name', 'Marty' ] }
console.log(iterator.next()); // { done: false, value: [ 'job', 'Programmer' ] }
console.log(iterator.next()); // { done: false, value: [ 'location', 'New York' ] }
console.log(iterator.next()); // { done: true, value: undefined }
const obj = Object.fromEntries(zip(keys, values));
console.log(obj);
// { name: 'Marty', job: 'Programmer', location: 'New York' }
range 함수
range
함수는 시작값부터 끝값까지(끝값은 포함하지 않음) 진행하는 숫자들의 Iterable/AsyncIterable을 반환하는 함수다. 시작값이 설정되지 않으면 0부터 시작한다.
pipe(
range(4),
toArray,
); // [0, 1, 2, 3]
// 1부터 10까지 제곱 계산
pipe(
range(1, 11),
map(x => x * x),
toArray,
); // [1, 4, 9, 16, 25, 36, 49, 64, 81, 100]
// 테스트 데이터 생성
pipe(
range(5),
map(i => ({ id: i, name: `User${i}` })),
toArray,
);
// [
// { id: 0, name: 'User0' },
// { id: 1, name: 'User1' },
// { id: 2, name: 'User2' },
// { id: 3, name: 'User3' },
// { id: 4, name: 'User4' }
// ]
지연 평가를 통해 필요할 때만 값을 호출하므로 가변적인 배열의 길이에 맞춰 동적으로 숫자를 생성해야할 때도 사용할 수 있다.
const strings = ['a', 'b', 'c', 'd', 'e'];
const iter2 = zip(range(Infinity), strings);
for (const a of iter2) {
console.log(a);
}
// [0, 'a']
// [1, 'b']
// [2, 'c']
// [3, 'd']
// [4, 'e']
이처럼 zip
과 range
를 함께 사용하면 배열의 길이와 관계없이 유연하게 인덱스를 생성하고 매핑할 수 있다.
반복문이나 명령형 코드를 사용하는 대신 선언적으로 문제를 해결하며 코드의 가독성과 유연성을 높일 수 있다.
break를 대신하는 함수
break
는 명령형 코드에서 반복문의 불필요한 반복을 줄이고 시간 복잡도를 낮추며 효율을 높이기 위해 사용된다.
함수형 프로그래밍에서도 이와 비슷한 역할을 하는 take
, find
, some
, every
, head
등의 함수들이 존재한다.
take
는 지연 평가된 이터레이터에서 소비할 최대 개수를 지정하여 결과를 제한하는 함수다. 즉 take
는 숫자라는 값을 기반으로 시간 복잡도를 줄이는 함수다.
const iter = take(2, [0, 1, 2, 3, 4, 5, 6]);
iter.next() // {done:false, value: 0}
iter.next() // {done:false, value: 1}
iter.next() // {done:true, value: undefined}
// 검색 결과 미리보기 3개
pipe(
searchResults,
take(3),
map(item => item.title),
toArray,
);
만약 숫자가 아닌 조건을 기반으로 결과를 제한하고 싶다면? takeWhile
과 takeUntilInclusive
같은 함수들을 사용하면 된다.
takeWhile 함수
takeWhile
함수는 주어진 조건 함수 f
를 만족하는 동안 계속해서 값들을 가져오는 Iterable/AsyncIterable을 반환하는 함수다.
take
와 달리 개수가 아니라 조건을 기준으로 한다. 조건이 false
가 되는 순간 멈춘다.
const iter = takeWhile(a => a < 3, [1, 2, 3, 4, 5, 6]);
iter.next() // {done:false, value: 1} // 1 < 3 이므로 true
iter.next() // {done:false, value: 2} // 2 < 3 이므로 true
iter.next() // {done:true, value: undefined} // 3 < 3 이 false이므로 멈춤
takeUntilInclusive 함수
takeUntilInclusive
함수는 주어진 조건 함수 f
가 truthy를 반환할 때까지 값들을 가져오는 Iterable/AsyncIterable을 반환하는 함수다.
const iter = takeUntilInclusive(a => a % 2 === 0, [1, 2, 3, 4, 5, 6]);
iter.next() // {done:false, value: 1} // 1 % 2 === 0 은 false
iter.next() // {done:false, value: 2} // 2 % 2 === 0 은 true, 그런데 inclusive라서 2도 포함
iter.next() // {done:true, value: undefined} // 조건이 true가 되었으므로 멈춤
중요한 건 조건이 true가 되는 요소까지 포함해서 가져온다는 것이다.
백엔드 비동기 프로그래밍
자주 직면하는 문제들을 함수형 스타일과 리스트 프로세싱을 활용해 해결해보자. 백엔드 환경에서는 비동기적 상황이 빈번히 발생하기 때문에 자원을 효율적으로 활용하고 병렬성을 통해 시간을 단축하는 것이 중요하다.
안정적인 비동기 작업 간격 유지
특정 함수를 일정 시간 간격으로 반복 실행하도록 구현해야한다고 해보자. 리스트 프로세싱을 사용하면 다음과 같이 처리할 수 있다.
await fx(range(Infinity))
.toAsync()
.forEach(() => Promise.all([
syncPayments(),
delay(10000)
]));
이 코드는 syncPayments
함수를 10초 간격으로 반복 실행한다. 코드를 자세히 살펴보면 다음과 같다.
range(Infinity)
를 활용한 무한 이터러블- 언제 끝날지 모르는 반복 작업이기 때문에 무한 이터러블을 사용했다.
toAsync
를 사용해 비동기적으로 전환한다.
forEach
를 사용한 반복 실행- 이터러블을 (여기서는 무한 반복)을 순회하며 작업을 실행한다.
Promise.all
로 동시 실행- Promise.all은 두 작업이 모두 완료될 때까지 대기한다.
- 따라서
delay
함수에 의해 최소 10초는 기다리게 된다.
최대 요청 크기 제한을 효과적으로 처리하기
백엔드 시스템에서는 서비스 간의 통신에서 요청 크기에 제한을 두는 경우가 많다. 예를 들어, 특정 함수가 한 번에 처리할 수 있는 요청 크기를 5개로 제한한다면 어떻게 해야할까?
chunk
를 활용해 요청을 N개씩 분할하여 안전하게 요청할 수 있다.
fx(payments)
.map(p => p.store_order_id)
.chunk(5)
.toAsync()
.flatMap(StoreDB.getOrders)
.toArray();
병렬성으로 효율 높이기
총 몇 페이지를 요청해야 하는지 안다면 모든 페이지를 반드시 순차적으로 요청할 필요가 없다.
FxTS 라이브러리에서는 동시성 처리를 위한 concurrent
메서드를 제공한다.
await pipe(
[1, 2, 3, 4, 5, 6],
toAsync,
map((a) => delay(1000, a)),
concurrent(3),
each(console.log), // log 1, 2, 3, 4, 5, 6
); // 2 seconds
// evaluation
// ┌─────┐ ┌─────┐ ┌─────┐ ┌─────┐ ┌─────┐ ┌─────┐
// │ 1 │──│ 2 │──│ 3 │──│ 4 │──│ 5 │──│ 6 │
// └──┬──┘ └──┬──┘ └──┬──┘ └──┬──┘ └──┬──┘ └──┬──┘
// map │ │ │ │ │ │
// concurrent(3) (1) (1) (1) (2) (2) (2)
// │ │ │ │ │ │
// ▼ ▼ ▼ ▼ ▼ ▼
리스트 프로세싱 패턴화
이번에는 리스트 프로세싱의 다양한 조합을 좀 더 구조적으로 이해할 수 있도록 패턴화된 몇 가지 사례를 알아보자.
이번 절의 예제들을 통해 리스트 프로세싱 기법을 더 잘 기억하고 필요할때 효과적으로 사용할 수 있도록 해보자.
변형-누적(map-reduce) 패턴
가장 널리 사용되는 패턴 중 하나로 이터러블을 map
으로 변형한 뒤 reduce
로 누적하여 최종 결과를 도출한다. 결과물이 단일 값일 때 주로 사용한다.
Query String을 객체로 변환하기
const queryString = "name=Sanghyeon%20Lee&gender=male&city=Seoul";
const queryObject = queryString
.split("&")
.map((param) => param.split("="))
.map(entry => entry.map(decodeURIComponent))
.map(([key, val]) => ({ [key]: val }))
.reduce((a, b) => Object.assign(a, b), {});
console.log(queryObject);
// {name: "Sanghyeon Lee", gender: "male", city: "Seoul"}
객체를 Query String으로 변환하기
const params = { name: "Sanghyeon Lee", gender: "male", city: "Seoul" };
const queryString =
Object.entries(params)
.map(entry => entry.map(encodeURIComponent))
.map((entry) => entry.join('='))
.join('&');
pipe
함수와 커링을 사용하면 다음과 같이 쓸 수 있다.
const queryString = pipe(
Object.entries(params),
map(map(encodeURIComponent)),
map(join('=')),
join('&'),
);
반복자-효과(Iterator-forEach) 패턴
이터레이터를 만들어둔 후 지연 평가를 통해 데이터를 소비하며 부수적인 효과(forEach)를 발생시키는 패턴이다.
이 패턴은 주로 이터레이터를 소비하면서 특정 작업(로깅, 출력, 네트워크 요청 등)을 수행할 때 사용된다.
결과로 데이터는 생성되지 않으며 작업 자체가 목적이 되는 경우에 적합하다.
fx(range(5))
.map(x => x * 2)
.forEach(x => console.log('x', x));
앞서 살펴봤던 비동기 반복을 제어하는 코드도 이 패턴이다.
await fx(range(Infinity))
.toAsync()
.forEach(() => Promise.all([
syncPayments(),
delay(10000)
]));
부수 효과를 격리하는 forEach
forEach
는 반환값이 없는 메서드로 명시적으로 부수 효과를 수반하는 동작을 수행하기 위해 설계되었다.
이처럼 부수효과를 격리하는 설계 방식은 코드의 유지보수성을 높이는데 중요한 역할을 한다.
데이터의 순수한 변환은 map
, filter
, reduce
와 같은 메서드에서 처리하고 DOM 삭제, 로그 작성, API 호출 등의 부수 효과는 forEach
내에서 처리된다.
때로는 부수적인 효과를 일으키면서도 실행 결과를 반환해야 할 때가 있다. 이럴 때는 mapEffect
와 같은 함수명을 사용하면 map
과 유사하게 동작하지만 부수 효과를 포함한 동작임을 명시할 수 있다.
리스트 프로세싱 함수 유형별 개념 정리
리스트 프로세싱 함수를 다음과 같이 분류하고자 한다.
지연 중간 연산 (Lazy Intermediate Operations)
결과가 실제로 필요할 때까지 연산을 미루며, 이 단계만으로는 최종 결과가 나오지 않는다.
// 이 시점에서는 아직 아무것도 실행되지 않음
const pipeline = pipe(
[1, 2, 3, 4, 5],
map(x => {
console.log(`Processing: ${x}`); // 아직 출력되지 않음
return x * 2;
}),
filter(x => x > 4)
);
// toArray를 호출해야 실제로 실행됨
const result = toArray(pipeline); // 이제 "Processing: ..." 출력됨
console.log(result); // [6, 8, 10]
주요 함수
map
,filter
: 데이터 변환과 필터링zip
: 여러 이터러블 결합flatten
: 중첩 구조 평탄화chunk
: 데이터를 청크로 분할
단축(Short-Circuit) 중간 연산
특정 조건이 충족되면 그 시점에서 불필요한 연산을 건너뛴다.
// 실무 예제: 첫 번째 유효한 설정 찾기
const findValidConfig = (configs) =>
pipe(
configs,
map(config => {
console.log(`Validating: ${config.name}`);
return validateConfig(config); // 무거운 검증 로직
}),
takeWhile(config => !config.isValid), // 유효한 설정을 찾으면 중단
head // 첫 번째 결과만 반환
);
// 만약 두 번째 설정이 유효하다면, 세 번째부터는 검증하지 않음
주요 함수
take(n)
: 처음 n개만 가져오기takeWhile(predicate)
: 조건을 만족하는 동안만takeUntilInclusive(predicate)
: 조건을 만족하는 요소까지 포함해서
터미널 연산 (Terminal Operations)
실제 이터러블을 소비하여 최종 결과를 만들어내요. 한 번 호출하면 지연이 해제되고 실제 순회가 일어나요.
// 이터러블을 최종적으로 소비하는 함수들
const users = [
{ name: 'Alice', age: 25, isActive: true },
{ name: 'Bob', age: 30, isActive: false },
{ name: 'Charlie', age: 35, isActive: true }
];
// 각각 터미널 연산 - 실제 순회 발생
const activeUser = pipe(users, find(user => user.isActive)); // Alice
const hasInactiveUser = pipe(users, some(user => !user.isActive)); // true
const allActive = pipe(users, every(user => user.isActive)); // false
const userArray = pipe(users, toArray); // 배열로 변환
주요 함수
find
: 조건에 맞는 첫 번째 요소some
: 하나라도 조건에 맞는지 확인every
: 모든 요소가 조건에 맞는지 확인toArray
: 배열로 변환head
: 첫 번째 요소
폴드/리듀스 연산 (Fold/Reduce Operations)
터미널 연산 중에서도 시퀀스 전체를 하나의 값으로 누적하여 반환하는 연산이다.
// 실무 예제: 주문 데이터 분석
const orders = [
{ id: 1, amount: 15000, category: 'food', date: '2024-01-15' },
{ id: 2, amount: 8000, category: 'food', date: '2024-01-16' },
{ id: 3, amount: 25000, category: 'electronics', date: '2024-01-15' }
];
// reduce: 전체 매출 합계
const totalSales = pipe(
orders,
map(order => order.amount),
reduce((sum, amount) => sum + amount, 0)
); // 48000
// groupBy: 카테고리별 그룹핑 (객체로 누적)
const ordersByCategory = pipe(
orders,
groupBy(order => order.category)
); // { food: [...], electronics: [...] }
// indexBy: ID를 키로 하는 맵 생성
const ordersById = pipe(
orders,
indexBy(order => order.id)
); // { 1: {...}, 2: {...}, 3: {...} }
부수 효과 (Side Effects)
특징: 출력, 로그, 파일 쓰기 등 외부 상태를 변경하는 연산이다.
// 부수 효과를 명시적으로 분리
const processUserData = (users) =>
pipe(
users,
filter(user => user.isActive), // 순수 함수
map(user => enrichUserData(user)), // 순수 함수
tap(user => console.log(`Processing: ${user.name}`)), // 부수 효과 (로깅)
map(user => validateUser(user)), // 순수 함수
forEach(user => saveToDatabase(user)) // 부수 효과 (DB 저장)
);
이 개념들을 기억해두면 리스트 프로세싱 파이프라인을 설계할 때 '어떤 시점에 변환이 이뤄지는지', '어디서 데이터를 실제로 소비하는지', '최종 결과를 어떻게 산출하는지' 등을 체계적으로 파악하며 더 효율적이고 가독성 높은 코드를 작성할 수 있다.