abonglog logoabonglog

커링 (currying) 에 대해 알아보자   의 썸네일

커링 (currying) 에 대해 알아보자

함수형 자바스크립트

프로필 이미지
yonghyeun

커링이란 ?

종종 유튜브에서 자바스크립트 면접 관련 영상들을 보면 이런 코드들을 볼 수 있다.

커링의 대표적인 예시
const sum = (a: number) => (b: number) => (c: number) => a + b + c;
console.log(sum(1)(2)(3)); // 6

함수형 프로그래밍에 대해 별 생각 없이 볼 땐 뭐 이리 직관적이지 못한 코드가 있을까, 이런걸 왜 물어보는걸까 라는 생각을 자주 했다.

함수형 패러다임에서 커링은 중요하게 적용하는 하나의 패러다임이다.

커링이란 다중 인수를 갖는 함수를 단일 인수로 갖는 함수들의 함수열로 바꾸는 것 을 말한다.

위 함수에서 const sum = (a,b,c)=> a+b+c 형태로 다중 인수를 갖는 함수가 있을 때, 해당 함수를 const sum = a => b => c=> a+b+c 형태로 단일 인수를 갖는 함수들의 시퀀스로 변경한 것이 이에 해당한다.

부분 적용

커링은 함수의 부분 적용이란 넓은 개념의 특정한 형태이다. 부분적용은 N 개의 인수를 받는 함수를 N-M (N > M) 개의 인수를 받는 함수로 변환하는 기법을 말한다.

부분 적용을 적용하여 함수를 생성하게 되면 함수의 일부 인수를 고정한 채로 동작하는 함수를 생성하여 함수의 재사용과 조합성을 높혀줄 수 있다.

부분 적용을 통한 재사용성이 높은 함수 생성
// 함수를 반환하는 고차함수
const addWith = (a: number) => (b: number) => a + b; 
 
const addWithFive = addWith(5);
console.log(addWithFive); // [Function]
console.log(addWithFive(3)); // 8

부분 적용을 적용하기 위한 특정한 기법이 커링으로, 한 번에 하나의 인수만 받는 함수들의 시퀀스로 강제하는 엄격한 커링, 한 개 이상의 인수를 받을 수 있는 유연한 커링으로 구현 할 수 있다.

커링이 동작하는 방식

커링이 동작하는 방식은 클로저와 연관이 깊다.

MDN 에서 정의하는 클로저는 주변 상태(어휘적 환경)에 대한 참조와 함께 묶인(포함된) 함수의 조합이라 한다.

함수는 특정 스코프에서 생성되는데, 생성된 스코프 외부의 변수를 스코프 체인에 따라 참조 가능하다.

이 때 함수가 호출 되는 시점에 함수가 선언된 환경이 제거 되었더라도, 선언되었던 환경 내 변수를 기억하여 사용하는 함수를 클로저 라고한다.

이는 가비지 컬렉터가 참조되고 있는 변수는 메모리에서 제거하지 않기 때문에 가능하다.

위 예시에서 addWithFive 메소드는 생성 될 때 인수로 addWith(5) 로 인해 생성 되는 시점 5라는 값을 참조하고 있다가

추후 addWithFive(1) 이 호출 될 때 참조해뒀던 값을 이용해 6 이란 결과값을 반환한다.

sum(1)(2)(3) 이란 함수도 sum(1) 시점에 특정 함수가 생성되고, sum(1)(2) 시점에 특정 함수가 생성되고 sum(1)(2)(3) 이 평가되는 시점에 생성될 때 받은 인수로 6 이란 값을 반환하게 되는 것이다.

커링이 왜 사용되는데?

함수형 프로그래밍에선 최대한 모든 표현식들을 함수의 나열과 조합으로 표현하고자 한다.

이 과정속에선 비즈니스 로직을 담고 있는 고차 함수들을 이용해 함수를 재사용하곤 하는데 이 과정속에서 커링은 필연적으로 함께 사용된다.

시나리오를 통해 알아보자

📖 접두사를 붙혀 메시지를 로깅해야 하는 경우

접두사를 붙혀 메시지 로깅하는 경우
const logging = (prefix: string, message: string) =>
  console.log(`🔥 ${prefix} ${message}`);
 
logging("INFO", "login!"); // 🔥 INFO login!
logging("INFO", "logout!"); // 🔥 INFO logout!
logging("Error", "Unexpected error!"); // 🔥 Error Unexpected error!
logging("Error", "User not found!"); // 🔥 Error User not found!

이런 메소드가 있을 때 매우 자주 서로 다른 곳에서 prefix 가 고정된 채로 자주 사용되는 경우를 생각해보자

이 때, 매번 인수를 건내주는게 귀찮아 prefix 가 고정된 새로운 메소드를 만든다고 생각해보자

커링을 이용한 함수 생성
const logging = (prefix: string, message: string) =>
  console.log(`🔥 ${prefix} ${message}`);
 
