다중 인자 함수와 파이프라인
함수형 프로그래밍 스타일로 코드를 작성할 때, 여러 함수를 조합하여 데이터 처리 파이프라인을 구축하는 것은 흔한 일입니다. 이때 단일 인자를 받는 함수들은 파이프라인에 손쉽게 통합될 수 있지만, 두 개 이상의 인자를 받는 함수들, 특히 filter나 map과 같은 컬렉션 처리 함수들을 사용할 때는 추가적인 전략이 필요합니다.
예를 들어, 특정 연도에 출판된 책들의 제목을 모두 찾아내는 기능을 구현한다고 가정해 봅시다. 책 객체들의 목록과 목표 연도가 주어졌을 때, 첫 시도는 다음과 같을 수 있습니다.
const checkPublicationStatus = (bookData, targetYear) => bookData.publishedYear === targetYear;
const getTitlesForYear = (booksCollection, year) => {
const filteredBooks = booksCollection.filter(book => checkPublicationStatus(book, year));
return filteredBooks.map(book => book.title);
};
// 사용 예시:
// const allBooks = [
// { title: 'The Great Novel', publishedYear: 2020 },
// { title: 'Fantasy World', publishedYear: 2021 },
// { title: 'Sci-Fi Future', publishedYear: 2020 },
// ];
// console.log(getTitlesForYear(allBooks, 2020)); // ['The Great Novel', 'Sci-Fi Future']
위 코드에서 filter와 map 호출을 하나의 함수형 파이프라인으로 연결하고 싶지만, 두 함수 모두 두 개의 인자를 받기 때문에 바로 적용하기 어렵습니다. 또한, filter 내부에 익명 함수 book => checkPublicationStatus(book, year)를 사용하는 것도 개선의 여지가 있습니다. 이 문제를 해결하기 위한 첫 단계는 고차 함수(Higher-Order Function)의 개념을 이해하는 것입니다.
고차 함수 (Higher-Order Functions)
자바스크립트에서 함수는 "일급 객체(first-class citizen)"입니다. 이는 함수를 다른 함수의 인자로 전달하거나, 다른 함수가 결과로 반환할 수 있다는 의미입니다. 인자로 함수를 전달하는 경우는 흔히 보았지만, 함수를 반환하는 경우는 아직 접하지 못했을 수 있습니다.
다른 함수를 인자로 받거나 결과로 반환하는 함수를 "고차 함수"라고 부릅니다. 위 예제에서 filter에 전달된 익명 함수를 제거하려면, checkPublicationStatus를 연도 인자를 받고, 다시 책 객체를 인자로 받아 출판 여부를 판단하는 함수를 반환하는 고차 함수로 변경해야 합니다.
// 일반 함수 문법으로 고차 함수 정의
function createPublicationChecker(targetYear) {
return function(bookData) {
return bookData.publishedYear === targetYear;
};
}
// 화살표 함수 문법으로 간결하게 정의
const createPublicationCheckerArrow = targetYear => bookData => bookData.publishedYear === targetYear;
새로 정의된 createPublicationCheckerArrow 함수를 사용하면 filter 호출에서 익명 함수를 제거할 수 있습니다.
const createPublicationCheckerArrow = targetYear => bookData => bookData.publishedYear === targetYear;
const getTitlesForYearImproved = (booksCollection, year) => {
const filteredBooks = booksCollection.filter(createPublicationCheckerArrow(year));
return filteredBooks.map(book => book.title);
};
이제 filter가 호출될 때, createPublicationCheckerArrow(year)는 즉시 평가되어 bookData를 인자로 받는 함수를 반환합니다. 이 반환된 함수는 filter가 각 책 객체에 대해 실행하기를 기대하는 형태와 정확히 일치합니다.
부분 적용 (Partial Application)
모든 다중 인자 함수를 위와 같은 고차 함수 형태로 직접 재작성할 수도 있지만, 우리가 소유하지 않은 외부 라이브러리 함수에는 적용하기 어렵습니다. 또한, 때로는 원래의 다중 인자 형태로 함수를 사용하고 싶을 때도 있습니다.
예를 들어, 단순히 checkPublicationStatus(someBook, 2020)처럼 호출하고 싶을 때, createPublicationCheckerArrow(2020)(someBook) 형태로 호출해야 하는 것은 가독성을 떨어뜨리고 번거로울 수 있습니다.
람다(Ramda)는 이러한 상황을 위해 R.partial과 R.partialRight 함수를 제공합니다. 이 함수들은 어떤 함수를 필요한 인자보다 적은 수의 인자로 호출할 수 있게 해줍니다. 이들은 나머지 인자를 기다리는 새로운 함수를 반환하며, 모든 인자가 제공되면 원래 함수를 실행합니다.
R.partial은 원래 함수의 왼쪽 인자부터 채워나가고, R.partialRight는 오른쪽 인자부터 채워나갑니다.
다시 첫 번째 예제로 돌아가 checkPublicationStatus 함수를 재작성하는 대신 R.partialRight를 사용해 봅시다. targetYear가 원래 함수의 가장 오른쪽 인자이므로 R.partialRight가 적합합니다.
import * as R from 'ramda';
const checkPublicationStatus = (bookData, targetYear) => bookData.publishedYear === targetYear;
const getTitlesForYearPartial = (booksCollection, year) => {
// targetYear를 오른쪽 인자로 부분 적용합니다.
const predicate = R.partialRight(checkPublicationStatus, [year]); // 주의: 인자는 배열로 전달
const filteredBooks = booksCollection.filter(predicate);
return filteredBooks.map(book => book.title);
};
만약 checkPublicationStatus가 원래 (targetYear, bookData) 순서로 정의되었다면, R.partial을 사용했을 것입니다.
중요: R.partial과 R.partialRight에 전달하는 인자들은 항상 배열 형태여야 합니다. 단 하나의 인자일지라도 배열로 감싸야 합니다. 이 규칙을 잊으면 혼란스러운 오류 메시지를 만날 수 있습니다.
커링 (Currying)
R.partial이나 R.partialRight를 매번 사용하는 것은 장황하고 반복적일 수 있습니다. 그렇다고 다중 인자 함수를 일련의 단일 인자 함수로 호출하는 것도 번거로울 수 있습니다.
람다(Ramda)는 이 문제에 대한 우아한 해결책으로 R.curry를 제공합니다. 커링은 함수형 프로그래밍의 핵심 개념 중 하나입니다. 기술적으로 커링된 함수는 항상 일련의 단일 인자 함수들을 반환합니다.
람다의 R.curry는 전통적인 커링 정의를 약간 완화하여, 커링된 함수를 필요한 모든 인자와 함께 한 번에 호출할 수도 있고, 인자의 일부만 제공하여 나머지 인자를 기다리는 새로운 함수를 반환하게 할 수도 있습니다. 즉, R.curry는 `R.partial`을 사용하는 것과 같은 유연성을 제공하면서도, 모든 인자를 한 번에 전달하여 일반 함수처럼 호출할 수 있는 두 가지 장점을 모두 제공합니다.
R.curry는 항상 R.partial처럼 왼쪽 인자부터 채워나갑니다. 따라서 R.curry를 최대한 활용하려면, 함수의 인자 순서를 "데이터가 마지막에 오는" 람다의 컨벤션에 맞게 조정하는 것이 좋습니다. 즉, `targetYear`를 먼저 받고 `bookData`를 나중에 받는 식으로 변경하는 것이 일반적입니다.
import * as R from 'ramda';
// 인자 순서 변경: targetYear가 먼저 오고 bookData가 마지막에 옵니다.
const isPublicationTargetYear = R.curry((targetYear, bookData) => bookData.publishedYear === targetYear);
const getTitlesForYearCurried = (booksCollection, year) => {
const predicate = isPublicationTargetYear(year); // 이제 year만 제공하면 bookData를 기다리는 함수가 반환됩니다.
const filteredBooks = booksCollection.filter(predicate);
return filteredBooks.map(book => book.title);
};
// isPublicationTargetYear(2020, someBook)처럼 모든 인자를 한 번에 호출하는 것도 가능합니다.
이제 isPublicationTargetYear(year)처럼 연도만으로 함수를 호출하여 책을 인자로 받는 새로운 함수를 얻을 수 있습니다. 동시에 isPublicationTargetYear(2020, someBook)처럼 모든 인자를 한 번에 전달하여 정상적으로 호출하는 것도 가능합니다.
인자 순서의 유연성
R.curry를 효과적으로 사용하려면 인자 순서를 "데이터가 마지막에 오는" 람다의 컨벤션에 맞추는 것이 좋습니다. 즉, 연산의 "설정(configuration)" 역할을 하는 인자(예: targetYear)를 먼저 받고, 연산될 "데이터(data)" 역할을 하는 인자(예: bookData)를 마지막에 배치합니다.
하지만 원본 함수의 인자 순서를 바꿀 수 없거나, 특정 순서가 필요한 경우도 있습니다. 람다는 이때를 위한 몇 가지 유용한 옵션을 제공합니다.
R.flip
R.flip은 두 개 이상의 인자를 받는 함수를 새로운 함수로 변환하며, 이때 첫 두 인자의 순서를 바꿉니다. 주로 두 인자 함수에 사용되지만, 그 이상에서도 첫 두 인자의 순서만 바꿉니다.
원본 isPublicationTargetYear의 인자 순서를 (bookData, targetYear)로 유지하면서 R.curry를 적용했다면, R.flip을 사용하여 targetYear를 먼저 적용할 수 있습니다.
import * as R from 'ramda';
const originalPublicationCheck = R.curry((bookData, targetYear) => bookData.publishedYear === targetYear);
const getTitlesForYearFlipped = (booksCollection, year) => {
// R.flip을 사용하여 originalPublicationCheck의 첫 두 인자(bookData, targetYear) 순서를 바꿉니다.
// 그 결과 targetYear를 먼저 받을 수 있게 됩니다.
const yearPredicate = R.flip(originalPublicationCheck)(year);
const filteredBooks = booksCollection.filter(yearPredicate);
return filteredBooks.map(book => book.title);
};
대부분의 경우 편리한 인자 순서(targetYear, bookData)를 선호하지만, 제어할 수 없는 외부 함수를 사용할 때 R.flip은 유용한 옵션입니다.
자리 표시자 (Placeholder, R.__)
더 일반적인 옵션은 "자리 표시자(placeholder)" 인자 R.__ (언더스코어 두 개)를 사용하는 것입니다.
만약 세 개의 인자를 받는 커링된 함수가 있고, 첫 번째와 세 번째 인자만 먼저 제공하고 가운데 인자는 나중에 제공하고 싶다면 R.__를 사용할 수 있습니다.
import * as R from 'ramda';
const processThreeArgs = R.curry((argA, argB, argC) => {
console.log(`A: ${argA}, B: ${argB}, C: ${argC}`);
return `${argA}-${argB}-${argC}`;
});
// argB 자리에 R.__를 사용하여 나중에 채워질 것을 명시합니다.
const middleArgLater = processThreeArgs('Value A', R.__, 'Value C');
// console.log(middleArgLater('Value B')); // 출력: A: Value A, B: Value B, C: Value C; 반환: Value A-Value B-Value C
R.__는 R.flip 대신에도 사용될 수 있습니다.
import * as R from 'ramda';
const originalPublicationCheck = R.curry((bookData, targetYear) => bookData.publishedYear === targetYear);
const getTitlesForYearPlaceholder = (booksCollection, year) => {
// bookData 인자 자리에 R.__를 사용하여 나중에 채워질 것을 표시합니다.
const yearPredicate = originalPublicationCheck(R.__, year);
const filteredBooks = booksCollection.filter(yearPredicate);
return filteredBooks.map(book => book.title);
};
R.__는 커링된 함수에만 작동합니다. 일반 함수와 함께 R.__를 사용해야 한다면, 먼저 R.curry로 함수를 래핑해야 합니다.
파이프라인 구축
이제 지금까지 배운 개념들을 활용하여 filter와 map 호출을 파이프라인으로 이동시켜 봅시다. 현재 코드는 다음과 같은 형태입니다.
import * as R from 'ramda';
const isPublicationTargetYear = R.curry((targetYear, bookData) => bookData.publishedYear === targetYear);
const getTitlesForYearCurried = (booksCollection, year) => {
const predicate = isPublicationTargetYear(year);
const filteredBooks = booksCollection.filter(predicate);
return filteredBooks.map(book => book.title);
};
람다의 거의 모든 함수는 기본적으로 커링되어 있습니다. 여기에는 R.filter와 R.map도 포함됩니다. 따라서 R.filter(isPublicationTargetYear(year))는 완전히 유효하며, booksCollection을 나중에 전달받을 새로운 함수를 반환합니다. R.map(book => book.title)도 마찬가지입니다.
이를 통해 파이프라인을 구성할 수 있습니다.
import * as R from 'ramda';
const isPublicationTargetYear = R.curry((targetYear, bookData) => bookData.publishedYear === targetYear);
const getTitlesForYearPipeline = R.curry((year, booksCollection) =>
R.pipe(
R.filter(isPublicationTargetYear(year)), // year가 적용된 함수가 반환되어, 책 컬렉션을 기다립니다.
R.map(book => book.title) // 책 객체에서 제목만 추출하는 함수를 기다립니다.
)(booksCollection) // 최종적으로 책 컬렉션을 파이프라인에 전달합니다.
);
// 사용 예시:
// const allBooks = [
// { title: 'The Great Novel', publishedYear: 2020 },
// { title: 'Fantasy World', publishedYear: 2021 },
// { title: 'Sci-Fi Future', publishedYear: 2020 },
// ];
// console.log(getTitlesForYearPipeline(2020, allBooks)); // ['The Great Novel', 'Sci-Fi Future']
getTitlesForYearPipeline 함수 자체도 람다의 컨벤션(데이터 마지막)에 맞춰 (year, booksCollection) 순서로 인자를 받고 R.curry를 적용하여, 향후 이 함수를 다른 파이프라인에 더 쉽게 통합할 수 있도록 했습니다.