함수형 프로그래밍에 익숙해지면, 단순히 함수를 호출하는 것을 넘어서 여러 함수를 조합해 새로운 기능을 만들어내는 사고방식이 자연스럽게 자리 잡습니다. Ramda는 이러한 사고를 지원하기 위해 다양한 고차 함수를 제공합니다. 이번 글에서는 complement, both, either, 그리고 pipe와 compose 같은 조합 도구들을 살펴보며, 어떻게 작은 함수들을 조립해 더 복잡한 로직을 명확하게 표현할 수 있는지 알아봅니다.
조건 함수의 반전: complement
짝수인지 판단하는 함수가 있다고 가정해 봅시다.
const isEven = n => n % 2 === 0;
Ramda의 find 함수를 사용하면 이 조건에 맞는 첫 번째 값을 찾을 수 있습니다.
find(isEven, [1, 3, 4, 6]); // 결과: 4
홀수를 찾고 싶다면? 굳이 isOdd를 새로 만들 필요 없이, 기존 함수의 결과를 반전시키면 됩니다. Ramda의 complement는 정확히 이 역할을 수행합니다.
const isOdd = complement(isEven);
find(isOdd, [2, 4, 5, 7]); // 결과: 5
complement(f)는 마치 !f()처럼 동작하지만, 값이 아니라 함수 자체를 다룬다는 점이 핵심입니다. 이를 통해 불리언 논리를 함수 수준에서도 추상화할 수 있습니다.
여러 조건 결합: both와 either
복잡한 조건은 종종 여러 하위 조건의 조합으로 구성됩니다. 예를 들어, 투표 자격을 갖추려면 성년이면서 시민권을 가져야 한다고 해봅시다.
const isAdult = person => person.age >= 18;
const bornInCountry = person => person.birthCountry === 'KO';
const naturalized = person => Boolean(person.naturalizationDate);
const hasCitizenship = either(bornInCountry, naturalized);
const canVote = both(isAdult, hasCitizenship);
both는 두 함수 모두 참일 때 참을 반환하며, either는 둘 중 하나라도 참이면 참을 반환합니다. 이들은 각각 &&와 || 연산자의 함수형 버전이라 할 수 있습니다. 더 많은 조건을 처리하려면 allPass나 anyPass를 사용할 수 있습니다.
데이터 흐름 파이프라인: pipe
값을 순차적으로 변환해야 하는 상황을 생각해 봅시다. 두 숫자를 곱하고, 그 결과에 1을 더한 후 제곱하는 연산이 필요하다고 가정합시다.
const multiply = (a, b) => a * b;
const increment = x => x + 1;
const square = x => x ** 2;
일련의 과정을 명시적으로 작성하면 다음과 같습니다.
const process = (x, y) => {
const step1 = multiply(x, y);
const step2 = increment(step1);
return square(step2);
};
이 구조는 데이터가 왼쪽에서 오른쪽으로 흐르는 파이프처럼 보입니다. Ramda의 pipe는 이를 추상화합니다.
const process = pipe(multiply, increment, square);
process(3, 4); // (3 * 4) => 12; (12 + 1) => 13; (13 ** 2) => 169
pipe는 함수들을 인자로 받아 새 함수를 생성합니다. 이 새 함수는 첫 번째 함수에 입력을 전달하고, 그 결과를 다음 함수로 넘기는 방식으로 실행됩니다. 중요한 점은 두 번째 이후 함수들은 반드시 단일 인수를 받아야 한다는 것입니다.
함수 합성: compose
compose는 pipe와 동일한 기능을 하지만, 함수 적용 순서가 반대입니다. 즉, 오른쪽에서 왼쪽으로 실행됩니다.
const process = compose(square, increment, multiply);
이 코드는 위의 pipe 예제와 동일한 결과를 내지만, 함수 배열 순서가 반대입니다. 수학에서의 함수 합성 f ∘ g가 f(g(x))를 의미하는 것처럼, compose(f, g)(x)도 같은 의미를 갖습니다.
compose는 중첩된 함수 호출을 직관적으로 표현할 때 유리합니다. 예를 들어 square(increment(multiply(3, 4)))라는 표현을 그대로 compose로 옮길 수 있기 때문입니다.
pipe vs compose: 선택 기준
어떤 것을 선택할지는 주로 가독성과 팀 컨벤션에 달렸습니다. 대부분의 개발자는 왼쪽에서 오른쪽으로 읽는 pipe가 데이터 흐름을 더 직관적으로 표현한다고 느낍니다. 특히 일련의 변환이나 ETL 작업에서는 pipe가 더 자연스럽습니다.
반면 compose는 수학적 전통에 익숙한 사람들에게 친숙하며, 기존의 중첩 표현을 그대로 옮기기에 적합합니다. 결국 두 함수는 서로로 변환 가능하므로, 특정 상황에서 더 읽기 쉬운 쪽을 선택하는 것이 좋습니다.