함수로 함수 만들기2 커링

이전글: (함수형JS) 함수로 함수 만들기1 다형성

curry-banner
curry-banner

왜 커링을 사용하는가?

이 글에서는 함수로 함수 만들기 두 번째 시간으로 커링(currying) 에 대해 알아보겠다. 커링이란 다중인자를 받는 함수를 단일 인자 함수열로 만드는 것을 말한다( 커링 - 위키백과, 우리 모두의 백과사전).

커링의 정의에 대해서는 잠시 잊어 버려도 좋다. 먼저 문자열을 숫자로 바꾸는 parseInt 사용법에 대해 눈을 돌려보자. 아래 코드의 결과를 예측할 수 있겠는가?

;["11", "11", "11", "11"].map(parseInt)

정수로 이뤄진 배열 [11, 11, 11, 11]을 기대하는 코드라고 미루어 짐작할 수 있다. 그러나 결과는 그렇지 않다. [11, NaN, 3, 4] 라는 값이 나온다. 왜 그럴까?

원인을 이해하려면 parseInt 함수의 인터페이스부터 살펴봐야 한다. parseInt는 parseInt(string, radix) 형태로 사용된다. 변환한 정수 문자열 string과 진수를 나타내는 radix 변수를 인자로 받는데 2와 36 범위로 한정한다. ([parseInt()(https://developer.mozilla.org/ko/docs/Web/JavaScript/Reference/Global_Objects/parseInt))

한편 맵 함수는 전달인자로 콜백 함수를 넣을 수 있다. 콜백 함수는 function callback(currentValue, index, array) 형태로 사용한다. 배열 요소 중 현재 처리되고 있는 값을 currentValue로 받고 인덱스와 해당 배열이 뒤따라 온다. (Array.prototype.map())

위 코드에서는 맵함수의 콜백함수로 parseInt를 넘겨 주었다. 따라서 parseInt의 파라매터는 string에 currentValue가 오고 radix에 index가 온다. parseInt의 두번째 파라매터인 진수 값을 배열 요소의 인덱스 값 0, 1, 2, 3을 전달했기 때문에 함수는 아래 순서대로 호출될 것이다.

parseInt("11", 0) // 11,
parseInt("11", 1) // NaN
parseInt("11", 2) // 3
parseInt("11", 3) // 4

parseInt의 두번재 파라매터에 진수 값을 잘못 전달한 탓이다. 원래 의도는 parseInt에 문자열만 전달하고 두번째 파라매터는 생략해서 10진법 결과를 얻으려는 의도였을 것이다(parseInt의 radix 파라매터는 기본값이 10이다). 그렇다면 이 문제는 parseInt 함수에 첫번째 문자열 파라매터만 넘겨주도록 하는 방법으로로 해결할 수 있을 것이다.

여기서 커링의 필요성이 등장한다. 서두에 언급한 커링의 정의를 떠올려 보자. 다중인자를 받는 함수를 단일인자만 받는 함수열로 변경하는 것이 커링이다. 2개 인자를 받는 parseInt 함수를 1개 인자만 받도록 고정하는 것이 커링인 셈이다.

curry()

parseInt를 커링하기 위한 커리 함수를 만들겠다. 함수 fun을 인자로 받고, arg를 인자로 받는 함수를 반환한다. 반환된 함수를 실행하면 클로져로 캡쳐된 fun에 인자로 받은 arg를 전달하여 실행하고 그 결과를 반환한다.

const curry = fun => arg => fun(arg)

curry(parseInt)("11") // 11
curry(parseInt)("11", 2) // 11

curry(parseInt)는 두 개의 인자를 받는 parseInt 함수를 하나의 인자만 받도록 강제하는 함수를 반환한다. curry(parseInt)('11',2) 처럼 두 개 인자를 넘겨주더라도 첫번재 파마매터인 문장려 '11'만 parseInt에 전달된다. 나머지 파라매터인 정수 2는 무시된다.

;["11", "11", "11", "11"].map(curry(parseInt)) // [11, 11, 11, 11]

전달인자 1개로 고정시킨, 즉 커링된 함수를 맵함수의 콜백함수로써 전달하면 우리가 원래 의도한 결과를 얻을 수 있다. 이 함수는 map 함수가 전달한 배열의 인덱스 값인 두번째 파라매터는 무시하고 첫번째 파라매터인 배열 요소 값만 사용해서 parseInt를 호출하기 때문이다.

curry2()

문자열을 10진수로 만들기 위해 parseInt가 하나의 파라매터만 전달 받도록 커링을 사용했다. parseInt의 radix 파라매터는 없을 경우 기본값 10을 사용한다. 이진수 값을 얻으려면 parseInt의 radix에 2를 넘겨 줘야한다. 어떻게 하면 커링으로 이 문제를 풀수 있을까?

curry() 함수가 한 개의 파라매터를 고정한 것은 반환한 함수가 한 개의 파라매터만 사용했기 때문이다. 반환된 이 함수가 한 개의 파라매터만 받는 함수를 다시 한번 반환한다면...... 상상이 되는가? 마지막 반환된 함수를 실행하면 이미 전달한 두 개의 파라매터가 클로져로 캡쳐된 상태이고 실제 실행할 함수에 고정값으로 전달할 수있다. 고정된 값이 각각 parseInt의 string과 radix에 들어갈 녀석이라고 하면 2진수를 정수를 반환하는 함수를 만들수 있을 것이다.

두 개의 인자를 고정하는 curry2는 curry와 비슷한 구조로 만들 수 있다. 함수를 반환하는 단계가 더 추가되었다.

const curry2 = fun => arg2 => arg1 => fun(arg1, arg2)
const parseBinaryString = curry2(parseInt)(2)

curry2(parseInt)(2)를 한 단계씩 추적해보자.

  • curry2(parseInt)는 arg2 => arg1 => parseInt(arg1, arg2) 함수를 반환한다.
  • 반환된 함수는 인자를 한 개만 받는데 그것이 arg2이고 parseInt의 radix로 들어갈 녀석이다.
  • 숫자 2를 전달해 실행하면 arg1 => parseInt(arg1, 2) 함수를 반환한다.
  • 반환된 함수는 인자를 한 개만 받는데 그것이 arg1이고 parseInt의 string으로 들어갈 놈이다.
  • '11' 값을 전달해서 실행하면 parseInt('11', 2)의 실행결과를 반환한다.

parseInt는 파라매터 두 개를 한 번에 전달받아 실행하는 함수였다. curry2를 이용해 parseInt를 하나의 인자만 받도록 강제했고 최종적으로 parseInt를 실행하기 위해서는 세 번의 함수 호출이 필요하다. 각 함수는 파라매터를 1개만 받는데 각 각 parseInt, 2, '11'이 전달된다. 즉 2개 인자를 받는 함수를 1개 인자를 받는 함수열로 변경한 모습이다. 앞서 언급한 커링의 정의를 구체적으로 보여주는 예제다.

커링된 함수 parseBinaryString을 맵함수의 콜백함수로서 전달하면 의도한 결과를 얻을 수 있다.

;["11", "11", "11", "11"].map(parseBinaryString) // [3, 3, 3, 3]

코드가 간결하고 가독성도 좋다. 이것을 커링없이 코딩한다면 다음과 같을 것이다.

['11','11','11','11].map(str => parseInt(str, 2)); // [3, 3, 3, 3]

어떤 코드가 좋은지는 개인의 취향이다.

커링을 이용한 플루언트 API

처음 질문을 상기해 보자. 왜 커링을 사용할까? 이제는 답할 수 있다. 가독성확장성이다. 지금까지 만든 코드를 정리해 보자.

  • parseDecimalString = curry(parseInt)
  • parseBinaryString = curry2(parseInt)(2)

curry, curry2 함수를 이용해 parseDecimalString, parseBinaryString 함수를 만들었다. 아직 parseInt를 실행하진 않았다. 단지 이 함수들은 parseInt를 실행할 코드를 담고 있다. 이 부분이 중요하다. parseInt를 바로 실행하지 않고 함수 실행을 몇 단계로 쪼개 놓았다. 그래서 인자를 미리 설정한 뒤 다른 역할을 하는 새로운 함수를 만들어 냈다.

함수를 이용해 함수를 만든다는 맥락에서 볼 때 커링은 넓은 기능의 함수를 특정 기능만 하도록 좁히는 능력을 가지고 있다. parseInt는 string, radix 둘 다 한번에 전달해서 문자열을 다양한 진법의 숫자로 변환한다. 이것을 커링하게 되면 파라매터로 넘긴 radix를 고정시켜서 10진수 또는 2진수 정수만 만드는 새로운 함수가 된다.

parseInt 함수는 여러가지 역할을 할수 있기 때문에 그 파라매터를 확인해야지만 정확한 기능을 읽을 수 있다. 반면 커링된 함수는 이름만으로도 어떤 일을 하는지 쉽게 파악할수 있다. 두 코드를 비교해보자.

  • ['11', '11', '11', '11'].map(parseDicimalString)

  • ['11', '11', '11', '11'].map(str => parseInt(str))

  • ['11', '11', '11', '11'].map(parseBinaryString)

  • ['11', '11', '11', '11'].map(str => parseInt(str, 2))

어떤 것이 가독성이 좋은가? 커링된 함수다. 함수를 할당한 변수 이름을 잘 지어서 그런거라고? 변수 없이 사용해 보자.

  • ['11', '11', '11', '11'].map(curry(parseInt))

  • ['11', '11', '11', '11'].map(str => parseInt(str))

  • ['11', '11', '11', '11'].map(curry2(parseInt, 2))

  • ['11', '11', '11', '11'].map(str => parseInt(str, 2))

여전히 커링을 사용한 코드의 손을 들어주고 싶다. 가독성 측면에서 다른 예를 하나 더 제시하고 글을 마무리 하겠다.

checker()

정수값이 특정 범위에 있는지 체크하는 함수 withInRange는 다음과 같이 만들 수 있다.

const withInRange = val > 30 < val && val < 50
withInRange(40) // true
withInRange(30) // false

솔직히 이런 조건문을 마주칠 때마다 몇 초간 멈칫한다. 유심히 조건문을 해석하며 의도를 파악하기 위해서다. 그럼 이런 코드는 어떨까?

const withInRange = checker(greaterThan(30), lessThan(50))
withInRange(40) // true
withInRange(30) // false

그냥 읽으면 된다. 영어권 사람들은 비개발자도 읽을수 있는 코드다.

아래 checker는 함수을 받아서 함수를 반환하는 고차함수다.

const checker = (...validators) => val =>
  validators.reduce((isValid, vali) => (vali(val) ? isValid : false), true)

인자로 받는 함수는 검증자로서 여기서는 30보다 큰지 50보다 작은지를 검사한다. 그리고 검증 대상이 되는 인자를 받는 함수를 반환한다.

인자는 val 변수로 들어오는데 이전 호출에서 갭쳐된 validators를 리듀스로 돌리면서 하나씩 체크한다. vali(val)로 값을 검증하는데 vali는 참/거짓을 반환하는 함수여야 한다. 모든 검증에 통과하면 true를 반환하고 그렇지 않으면 false를 반환하는 것이 checker의 역할이다.

두 값을 비교하는 것은 (l, r) => l > r 따위의 함수로 표현 할 수 있다. 여기서 하나의 값을 고정한다면, 예를 들어 r 값을 30으로 고정한다면, 30보다 큰 값인지 검사하는 새로운 함수를 만들수 있다. 바로 커링을 이용한다면 말이다.

const greaterThan = curry2((l, r) => l > r)
greatherThan30 = greaterThan(30)
greatherThan30(40) // true

같은 방법으로 lesstThan() 함수도 만들수 있다.

const lessThan = curry2((l, r) => l < r)

greaterThan()과 lessThan() 함수로 만든 검증자 함수를 checker의 인자로 전달할 수 있다. 왜냐면 체커는 참/거짓을 반환하는 함수를 인자로 받기 때문이다.

const withInRange = checker(greaterThan(30), lessThan(50))

withInRange(40) // true
withInRange(29) // false
withInRange(51) // false

결론

parseInt는 두 개 인자를 받고 곧장 문자열을 파싱한 정수값을 반환하는 함수다. 반면 parseInt를 커링하면 전달한 인자 하나씩만 받는 함수열을 반환한다. 이 함수에 인자를 전달하는 행위는 parseInt를 특정 기능만 하도록 행동을 구체화하는 것이다.

커링된 함수는 코드의 가독성을 높인다. 게다가 커링된 함수는 함수를 파라매터로 받는 고차함수와 결함하여 함수 조립이 가능하다.

아마도 curry2처럼 3, 4, 5, ..., n개 인자를 커링하는 함수를 생각할지도 모르겠다. 실행한다면 아마도 이런 모습일 것이다. curry10()()()()()()()()()()() 괴상하다. 커링이 인자를 하나씩 받아 고정한 반면, 여러 인자를 한번에 받아 고정시키는 것을 우리는 부분적용이라고 부를 것이다. 다음 글에서 소개하겠다.

다음글: (함수형JS) 함수로 함수 만들기3 부분적용