const loggingWithError = (message: string) => logging("ERROR:", message);
loggingWithError("Something went wrong"); // 🔥 ERROR: Something went wrong

커링을 통해 고정하고자 하는 인자(고정인자)인 prefixloggingWithError 메소드 선언 당시에 호출하고 변경 될 예정인 인자(가변인자)인 messageloggingWithError 메소드 호출 당시에 넣어주는편으로 변경 가능하다. (이러한 과정을 함수의 부분적용이라 한다.)

이렇게 필요한 인자가 N개 있을 때 N-M 개의 인자만을 호출해 생성된 함수를 partial 이라고 하기도 한다.

이렇게 partial 함수를 이용해 여러 부분에서 재사용 하는것을 가능하게 했다.

커링은 함수를 조합 할 때 사용하면 효율적이다

커링 자체만으로는 크게 의미 있다고 느껴지진 않는다. 어떤 변수 result 의 값을 구하기 위해 sum(1,2,3) 으로 쓰나 sum(1)(2)(3) 으로 쓰나 결국 결과값은 똑같기 때문이다.

다만 함수를 조합하여 사용하는 경우 커링은 되게 효율적이다.

어떤 복잡한 값 X 를 구하기 위한 값이 f(g(h( ...))) 이런식으로 많은 함수를거쳐야 할 때 순수 함수로 이뤄진 함수의 조합으로 h->g->f 와 같은 파이프라인 형태로 표현 할 수 있다.

간단히 연산들로 이뤄진 다음과 같은 함수를 살펴보자

합성 함수 형태로 구성된 사칙연산
// 더하기 , 곱하기 , 빼기, 나누기 함수
 
const add = (operand: number, base: number) => operand + base;
const subtract = (operand: number, base: number) => operand - base;
const multiply = (operand: number, base: number) => operand * base;
const divide = (operand: number, base: number) => operand / base;
 
const base = 7;
const result = multiply(10, subtract(3, divide(3, add(8, base))));
console.log(result); // 28

지금처럼 multiply(subtract(...)) 형태처럼 표현된 표현식은 정상적으로 동작하지만 가독성은 상당히 나쁘다.

이 때 모든 함수들이 순수 함수로 구성되어 있다면, 파이프라인 형태로 표현하는 것이 가능하다.

reduce를 이용한 파이프라인
type Func = (...args: any[]) => any;
const pipe = (initialValue: any, ...functions: Func[]) =>
  functions.reduce((acc, func) => func(acc), initialValue);
 
const result = pipe(
  base,
  (base) => add(8, base),
  (base) => divide(3, base),
  (base) => subtract(3, base),
  (base) => multiply(10, base)
);
 
console.log(result); // 28

으악 아직 타입 안정성을 지키면서 메소드를 어떻게 구현해야 할지 감이 안와서 any 타입으로 만들어둔다.

이 과정 속에서 불필요하게 반복된다고 느껴지는 것들은 (base)=> operation(operand , base) 일 것이다.

저런 반복되는 일들이 일어났던 이유는 각 operation 들의 가변인자인base 들이 이전에서 선언된 메소드가 호출되어아먄 평가될 수 있기 때문이다.

매번 똑같은 코드를 반복하기보다 호출되는 시점에 완전히 평가 될 수 있도록 각 operation 들을 커링을 통해 변환해주도록 해보자

메소드들을 커링을 통해 변환해주자
// 더하기 , 곱하기 , 빼기, 나누기 함수
 
const add = (operand: number) => (base: number) => operand + base;
const subtract = (operand: number) => (base: number) => operand - base;
const multiply = (operand: number) => (base: number) => operand * base;
const divide = (operand: number) => (base: number) => operand / base;
 
const base = 7;
 
type Func = (...args: any[]) => any;
const pipe = (initialValue: number, ...functions: Func[]) =>
 functions.reduce((acc, func) => func(acc), initialValue);
 
const result = pipe(base, add(8), divide(3), subtract(3), multiply(10));
console.log(result); // 28

커링을 통해 pipe 가 선언되는 시점에 불변인자들 (operand) 을 모두 채워둔 후 가변인자는 pipe 의 흐름에 따라 partial 들이 호출되며 채워지도록 수정해줄 수 있다.

이 예시를 만들며 스스로 정의했던 커링의 장점은 이처럼 정의 할 수 있을 거 같다.

커링은 함수의 불변인자와 가변인자를 나눠 함수를 생성 할 수 있게 해주고, 원하는 시점에 가변인자를 넣어 완전히 호출함으로서 함수의 실행 주기를 제어 할 수 있게 도와준다.

커링 전 후 코드 가독성 비교
// 파이프라인 전
const result = multiply(10, subtract(3, divide(3, add(8, base))));
// 파이프라인이지만 커링 적용 전 
const result = pipe(
  base,
  (base) => add(8, base),
  (base) => divide(3, base),
  (base) => subtract(3, base),
  (base) => multiply(10, base)
);
// 파이프라인과 커링 적용 후 
const result = pipe(base, add(8), divide(3), subtract(3), multiply(10));