목차
1. Pipe 함수 소개
2. pipe(arg, f1, f2) vs pipe(f1, f2)(arg)
3. Typescript 적용
4. 마치면서
1. Pipe 함수 소개
지난 curry 함수에 이어 이번엔 pipe 함수를 만들어보려고 한다. Pipe 함수는 여러 개의 함수를 왼쪽에서 오른쪽으로 순차적으로 실행하는 함수이다. 데이터 변환의 흐름이 명확하게 표현되어 코드의 동작 흐름을 더 직관적으로 볼 수 있고, 선언적 프로그래밍 스타일을 더 쉽게 적용할 수 있다. 다음 코드를 보자.
const add = x => x + 1;
const multiply = x => x * 2;
const subtract = x => x - 3;
const result = subtract(multiply(add(5)));
console.log(result); // 9
add를 한 결과를 multiply하고, 그 결과를 subtract해서 result가 된다.
이번엔 똑같은 코드를 pipe로 구현해보자.
const processValue = pipe(add, multiply, subtract);
console.log(processValue(5)); // 9
첫 번째 코드는 안쪽에서 바깥쪽으로 읽어야 하기 때문에 가독성이 떨어지는 반면, 이렇게 pipe를 사용하면 add → multiply → subtract의 연속적인 함수 실행 흐름을 직관적으로 표현할 수 있다. Pipe를 사용함으로써 'how'보다는 'what'에 집중하는 선언적 프로그래밍 스타일이 더 됐다.
이제 pipe함수를 타입스크립트로 만들어보자.
2. pipe(arg, f1, f2) vs pipe(f1, f2)(arg)
🚩
즉시 실행 스타일: pipe(arg, f1, f2, ...)
커리 스타일: pipe(f1, f2, ...)(arg)
사람들이 사용하는 pipe 함수의 형태에는 크게 두 가지가 있다. fp-ts 라이브러리는 즉시 실행 스타일을 채택하고 있고, ramda 라이브러리는 커리 스타일을 채택하고 있다.
1️⃣ 즉시 실행 스타일 pipe(arg, f1, f2, ...)
const result = pipe(5, add, double, subtract);
모양을 보면 arg가 바로 전달되므로, pipe를 호출하면 즉시 실행된다. 커리 스타일과 비교했을 때 데이터 흐름이 좀 더 명확하게 표현되어 가독성이 좋다.
2️⃣ 커리 스타일 pipe(f1, f2, ...)(arg)
이 스타일을 보면 pipe(fn1, fn2, fn3)가 새로운 함수를 반환하고, result = pipe(fn1, fn2, fn3)(arg)처럼 한 번 더 실행해야 한다. 즉, 함수 자체를 조합할 수 있는 능력이 강하다고 볼 수 있다.
데이터보다는 변환 로직을 먼저 정의하고, 이후에 데이터를 전달하는 방식, 즉 2번으로 구현해보자.
3. TypeScript
이제 타입스크립트에서도 안정적으로 사용할 수 있도록 타입을 붙여보자.
3.1. Pipe 함수의 타입 요구사항
pipe 함수에서 보장해야 할 것:
- 각 함수의 반환값이 다음 함수의 입력값과 일치해야 한다.
- (arg: X) => Y, (arg: Y) => Z, (arg: Z) => K 처럼, 함수 체인의 연결이 타입 안정성을 유지해야 한다.
- 최종 반환값은 마지막 함수의 반환값과 동일해야 한다.
- 즉, pipe에 전달된 마지막 함수의 반환값이 전체 pipe의 최종 결과여야 한다.
즉, n+1 번째 함수의 인자는 n 번째 함수의 ReturnType이어야 한다.
이러한 타입 안정성을 유지하면서 pipe를 구현하는 방법을 살펴보자.
3.2. 재귀적인 타입 구현이 가능할까?
처음에는 재귀적으로 타입을 추적하는 방법을 고려했다. 실제로 다음 블로그 글에서는 재귀적인 방식으로 pipe의 타입을 정의했다.
https://dev.to/ecyrbe/how-to-use-advanced-typescript-to-define-a-pipe-function-381h
How to use advanced Typescript to define a `pipe` function
Typescript is awesome but some functionnal libraries have limited implementation for Typescript...
dev.to
위 블로그의 코드를 잠깐 소개해보면,
type PipeArgs<F extends AnyFunc[], Acc extends AnyFunc[] = []> = F extends [
(...args: infer A) => infer B
]
? [...Acc, (...args: A) => B]
: F extends [(...args: infer A) => any, ...infer Tail]
? Tail extends [(arg: infer B) => any, ...any[]]
? PipeArgs<Tail, [...Acc, (...args: A) => B]>
: Acc
: Acc;
function pipe<FirstFn extends AnyFunc, F extends AnyFunc[]>(
arg: Parameters<FirstFn>[0],
firstFn: FirstFn,
...fns: PipeArgs<F> extends F ? F : PipeArgs<F>
): LastFnReturnType<F, ReturnType<FirstFn>> {
return (fns as AnyFunc[]).reduce((acc, fn) => fn(acc), firstFn(arg));
}
이 방식은 무제한으로 pipe에 함수를 넣을 수 있는 장점이 있다.
하지만 현실적으로 너무 복잡한 타입 시스템이 되고, 컴파일 속도가 느려질 수 있다.
3.3. 현실적으로 pipe에 10개 이상의 함수를 사용할까?
함수형 프로그래밍에서 작은 단위의 함수를 조합하여 가독성을 높이는 것이 목표다. 하지만 하나의 pipe에 10개 이상의 함수를 넣어 사용하는 경우는 거의 없다.
pipe에 너무 많은 함수를 넣으면 다음과 같은 문제가 발생한다.
① 오히려 코드가 가독성이 떨어진다.
너무 긴 체인은 논리적으로 분리하는 것이 더 읽기 쉽다.
const step1 = pipe(func1, func2, func3);
const step2 = pipe(func4, func5, func6);
const finalStep = pipe(step1, step2, func7, func8);
② TypeScript의 타입 검사기 성능이 저하된다.
TypeScript는 재귀적으로 타입을 추적하기 때문에, 체인이 길어질수록 타입 검사기가 느려진다. 최적의 성능을 유지하면서도 타입 안정성을 유지하는 방법을 선택해야 한다.
3.4. 함수 오버로딩으로 pipe를 구현하자
함수 오버로딩는 같은 이름의 함수를 여러 개 선언해서 각기 다른 매개변수 타입과 반환 타입을 가질 수 있도록 하는 기능이다. 즉, 함수의 시그니처를 여러 개 정의해서 다양한 입력 조합을 처리할 수 있도록 설계하는 기법이다.
함수 오버로딩은 여러 개의 함수를 선언하는 overload signatures와 실제 구현 부분으로 구성된다.
// 함수 오버로드 정의 (여러 개의 시그니처 선언)
function example(x: number): number;
function example(x: string): string;
// 실제 구현 (하나만 존재해야 함)
function example(x: number | string): number | string {
return typeof x === "number" ? x * 2 : x.toUpperCase();
}
// 사용 예시
console.log(example(10)); // 20
console.log(example("hello")); // "HELLO"
이걸로 pipe를 구현해보면 다음과 같다.
// overload
export function pipe<A, B>(ab: (a: A) => B): (a: A) => B;
export function pipe<A, B, C>(ab: (a: A) => B, bc: (b: B) => C): (a: A) => C;
export function pipe<A, B, C, D>(
ab: (a: A) => B,
bc: (b: B) => C,
cd: (c: C) => D
): (a: A) => D;
export function pipe<A, B, C, D, E>(
ab: (a: A) => B,
bc: (b: B) => C,
cd: (c: C) => D,
de: (d: D) => E
): (a: A) => E;
(생략...)
// implementation
export function pipe(firstFn: Function, ...fns: Function[]): Function {
return (arg: any) => fns.reduce((acc, fn) => fn(acc), firstFn(arg));
}
이렇게 해서 타입스크립트가 정확한 타입을 추론할 수 있게 됐다. 함수의 입력과 출력 관계가 명확해졌고, 재귀적 방법과 비교했을 때 컴파일 속도가 훨씬 빠르고 IDE에서 자동 완성이 정확하게 동작한다.
하지만, 최대 개수를 미리 지정해야 한다는 단점이 있지만!! 현실적으로 10개 이상의 pipe를 사용할 일이 거의 없으므로 문제 없다고 판단된다.
4. 마치면서
이번 글에서는 TypeScript에서 pipe 함수를 함수 오버로딩으로 하드코딩했다. 처음에는 무제한 함수 체인을 지원하는 재귀적 타입 시스템을 고려했지만,
- 타입이 너무 복잡해지고,
- 컴파일 속도가 느려지며,
- 실제로 10개 이상의 함수를 pipe에 연결하는 경우가 거의 없다는 점을 고려했을 때,
현실적으로 함수 오버로딩 방식이 가장 적절한 선택이라는 결론에 도달했다.
가끔은 무식한 방법이 가장 실용적인 방법일 때가 있다. 직관적이고, 성능이 뛰어나며, 유지보수가 쉬운 코드가 더 나은 선택이 될 수 있기 때문이다.
TypeScript의 타입 시스템을 활용하는 것도 중요하지만, 현실적인 사용성과 가독성을 고려하는 것도 중요하다!
추가..
fp-ts 라이브러리의 pipe 함수도 똑같은 방식으로 구현된 것을 볼 수 있다!
'CS > Functional Programming' 카테고리의 다른 글
직접 만든 라이브러리를 우테코 미션에 녹여내기 (5) | 2025.03.19 |
---|---|
[FP] 아무도 모르는 side effect는 괜찮을까? (2) | 2025.02.26 |
[FP] Type Safe Curry함수 만들기 (1) | 2025.02.01 |