소프트웨어 개발의 세계는 끊임없이 변화하는 패러다임의 연속입니다. 절차적 프로그래밍에서 객체지향 프로그래밍으로, 그리고 최근에는 함수형 프로그래밍(Functional Programming)이 다시금 주목받고 있습니다. 함수형 프로그래밍은 복잡한 시스템을 더 작고, 예측 가능하며, 테스트하기 쉬운 순수 함수의 조합으로 구축하려는 접근 방식입니다. 이러한 함수형 프로그래밍의 핵심 철학을 관통하는 강력하고 우아한 기법이 바로 '커링(Currying)'입니다.
커링은 단순히 여러 인자를 받는 함수를 다르게 호출하는 문법적 설탕(Syntactic Sugar)이 아닙니다. 이는 함수를 더 유연하고 재사용 가능한 부품으로 만들어 코드의 모듈성을 극대화하고, 함수 조합(Function Composition)과 같은 고차원적인 패턴을 가능하게 하는 근본적인 사고방식의 전환을 의미합니다. 이 글에서는 커링의 이론적 배경부터 자바스크립트를 활용한 구체적인 구현 방법, 그리고 실무에서 마주할 수 있는 다양한 활용 사례까지 심도 있게 탐구하여 여러분의 코드에 새로운 차원의 표현력을 더할 수 있도록 돕고자 합니다.
커링의 본질: 함수의 변환과 클로저의 역할
커링을 정확히 이해하기 위해서는 먼저 그 정의부터 명확히 해야 합니다. 커링은 논리학자 하스켈 커리(Haskell Curry)의 이름에서 유래한 개념으로, N개의 인자를 받는 함수를 '하나의 인자'를 받는 N개의 함수 체인으로 변환하는 과정을 말합니다.
예를 들어, 세 개의 숫자를 더하는 함수 `add(x, y, z)`가 있다고 상상해 봅시다. 이 함수는 일반적으로 `add(10, 20, 30)`과 같이 모든 인자를 한 번에 전달하여 호출합니다. 하지만 이 함수를 커링하면 그 구조가 완전히 달라집니다.
// 일반적인 다중 인자 함수
function add(x, y, z) {
return x + y + z;
}
console.log(add(10, 20, 30)); // 60
// 위 함수의 커리된(Curried) 버전
function curriedAdd(x) {
return function(y) {
return function(z) {
return x + y + z;
};
};
}
// 호출 방식의 변화
const add10 = curriedAdd(10); // x = 10이 고정된 새로운 함수를 반환
const add10And20 = add10(20); // y = 20이 고정된 또 다른 함수를 반환
const finalResult = add10And20(30); // 마지막 인자 z를 받아 최종 결과를 계산
console.log(finalResult); // 60
// 또는 한 번에 호출
console.log(curriedAdd(10)(20)(30)); // 60
`curriedAdd` 함수는 첫 번째 인자 `x`를 받은 후, `x`를 기억하는 새로운 함수를 반환합니다. 이어서 반환된 함수는 두 번째 인자 `y`를 받고, `x`와 `y`를 모두 기억하는 또 다른 함수를 반환합니다. 이 과정은 모든 인자가 제공될 때까지 반복되며, 마지막 인자가 전달되었을 때 비로소 최종 계산이 실행됩니다.
이러한 연쇄적인 함수 반환이 가능한 이유는 자바스크립트의 클로저(Closure) 덕분입니다. 클로저는 함수가 자신이 생성될 때의 환경(Lexical Environment)을 기억하는 매커니즘입니다. `curriedAdd(10)`이 호출될 때, 내부 함수는 `x`의 값으로 `10`을 기억하는 클로저를 형성합니다. 마찬가지로 `add10(20)`이 호출되면, 그 내부 함수는 `x=10`과 `y=20`을 모두 기억하는 더 넓은 범위의 클로저를 형성하게 됩니다. 이처럼 클로저는 커링의 각 단계에서 필요한 인자(컨텍스트)를 "기억"하고 다음 단계로 전달하는 핵심적인 역할을 수행합니다.
커링과 부분 적용(Partial Application)의 차이
커링은 종종 '부분 적용'과 혼동되곤 합니다. 두 개념은 매우 밀접한 관련이 있지만, 엄밀히 말해 다릅니다.
- 커링(Currying): N개 인자를 받는 함수를 1개 인자를 받는 N개의 함수로 변환하는 과정 또는 변환 그 자체를 의미합니다. `f(a, b, c)`를 `f(a)(b)(c)`의 형태로 바꾸는 것입니다.
- 부분 적용(Partial Application): 다중 인자 함수에 일부 인자만 미리 고정하여 더 적은 수의 인자를 받는 새로운 함수를 만드는 행위 또는 그 결과물을 말합니다.
// 부분 적용의 예
function multiply(a, b, c) {
return a * b * c;
}
// bind를 사용하여 a=2, b=3을 미리 적용
const multiplyBy6 = multiply.bind(null, 2, 3);
console.log(multiplyBy6(10)); // 60 (c=10을 전달)
위 예제에서 `multiplyBy6`는 부분 적용의 결과물입니다. 원본 함수 `multiply`는 3개의 인자를 받지만, `multiplyBy6`는 1개의 인자만 받습니다. 커링은 언제나 인자를 하나씩 받는 함수들의 체인을 만들지만, 부분 적용은 한 번에 여러 인자를 고정할 수 있으며 결과적으로 N-k개의 인자를 받는 함수를 반환합니다.
결론적으로, 커리된 함수를 사용하여 첫 번째 인자만 전달하는 행위는 부분 적용의 한 형태로 볼 수 있습니다. 즉, 커링은 부분 적용을 매우 쉽고 우아하게 구현할 수 있는 강력한 도구입니다. 이 글의 나머지 부분에서는 이 둘을 엄격하게 구분하기보다는, 커링을 통해 부분 적용의 이점을 어떻게 극대화할 수 있는지에 초점을 맞출 것입니다.
왜 커링을 사용해야 하는가? 실용적 이점 심층 분석
커링의 개념을 이해했다면, 이제 왜 이러한 변환이 유용한지에 대한 질문에 답할 차례입니다. 커링은 단순히 코드를 더 복잡하게 만드는 것이 아니라, 다음과 같은 구체적이고 강력한 이점을 제공합니다.
1. 코드의 재사용성과 모듈성 극대화
커링의 가장 큰 장점은 **함수의 재사용성**을 획기적으로 높인다는 점입니다. 일반적인 함수는 특정 작업을 수행하기 위해 필요한 모든 정보를 한 번에 요구합니다. 하지만 커링을 사용하면, 함수의 일부 인자를 미리 고정하여 더 특화된(specialized) 버전을 쉽게 만들어낼 수 있습니다.
예를 들어, API 요청을 보내는 범용 함수가 있다고 가정해 봅시다. 이 함수는 HTTP 메소드, URL, 그리고 전송할 데이터를 인자로 받습니다.
// 일반적인 API 요청 함수
async function request(method, url, data) {
const options = {
method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
};
const response = await fetch(url, options);
return response.json();
}
// 사용 예시
const newUser = { name: 'Alice', age: 30 };
request('POST', '/api/users', newUser);
const updatedUser = { age: 31 };
request('PUT', '/api/users/123', updatedUser);
이 방식은 매번 `method`와 `url`을 반복적으로 입력해야 하는 번거로움이 있습니다. 커링을 적용하면 이 구조를 훨씬 효율적으로 개선할 수 있습니다.
// 커링을 활용한 API 요청 함수
const curriedRequest = (method) => (url) => async (data) => {
const options = {
method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
};
const response = await fetch(url, options);
return response.json();
};
// 1. HTTP 메소드를 고정하여 더 특화된 함수 생성
const postRequest = curriedRequest('POST');
const putRequest = curriedRequest('PUT');
// 2. URL까지 고정하여 특정 엔드포인트 전용 함수 생성
const createUser = postRequest('/api/users');
const updateUser = (id) => putRequest(`/api/users/${id}`);
// 3. 최종적으로 데이터만 전달하여 함수 호출
const newUser = { name: 'Alice', age: 30 };
createUser(newUser);
const updatedUser = { age: 31 };
const updateUser123 = updateUser(123);
updateUser123(updatedUser);
`curriedRequest`라는 하나의 범용 함수로부터 `postRequest`, `putRequest`, `createUser`, `updateUser` 등 다양한 용도의 함수들이 파생되었습니다. 이는 마치 공장에서 하나의 기본 틀을 가지고 다양한 맞춤형 부품을 생산하는 것과 같습니다. 코드의 중복이 사라지고, 각 함수의 책임이 명확해지며, 애플리케이션의 설정(configuration)과 실행(execution)이 자연스럽게 분리됩니다.
2. 함수 조합(Function Composition)의 강력한 촉매제
함수 조합은 여러 함수를 연결하여 마치 파이프라인처럼 데이터가 흐르도록 만드는 함수형 프로그래밍의 핵심 기법입니다. 예를 들어, `h(x) = f(g(x))`와 같이 `g` 함수의 출력이 `f` 함수의 입력으로 사용되는 형태입니다. 커링은 이러한 함수 조합을 매우 자연스럽고 읽기 쉽게 만들어줍니다.
커리된 함수는 대부분 마지막 인자로 처리할 데이터를 받도록 설계됩니다. 이렇게 하면 데이터가 파이프라인의 마지막에 주입될 때까지 함수의 조합을 자유롭게 구성할 수 있습니다.
// 함수 조합을 위한 유틸리티 함수
const compose = (...fns) => (initialValue) => fns.reduceRight((val, fn) => fn(val), initialValue);
const numbers = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
// 커리되지 않은 일반 함수들
const filterEvens = (arr) => arr.filter(n => n % 2 === 0);
const doubleAll = (arr) => arr.map(n => n * 2);
const sumAll = (arr) => arr.reduce((sum, n) => sum + n, 0);
// 사용법: 중첩이 깊어지고 가독성이 떨어짐
const result1 = sumAll(doubleAll(filterEvens(numbers)));
console.log(result1); // 120
// --- 커링을 사용한 개선 ---
// 커리된 버전의 함수들 (데이터를 마지막 인자로 받음)
const filter = (predicate) => (arr) => arr.filter(predicate);
const map = (transformer) => (arr) => arr.map(transformer);
const reduce = (reducer, initial) => (arr) => arr.reduce(reducer, initial);
// 각 연산을 나타내는 함수 정의
const isEven = n => n % 2 === 0;
const double = n => n * 2;
const sum = (a, b) => a + b;
// 함수 조합을 통해 데이터 처리 파이프라인 구축
const calculate = compose(
reduce(sum, 0),
map(double),
filter(isEven)
);
// 마지막에 데이터를 주입하여 파이프라인 실행
const result2 = calculate(numbers);
console.log(result2); // 120
커링을 사용한 두 번째 예제는 '무엇을(what)' 할 것인지(짝수 필터링, 2배 곱하기, 합산)와 '어떻게(how)' 조합할 것인지만을 선언적으로 보여줍니다. 실제 데이터(`numbers`)는 마지막에 주입됩니다. 이러한 스타일을 **포인트-프리 스타일(Point-free style)** 또는 **암묵적 프로그래밍(Tacit programming)**이라고 부르며, 처리해야 할 데이터(인자)를 직접 명시하지 않고 함수들의 조합으로 로직을 표현하는 기법입니다. 이는 코드의 추상화 수준을 높여 비즈니스 로직에 더 집중할 수 있게 해줍니다.
3. 지연된 계산(Lazy Evaluation)과 컨텍스트의 보존
커리된 함수는 모든 인자가 제공될 때까지 최종 계산을 지연시킵니다. 이 특성은 특정 컨텍스트나 설정이 필요한 작업을 나중에 실행해야 할 때 매우 유용합니다.
이벤트 핸들러를 등록하는 경우를 생각해 봅시다. 특정 버튼이 클릭되었을 때, 해당 버튼의 ID와 메시지를 함께 로그로 남기고 싶다고 가정해 보겠습니다.
// 커링을 사용하지 않은 경우 (추가적인 클로저 필요)
function logHandler(id, message) {
// 이벤트 핸들러는 event 객체 하나만 인자로 받으므로,
// id와 message를 전달하려면 클로저를 수동으로 만들어야 함
return function(event) {
console.log(`[${new Date().toISOString()}] Button '${id}' clicked. Message: ${message}`);
console.log('Event details:', event);
};
}
document.getElementById('save-btn').addEventListener('click', logHandler('save-btn', 'Saving data...'));
// 커링을 사용한 경우
const curriedLogHandler = (id) => (message) => (event) => {
console.log(`[${new Date().toISOString()}] Button '${id}' clicked. Message: ${message}`);
console.log('Event details:', event);
};
const saveButtonLogger = curriedLogHandler('save-btn');
document.getElementById('save-btn').addEventListener('click', saveButtonLogger('Saving data...'));
document.getElementById('load-btn').addEventListener('click', curriedLogHandler('load-btn')('Loading data...'));
`curriedLogHandler`는 `id`와 `message`라는 컨텍스트를 미리 받아두고, 실제 이벤트(`click`)가 발생했을 때 마지막 인자인 `event` 객체를 받아 실행되는 함수를 반환합니다. 이처럼 커링은 필요한 컨텍스트를 단계별로 주입하고 최종 실행을 지연시키는 패턴을 자연스럽게 표현할 수 있게 해줍니다. 이는 비동기 처리, 설정 관리, 의존성 주입 등 다양한 시나리오에서 코드를 명확하고 간결하게 만드는 데 기여합니다.
자바스크립트로 구현하는 범용 커링 함수
지금까지는 수동으로 함수를 중첩하여 커링을 구현했습니다. 하지만 매번 이렇게 하는 것은 비효율적입니다. 다행히 자바스크립트의 유연한 특성을 이용하면 어떤 함수든 자동으로 커리된 버전으로 변환해주는 범용 `curry` 함수를 만들 수 있습니다.
ES6를 이용한 현대적인 구현
ES6의 화살표 함수(Arrow Function)와 나머지 매개변수(Rest Parameters)를 사용하면 매우 간결하고 직관적인 `curry` 함수를 작성할 수 있습니다.
/**
* 함수를 커리된 버전으로 변환합니다.
* @param {Function} fn - 커링할 원본 함수
* @returns {Function} 커리된 함수
*/
const curry = (fn) => {
// 원본 함수의 인자 개수를 확인
const arity = fn.length;
return function curried(...args) {
// 현재까지 받은 인자의 개수가 원본 함수의 인자 개수보다 많거나 같으면
// 원본 함수를 즉시 실행
if (args.length >= arity) {
return fn(...args);
} else {
// 인자가 부족하면, 다음 인자를 받을 새로운 함수를 반환
// 이 때, 기존에 받은 인자(args)는 클로저에 저장됨
return function(...nextArgs) {
return curried(...args, ...nextArgs);
};
}
};
};
// --- 사용 예제 ---
function sum(a, b, c, d) {
return a + b + c + d;
}
const curriedSum = curry(sum);
console.log(curriedSum(1)(2)(3)(4)); // 10
console.log(curriedSum(1, 2)(3, 4)); // 10
console.log(curriedSum(1, 2, 3)(4)); // 10
console.log(curriedSum(1)(2, 3, 4)); // 10
const add1And2 = curriedSum(1, 2); // 부분 적용
const result = add1And2(3, 4);
console.log(result); // 10
이 `curry` 함수는 다음과 같이 동작합니다.
- 원본 함수 `fn`을 인자로 받아 그 함수의 인자 개수(`fn.length`)를 `arity` 변수에 저장합니다.
- `curried`라는 내부 함수를 반환합니다. 이 함수는 재귀적으로 자신을 호출하며 인자를 누적합니다.
- `curried` 함수가 호출될 때마다, 현재까지 누적된 인자(`args`)의 개수가 `arity`와 같은지 확인합니다.
- 인자가 충분하면 원본 함수 `fn`을 누적된 인자들과 함께 실행하고 결과를 반환합니다.
- 인자가 부족하면, 이전에 받은 인자(`args`)와 새로 받은 인자(`nextArgs`)를 합쳐서 다시 `curried` 함수를 호출하는 또 다른 함수를 반환합니다.
이 구현의 장점은 `curriedSum(1)(2)(3)(4)`처럼 전통적인 커링 방식뿐만 아니라, `curriedSum(1, 2)(3, 4)`처럼 여러 인자를 한 번에 전달하는 방식도 지원한다는 점입니다. 이는 실용적인 측면에서 매우 유연한 사용성을 제공합니다.
실전 예제: 데이터 유효성 검사 파이프라인 구축
범용 `curry` 함수를 활용하여 실용적인 예제를 만들어 보겠습니다. 사용자 입력 폼의 데이터를 검증하는 로직을 함수 조합과 커링을 이용해 선언적으로 구축해 봅시다.
// 범용 curry 함수 (위에서 정의)
const curry = (fn) => { /* ... */ };
// --- 유효성 검사 헬퍼 함수들 (커링 적용) ---
// 1. 최소 길이 검사
const minLength = curry((len, fieldName, value) => {
if (value.length < len) {
return `${fieldName}은(는) 최소 ${len}자 이상이어야 합니다.`;
}
return null; // 유효하면 null 반환
});
// 2. 이메일 형식 검사
const isValidEmail = curry((fieldName, value) => {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(value)) {
return `${fieldName}이(가) 유효한 이메일 형식이 아닙니다.`;
}
return null;
});
// 3. 여러 유효성 검사를 실행하는 함수
const runValidators = (value, validators) => {
return validators
.map(validator => validator(value)) // 각 validator 실행
.filter(error => error !== null); // 에러 메시지만 필터링
};
// --- 유효성 검사기(Validator) 생성 ---
// 부분 적용을 통해 특정 필드에 대한 validator를 만듭니다.
const validateUsername = minLength(4, '사용자 이름');
const validatePassword = minLength(8, '비밀번호');
const validateEmail = isValidEmail('이메일');
// --- 폼 데이터 및 검증 실행 ---
const formData = {
username: 'tim',
email: 'tim@test',
password: '123'
};
const usernameErrors = runValidators(formData.username, [validateUsername]);
const emailErrors = runValidators(formData.email, [validateEmail]);
const passwordErrors = runValidators(formData.password, [validatePassword]);
console.log('Username Errors:', usernameErrors); // ["사용자 이름은(는) 최소 4자 이상이어야 합니다."]
console.log('Email Errors:', emailErrors); // ["이메일이(가) 유효한 이메일 형식이 아닙니다."]
console.log('Password Errors:', passwordErrors); // ["비밀번호는(는) 최소 8자 이상이어야 합니다."]
이 예제에서 커링은 `minLength(4, '사용자 이름')`와 같이 유효성 검사 규칙의 '설정' 부분을 미리 적용하여 `validateUsername`이라는 재사용 가능한 함수(검사기)를 만드는 데 핵심적인 역할을 합니다. 각 검사기는 설정(최소 길이, 필드명 등)을 기억하고 있으며, 나중에 실제 검증할 값(`value`)만 인자로 받아 동작합니다. 이처럼 커링을 활용하면 복잡한 비즈니스 규칙을 작고 독립적인 함수의 조합으로 명료하게 표현할 수 있습니다.
커링의 생태계: 다양한 언어와 라이브러리
커링은 특정 언어에 국한된 기술이 아닌, 함수형 프로그래밍 패러다임을 지원하는 여러 언어에서 찾아볼 수 있는 보편적인 개념입니다.
- Haskell: 순수 함수형 프로그래밍 언어인 하스켈에서는 모든 함수가 기본적으로 커리된 형태입니다. `add x y = x + y`와 같이 함수를 정의하면, 이는 사실 `x`를 받아 `y`를 받아 `x + y`를 반환하는 함수를 반환하는 함수로 해석됩니다. 하스켈에서 다중 인자 함수라는 개념은 존재하지 않으며, 오직 커리된 함수만이 있을 뿐입니다.
- Scala: 스칼라는 객체지향과 함수형 프로그래밍을 모두 지원하며, 커링을 위한 특별한 문법을 제공합니다. `def add(x: Int)(y: Int): Int = x + y`와 같이 인자 목록을 여러 개로 나누어 정의하면 해당 함수는 커리된 함수가 됩니다.
- F#, OCaml: ML 계열의 함수형 언어들 역시 하스켈과 마찬가지로 함수 커링을 언어의 핵심 기능으로 지원합니다.
- JavaScript: 자바스크립트는 멀티 패러다임 언어로, 일급 함수(First-class function)와 클로저를 지원하기 때문에 커링을 강력하게 구현하고 활용할 수 있습니다. 특히, 함수형 프로그래밍을 위한 라이브러리들은 커링을 핵심적인 디자인 원칙으로 삼고 있습니다.
- Ramda.js: Ramda는 함수형 프로그래밍을 위해 설계된 자바스크립트 라이브러리로, 제공하는 모든 함수가 자동으로 커리됩니다. 또한, 항상 '데이터를 마지막 인자(data-last)'로 받도록 설계되어 있어 함수 조합과 포인트-프리 스타일에 최적화되어 있습니다.
- Lodash/fp: 유명한 유틸리티 라이브러리인 Lodash의 함수형 버전입니다. Ramda와 유사하게 모든 함수가 커리되어 있고, 인자 순서가 함수 조합에 용이하도록 재배열되어 있습니다.
// Ramda.js 사용 예제
import * as R from 'ramda';
const numbers = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
// Ramda의 함수들은 모두 커리되어 있다.
const filter = R.filter;
const map = R.map;
const reduce = R.reduce;
const isEven = n => n % 2 === 0;
const double = n => n * 2;
const sum = (a, b) => a + b;
// R.pipe는 compose와 유사하지만, 함수 실행 순서가 왼쪽에서 오른쪽
const calculateWithRamda = R.pipe(
filter(isEven),
map(double),
reduce(sum, 0)
);
const result = calculateWithRamda(numbers);
console.log(result); // 120
Ramda와 같은 라이브러리를 사용하면, 우리가 직접 `curry` 함수를 만들거나 함수 조합 유틸리티를 작성할 필요 없이, 이미 검증되고 최적화된 도구들을 활용하여 함수형 프로그래밍의 이점을 즉시 누릴 수 있습니다.
결론: 코드의 표현력을 한 단계 높이는 기술
커링은 처음 접했을 때 다소 추상적이고 불필요하게 느껴질 수 있습니다. 하지만 그 본질을 이해하고 나면, 함수를 단일 목적을 가진 독립적인 부품으로 바라보고, 이 부품들을 유연하게 조립하여 더 큰 시스템을 구축하는 새로운 관점을 열어줍니다.
커링을 통해 우리는 다음과 같은 가치를 얻을 수 있습니다.
- 설정과 실행의 분리: 함수의 일부 인자를 미리 고정(설정)하여 새로운 함수를 만들고, 나중에 나머지 인자를 전달하여 실행하는 패턴을 통해 코드의 유연성을 높입니다.
- 높은 재사용성: 하나의 범용 함수로부터 수많은 특화된 함수를 파생시켜 코드 중복을 줄이고 유지보수성을 향상시킵니다.
- 선언적 프로그래밍: 함수 조합을 통해 '어떻게'가 아닌 '무엇을' 할 것인지에 집중하는 선언적인 코드를 작성하게 되어, 비즈니스 로직의 가독성과 명확성이 증가합니다.
함수형 프로그래밍의 여정에서 커링은 필수적인 이정표와 같습니다. 오늘 당장 여러분의 코드에 있는 다중 인자 함수를 커리된 형태로 바꾸어 보세요. 작은 로거(Logger) 함수, 유틸리티 함수부터 시작하여 점진적으로 적용하다 보면, 여러분의 코드가 얼마나 더 모듈화되고, 재사용 가능하며, 표현력이 풍부해지는지 직접 경험하게 될 것입니다. 커링은 단순한 기술을 넘어, 더 나은 소프트웨어를 만들기 위한 강력한 사고의 도구입니다.
0 개의 댓글:
Post a Comment