자바스크립트로 하는 함수형 프로그래밍에 대해서 글을 써볼까 합니다. 우연한 기회로 함수형 프로그래밍에 대한 관심을 갖게 됐고, 프론트엔드 개발을 하면서 적용했던 함수형 프로그래밍에 대해서 다뤄볼 예정입니다.
두 번째 글입니다. 함수형 프로그래밍에서는 함수의 조합으로 원하는 값을 만들어 냅니다. 함수의 조합인 함수 컴포지션에 대해서 살펴보도록 하겠습니다. 그리고 커링 기법을 이용해 함수 컴포지션의 멋진 형태의 코드를 만드는 방법에 대해서도 알아보도록 하겠습니다.
함수 컴포지션은 한마디로 함수를 조합한다고 생각하면 됩니다. 함수 컴포지션이 어떻게 생긴건지 살펴보도록 하죠.
아래 세 가지 함수를 정의했습니다.
// 제곱 계산을 합니다.
const pow = (num1, num2) => {
return Math.pow(num1, num2);
}
// 숫자를 음수로 만듭니다.
const negate = (num) => {
return num * -1;
}
// 숫자에 더하기 1을 합니다.
const inc = (num) => {
return num + 1;
}
이 세 함수를 가지고 절차지향 스타일로 의미 없는 값을 만들어 보겠습니다.
const powered = pow(2, 3);
const neagted = negate(powered);
const result = inc(negated);
console.log(result); // -15
위 코드에서 할당한 변수들은 단 한번만 사용되기 때문에 굳이 변수로 할당할 필요가 없을것 같습니다. 변수를 제거한 구현은 아래처럼 구현할 수 있습니다.
inc(negate(pow(2, 3))); // -15
어떤가요? 함수의 결과 값을 변수 할당 없이 다음 함수의 입력으로 바로 실행하도록 했습니다. 이런 코드를 함수 컴포지션이라고 합니다. 함수의 조합이니까요. 그런데 어째 절차지형 스타일의 코드보다 읽기가 더 어려운 느낌이 드는것 같습니다.
pow(2, 3)를 시작으로 negate 그리고 inc 순으로 가장 안쪽부터 계산해서 바깥쪽으로 코드를 읽어 나가야 하기 때문입니다. 지금은 함수 조합을 세 개만 했지만 더 많아진다면 더욱 읽기 힘든 코드가 되겠죠. 이런 코드 처럼요... inc(negate(inc(negate(inc(negate(pow(2, 3)))))));
수학 공부좀 하신 분들은 위 코드를 보고 뭔가 떠오르시는게 있을수도 있겠네요.
- 함수 합성
(h ・ g ・ f)(x) = h( g( f( x ) ) )
이 읽기 힘든 코드를 보기 좋게 도와줄 멋진 함수를 하나 만들어 보도록 하겠습니다. 이름하여 compose
입니다. compose 함수 구현이 조금 복잡해 보입니다. 하지만 겁내지 마세요. 원리 이해를 위해서 썼을 뿐 직접 이런 코드를 구현할 일은 많지 않을거에요.
const compose = (...fns) => { // ..., fn3, fn2, fn1
return (...args) => {
return fns.reduceRight(
(res, fn) => [fn.call(null, ...res)], // 입력 받은 fns 를 오른쪽부터 실행
args // 초기값으로 받은 파라미터
)[0];
}
};
간단하게 compose 함수를 설명하면 다음과 같습니다.
1. 실행할 함수 목록을 넘겨주면 새로운 함수를 반환합니다.
2. 새로 반환 받은 함수에 초깃값을 파라미터로 넘겨줍니다.
3. 초깃값을 가지고 실행할 함수 목록을 역순으로 순차적으로 실행해서 최종 결과를 반환합니다.
실제 어떻게 사용하는지 inc(negate(pow(2, 3)));
를 compose 함수를 이용해서 구현해 보도록 하겠습니다.
const mySpecialFunc = compose(
(num) => inc(num),
(num) => negate(num),
(num1, num2) => pow(num1, num2)
);
mySpecialFunc(2, 4);
어떤가요? 조금 더 보기 좋아졌나요? 코드 실행 순서는 위에서 설명한 것처럼 pow -> negate -> inc 순으로 실행해서 결과를 반환합니다.
저는 이 compose 함수를 처음 봤을때 정말 이해하기가 힘들었습니다. 혹시 저같은 분들이 계실지 모르기 때문에 한가지 예를 들면 자동자 공장을 생각하시면 좀 더 이해하기 쉬울 것 같습니다.
왼쪽에서 오른쪽으로 흘러가는 자동차 자체를 데이터로 그리고 각 단계별 로봇 손은 함수로 대입해서 생각하면 될것 같습니다.
사실 저는 compose 형태를 처음 보고 살짝 거슬리는 것이 있었습니다. 여러분도 같은 생각이 드실지 모르겠네요. 굳이 저렇게 (num) => inc(num)
형태로 써줘야 하는걸까 였습니다.
const mySpecialFunc = compose(
(num) => inc(num),
(num) => negate(num),
(num1, num2) => pow(num1, num2)
);
inc 함수는 num이라는 숫자 하나를 받는 함수입니다. 익명 함수 (num) => inc(num)도 num이라는 숫자를 받는 함수입니다. 이 익명 함수에서는 num을 가지고 그대로 inc를 실행합니다. 결국 익명함수는 inc와 같다고 생각할 수 있습니다.
그럼 위 생각대로 코드를 다시 바꿔볼까요?
const mySpecialFunc = compose(inc, negate, pow);
mySpecialFunc(2, 4); // -15
실행 결과는 이전과 같습니다. 달라진건 불필요하게 추가했던 익명 함수를 제거 하고 원래 실행하고자 했던 함수를 그대로 넘겨준 점입니다. 여기에서 Pointfree style이 등장하게 됩니다.
함수를 사용할 때 파라미터를 이용해 호출하지 않고 함수 자체를 이용하는 방식을 Poinfree style이라고 합니다. 특별히 이 용어를 알아야 하는건 아니지만 그냥 이런 용어가 있다 하고 넘어가면 될 것 같습니다.
이렇게 Pointfree style로 코드를 작성하면 코드가 보다 간결해지고 가독성이 높아진다는 점입니다.
const mySpecialFunc1 = compose(
(num) => inc(num),
(num) => negate(num),
(num1, num2) => pow(num1, num2)
);
const mySpecialFunc2 = compose(inc, negate, pow);
그런데 아무래도 조금 불편한점이 남아 있습니다. 바로 오른쪽에서 왼쪽으로 함수를 실행하기 때문에 거꾸로 읽어나가야 한다는 느낌이 있습니다. 물론 저와 다르게 오른쪽에서 왼쪽으로 읽는게 더 편하신 분들이 계실수도 있습니다. 하지만 저와 취향이 같으신 분들을 위해 한 단계 더 나아가 보도록 하겠습니다.
한 단계 더 나아가 보자고 거창하게 말을 했지만 이번에 살펴보려고 하는 것은 오른쪽 -> 왼쪽으로 흐르는 코드를 왼쪽 -> 오른쪽으로 흐르도록 만드는 과정입니다. 이걸 도와주기 위한 함수를 만들도록 하겠습니다. 이 함수의 이름은 pipe
입니다.
const pipe = (...fns) => {
return (...args) => {
return fns.reverse().reduceRight( // 입력 받은 fns의 순서를 뒤집는다
(res, fn) => [fn.call(null, ...res)], // 순서가 뒤집어진 fns 를 오른쪽부터 실행
args // 초기값으로 받은 파라미터
)[0];
}
};
compose
함수와 다른 점은 fns.reduceRight
를 fns.reverse().reduceRight
로 바꾼 부분밖에 없습니다. 이렇게 하면 compose
함수와는 반대로 왼쪽에서 오른쪽으로 함수를 실행할 수 있게 됩니다.
pipe
를 이용해서 구현해볼까요?
const mySpecialFunc = pipe(pow, negate, inc)
mySpecialFunc(2, 4); // -15
짜잔! 이젠 코드를 읽을 때 왼쪽에서 오른쪽으로 읽을 수 있게 됐습니다. 취향에 따라 compose
와 pipe
를 선택하면 될것 같습니다.
const composeFunc = compose(inc, negate, pow);
const pipeFunc = pipe(pow, negate, inc);
위 제목의 부제가 커링에 대한 한마디 설명인것 같습니다. 예제로 살펴보도록 하죠. person 객체에 아래 조건을 만족한는 결과를 만들고 싶다고 가정합니다.
const person = {
name: 'nakta',
age: 10,
work: 'developer'
};
키를 삭제할 dissoc
함수를 만들고 키 이름을 변경할 rename
함수를 만들어 보겠습니다. 구현부는 그리 중요하지 않습니다.
const dissoc = (dissocKey, obj) => {
return Object.keys(obj).reduce(
(acc, key) => {
if (key === dissocKey) return acc;
acc[key] = obj[key];
return acc;
},
{}
)
}
const rename = (keysMap, obj) => {
return Object.keys(obj).reduce(
(acc, key) => {
acc[keysMap[key] || key] = obj[key];
return acc;
},
{}
);
};
실제 두 조건을 만족하는 결과를 구현해 봅시다.
pipe(
person => dissoc('age', person),
person => rename({work: 'job'}, person)
)(person); // { name: 'nakta', job: 'developer' }
pipe를 이용해서 먼저 age 값을 지워 주고 work 라는 키 이름을 job으로 바꿨습니다. 그런데 dissoc
와 rename
가 파라미터를 두 개를 받는 함수이기 때문에 익명 함수를 써야만 함수를 호출 할 수 있습니다. 그 영향으로 코드가 살짝 지저분해 보이기까지 합니다.
이 상황을 구원해줄 기법이 바로 커링
입니다. 제목을 다시 살펴볼까요?
파라미터를 모두 채우지 않는 한 함수로 남아있겠다.
무슨 뜻인고 하니. dissoc
함수는 두 개의 파라미터를 받아서 실행하는 함수입니다. 커링을 이용한다면 dissoc('age')
처럼 파라미터를 하나만 넘겨서 실행할 수 있습니다. 이 때 반환 값은 두 번째 파라미터를 받는 새로운 함수입니다. 즉, 파라미터를 부족하게 채울 경우 그 나머지 파라미터를 받을 수 있는 함수를 반환한다는 뜻입니다.
const dissocAge = dissoc('age'); // (obj) => { obj 에서 'age' 키를 지워서 반환해 }
dissocAge(person); // { name: 'nakta', work: 'developer' }
그럼 dissoc
과 rename
함수에 직접 커링을 적용해 볼까요?
const dissoc = (dissocKey) => (obj) => {
return Object.keys(obj).reduce(
(acc, key) => {
if (key === dissocKey) return acc;
acc[key] = obj[key];
return acc;
},
{}
)
};
const rename = (keysMap) => (obj) => {
return Object.keys(obj).reduce(
(acc, key) => {
acc[keysMap[key] || key] = obj[key];
return acc;
},
{}
);
};
짜잔! 이렇게 커링을 적용하면 이를 이용해서 좀더 간단하고 깔끔한 코드를 작성할 수 있습니다.
pipe(
dissoc('age'),
rename({ work: 'job' })
)(person); // { name: 'nakta', job: 'developer' }
위와 같은 상황처럼 커링이 필요할때 매번 저렇게 파라미터를 분리해서 함수를 반환하는 코드를 작성한다고 생각하지 도저히 엄두가 나지 않습니다. 커링을 편하게 도와줄 curry
함수를 만들어 봅시다.
const curry = (fn) => {
const arity = fn.length;
return function _curry(...args) {
if (args.length < arity) {
return _curry.bind(null, ...args);
}
return fn.call(null, ...args);
};
}
curry
함수를 이용해서 커링을 하고 싶은 함수를 파라미터로 넘기면 커링된 함수를 다시 반환해 줍니다.
const dissoc = curry((dissocKey, obj) => {
return Object.keys(obj).reduce(
(acc, key) => {
if (key === dissocKey) return acc;
acc[key] = obj[key];
return acc;
},
{}
)
});
const rename = curry((keysMap, obj) => {
return Object.keys(obj).reduce(
(acc, key) => {
acc[keysMap[key] || key] = obj[key];
return acc;
},
{}
);
});
이젠 curry
함수를 이용해서 얼마든지 커링된 함수를 손쉽게 만들수 있게 됐습니다. 이젠 더욱 편하게 함수 컴포지션을 사용할 수 있게 됐죠!
Before
pipe(
person => dissoc('age', person),
person => rename({work: 'job'}, person)
)(person); // { name: 'nakta', job: 'developer' }
After
pipe(
dissoc('age'),
rename({ work: 'job' })
)(person); // { name: 'nakta', job: 'developer' }
코드가 깔끔하니 더욱 가독성이 높아졌습니다.
여러 함수를 조합하는 함수 컴포지션의 가독성을 높이는 방법을 살펴봤습니다.
1. 그냥 쭉 나열
- a( b( c( data ) ) )
- 코드의 가장 안쪽부터 읽어야 함.
2. compose 함수 활용
- compose(a, b, c)
- c -> a 방향으로 실행
3. pipe 함수 활용
- pipe(a, b, c)
- a -> c 방향으로 실행
4. Pointfree style로 코드 쓰기
- (num) => inc(num) 형태를 단순하게 inc 형태로 변경
- 가독성이 좋아짐
그리고, 파라미터가 두 개 이상인 함수에 커링을 적용해서 함수 컴포지션 시 가독성을 높이는 방법에 대해서도 살펴봤습니다.
pipe(
dissoc('age'),
rename({ work: 'job' })
)(person); // { name: 'nakta', job: 'developer' }
첫 번째와 두 번째 글에서 나왔던 내용을 기반으로 함수형 프로그래밍의 특징에 대해서 정리해보도록 하겠습니다.
부족한 설명 끝까지 읽어주셔서 감사합니다.
pipe 구현자에서 reverse().reduceRight() 하는것 보다는 reduce()를 쓰면 똑같이 동작하지 않을까요