본문 바로가기

고차 함수와 커링

함수를 인수로 받거나 돌려주는 고차 함수를 타입과 함께 구현하고, 여러 인수를 하나씩 받는 함수의 연쇄로 바꾸는 커링과 일부 인수만 고정하는 부분 적용을 구분한 뒤, 커링된 로거에서 각 호출이 무엇을 돌려주고 언제 출력하는지 확인합니다.

자바스크립트와 타입스크립트에서 함수는 일급 객체(first-class object)입니다. 숫자나 문자열처럼 변수에 담고, 다른 함수의 인수로 넘기고, 함수의 결과로 돌려줄 수 있다는 뜻입니다. 이 성질 위에서 함수를 다루는 함수인 고차 함수(higher-order function)와, 인수를 하나씩 나누어 받는 커링(currying)이 만들어집니다.

타입스크립트에서는 함수 타입을 (item: T) => U처럼 적을 수 있어서, 인수로 받는 함수와 돌려주는 함수가 각각 무엇을 받고 무엇을 돌려주는지 검사할 수 있습니다. 이 글은 고차 함수의 두 가지 모습을 구현하고, 커링과 부분 적용(partial application)을 구분한 뒤, 커링된 로거의 각 호출이 무엇을 돌려주는지 따라갑니다.

고차 함수의 두 역할과 커링의 호출 계약을 구분한다 — 상태 추적

INFO·USER를 캡처한 함수 반환과 마지막 message 호출의 효과 발생이 다르다. 함수/캡처 값/출력을 고정 역할로 추적할 수 있다.

createLogger INFO가 tag 함수, USER 적용이 message 함수, 메시지 호출이 void 효과를 만드는 실제 3단계 호출을 보여준다.

HIGHER ORDER / CURRYING CONTRACT

커링된 logger는 단계마다 인자를 고정한다

고차 함수는 함수를 인자로 받거나 반환하며 두 역할을 함께 할 수도 있습니다. 교재 logger는 level과 tag를 앞 호출에서 고정하고, 마지막 message 호출에서 시간 조회·출력을 실행합니다.

커링된 logger는 단계마다 인자를 고정한다createLogger INFO가 tag 함수, USER 적용이 message 함수, 메시지 호출이 void 효과를 만드는 실제 3단계 호출을 보여준다.
createLogger('INFO')tag 함수를 반환
연결 관계createLogger('INFO') · tag 함수를 반환 → infoLogger('USER') · message 함수를 반환
infoLogger('USER')message 함수를 반환
연결 관계infoLogger('USER') · message 함수를 반환 → userLogger(message) · 시간 조회 · console 출력
userLogger(message)시간 조회 · console 출력

level → tag → message → void

logger의 세 단계 호출과 실행 시점createLogger INFO는 tag 함수를 반환하고 infoLogger USER는 message 함수를 반환한다. userLogger message 호출에서만 시간 조회와 console 출력이 실행되어 void가 된다. 앞 호출이 최종 로그를 실행한 것으로 해석하지 않는다.
createLogger('INFO')tag 함수를 반환
연결 관계createLogger('INFO') · tag 함수를 반환 → infoLogger('USER') · message 함수를 반환
infoLogger('USER')message 함수를 반환
연결 관계infoLogger('USER') · message 함수를 반환 → userLogger(message) · 시간 조회 · console 출력
userLogger(message)시간 조회 · console 출력

level → tag → message→ void

HIGHER ORDER

함수 입력·출력은 배타적이지 않다

applyTransformation은 callback을 받고 createFilterByMin은 함수를 반환합니다. 둘 다 고차 함수이며 하나의 함수가 두 역할을 함께 할 수도 있습니다.

CURRYING

단항 호출들의 연쇄

curriedAdd(1)(2)(3)은 각 호출마다 한 인자를 받습니다. 앞 인자를 고정해 add5, add5And10처럼 새 함수를 재사용합니다.

PARTIAL APPLICATION

부분 적용과 커링을 구분

부분 적용은 일부 인자를 고정하는 일반 개념입니다. 함수를 반환한다고 모두 커링이거나 컬렉션 연산인 것은 아닙니다.

EFFECTS

고차 함수도 효과를 낼 수 있다

logger의 앞 단계는 클로저 문맥을 만들고 마지막 단계는 new Date·console.log를 실행합니다. 고차 함수·커링이라는 분류만으로 순수성이 보장되지는 않습니다.

함수 타입에서 각 호출의 입력과 반환을 읽고, 캡처한 인자와 효과가 실행되는 시점을 확인합니다.

이 예의 조건

  • 원도표의 전체 구조와 설명은 위 관계도와 표에서 유지합니다. 아래 인쇄 표에도 원도표의 관계·조건·라벨·설명·주석을 보존합니다.
  • 두 관찰 칸의 위치는 고정입니다. 관찰 상태는 해당 순간의 값이며, 동작·이유는 그 조건에서의 판정과 변화 원인입니다. 독립 비교와 실패 분기는 별도 조건으로 읽고 모든 분기를 한 요청의 필수 실행 순서로 이어 읽지 않습니다.
관계도의 보충 설명
원도표의 전체 관계·조건·설명
구분내용
원도표 캡션createLogger INFO가 tag 함수, USER 적용이 message 함수, 메시지 호출이 void 효과를 만드는 실제 3단계 호출을 보여 준다.
전체 상태와 변화 근거
장면현재 관찰값설명변화 이유
INFO 캡처captured: INFO / effect: 아직 출력 없음createLogger(INFO)가 태그를 받을 함수를 반환한다부분 적용은 최종 로그 실행이 아니다
USER 캡처captured: INFO·USER / effect: 아직 출력 없음infoLogger(USER)가 메시지를 받을 함수를 반환한다두 번째 호출도 함수와 환경을 만든다
message 호출captured: INFO·USER / 반환: void마지막 호출에서 시간과 console 출력을 실행한다실제 효과는 message를 전달한 마지막 호출에서 발생한다

고차 함수의 두 역할과 커링의 호출 계약을 구분한다 — 상태 추적

INFO·USER를 캡처한 함수 반환과 마지막 message 호출의 효과 발생이 다르다. 함수/캡처 값/출력을 고정 역할로 추적할 수 있다.

createLogger INFO가 tag 함수, USER 적용이 message 함수, 메시지 호출이 void 효과를 만드는 실제 3단계 호출을 보여준다.

HIGHER ORDER / CURRYING CONTRACT

커링된 logger는 단계마다 인자를 고정한다

고차 함수는 함수를 인자로 받거나 반환하며 두 역할을 함께 할 수도 있습니다. 교재 logger는 level과 tag를 앞 호출에서 고정하고, 마지막 message 호출에서 시간 조회·출력을 실행합니다.

커링된 logger는 단계마다 인자를 고정한다createLogger INFO가 tag 함수, USER 적용이 message 함수, 메시지 호출이 void 효과를 만드는 실제 3단계 호출을 보여준다.
createLogger('INFO')tag 함수를 반환
연결 관계createLogger('INFO') · tag 함수를 반환 → infoLogger('USER') · message 함수를 반환
infoLogger('USER')message 함수를 반환
연결 관계infoLogger('USER') · message 함수를 반환 → userLogger(message) · 시간 조회 · console 출력
userLogger(message)시간 조회 · console 출력

level → tag → message → void

logger의 세 단계 호출과 실행 시점createLogger INFO는 tag 함수를 반환하고 infoLogger USER는 message 함수를 반환한다. userLogger message 호출에서만 시간 조회와 console 출력이 실행되어 void가 된다. 앞 호출이 최종 로그를 실행한 것으로 해석하지 않는다.
createLogger('INFO')tag 함수를 반환
연결 관계createLogger('INFO') · tag 함수를 반환 → infoLogger('USER') · message 함수를 반환
infoLogger('USER')message 함수를 반환
연결 관계infoLogger('USER') · message 함수를 반환 → userLogger(message) · 시간 조회 · console 출력
userLogger(message)시간 조회 · console 출력

level → tag → message→ void

HIGHER ORDER

함수 입력·출력은 배타적이지 않다

applyTransformation은 callback을 받고 createFilterByMin은 함수를 반환합니다. 둘 다 고차 함수이며 하나의 함수가 두 역할을 함께 할 수도 있습니다.

CURRYING

단항 호출들의 연쇄

curriedAdd(1)(2)(3)은 각 호출마다 한 인자를 받습니다. 앞 인자를 고정해 add5, add5And10처럼 새 함수를 재사용합니다.

PARTIAL APPLICATION

부분 적용과 커링을 구분

부분 적용은 일부 인자를 고정하는 일반 개념입니다. 함수를 반환한다고 모두 커링이거나 컬렉션 연산인 것은 아닙니다.

EFFECTS

고차 함수도 효과를 낼 수 있다

logger의 앞 단계는 클로저 문맥을 만들고 마지막 단계는 new Date·console.log를 실행합니다. 고차 함수·커링이라는 분류만으로 순수성이 보장되지는 않습니다.

함수 타입에서 각 호출의 입력과 반환을 읽고, 캡처한 인자와 효과가 실행되는 시점을 확인합니다.

이 예의 조건

  • 원도표의 전체 구조와 설명은 위 관계도와 표에서 유지합니다. 아래 인쇄 표에도 원도표의 관계·조건·라벨·설명·주석을 보존합니다.
  • 두 관찰 칸의 위치는 고정입니다. 관찰 상태는 해당 순간의 값이며, 동작·이유는 그 조건에서의 판정과 변화 원인입니다. 독립 비교와 실패 분기는 별도 조건으로 읽고 모든 분기를 한 요청의 필수 실행 순서로 이어 읽지 않습니다.

관계도의 보충 설명

원도표의 전체 관계·조건·설명
구분내용
원도표 캡션createLogger INFO가 tag 함수, USER 적용이 message 함수, 메시지 호출이 void 효과를 만드는 실제 3단계 호출을 보여 준다.
전체 상태와 변화 근거
장면현재 관찰값설명변화 이유
INFO 캡처captured: INFO / effect: 아직 출력 없음createLogger(INFO)가 태그를 받을 함수를 반환한다부분 적용은 최종 로그 실행이 아니다
USER 캡처captured: INFO·USER / effect: 아직 출력 없음infoLogger(USER)가 메시지를 받을 함수를 반환한다두 번째 호출도 함수와 환경을 만든다
message 호출captured: INFO·USER / 반환: void마지막 호출에서 시간과 console 출력을 실행한다실제 효과는 message를 전달한 마지막 호출에서 발생한다

함수를 인수로 받는 고차 함수

고차 함수는 함수를 인수로 받거나, 함수를 결과로 돌려주거나, 그 둘을 함께 하는 함수입니다. 배열의 map, filter, reduce가 이미 익숙한 예로, 각 요소를 어떻게 다룰지를 콜백 함수(callback function)로 받습니다. 다음 applyTransformation은 변환 방법을 함수로 받아 배열의 모든 요소에 적용합니다.

function applyTransformation<T, U>(arr: readonly T[], transformFn: (item: T) => U): U[] {
  return arr.map(transformFn);
}

const numbers = [1, 2, 3, 4];
const double = (n: number): number => n * 2;
const label = (n: number): string => `Number: ${n}`;

console.log(applyTransformation(numbers, double).join(", "));
console.log(applyTransformation(numbers, label).join(" | "));
2, 4, 6, 8
Number: 1 | Number: 2 | Number: 3 | Number: 4

applyTransformation은 배열을 어떻게 바꿀지 모르고, 무엇을 하든 transformFn에 맡깁니다. 타입 매개변수 T는 배열에서 number로 추론되고, U는 넘긴 함수의 반환 타입에서 추론되므로 첫 호출은 number[], 둘째 호출은 string[]을 돌려줍니다. 문자열을 받는 함수를 넘기면 T와 맞지 않아 호출하는 자리에서 진단되므로, 변환 함수를 잘못 고른 실수를 실행하기 전에 알 수 있습니다.

함수를 돌려주는 고차 함수

함수를 돌려주는 고차 함수는 조건을 미리 정해 둔 함수를 찍어 내는 함수 팩토리(function factory)로 자주 쓰입니다. 다음 createFilterByMin은 기준값을 받아, 그 값보다 큰지 검사하는 함수를 돌려줍니다.

function createFilterByMin(minThreshold: number): (num: number) => boolean {
  return (num) => num > minThreshold;
}

const data = [10, 25, 5, 40, 15];
const greaterThan20 = createFilterByMin(20);
const greaterThan30 = createFilterByMin(30);

console.log(data.filter(greaterThan20).join(","));
console.log(data.filter(greaterThan30).join(","));
25,40
40

createFilterByMin(20)을 부르는 시점에는 아무것도 걸러지지 않습니다. 이 호출은 20을 기억하는 (num: number) => boolean 함수를 만들어 돌려줄 뿐이고, 함수 안의 minThreshold는 돌려준 함수가 바깥 변수를 붙잡아 두는 클로저(closure)로 남습니다. 나중에 filter가 배열의 값마다 그 함수를 부르면, 25와 40만 num > 20을 만족해 남습니다.

함수를 만드는 시점과 데이터를 처리하는 시점이 나뉘어 있다는 점이 함수 팩토리를 읽는 열쇠입니다. 이렇게 공통 처리는 고차 함수에 두고 달라지는 부분만 함수로 넘기면, 기존 코드를 고치지 않고 새 동작을 더할 수 있고 같은 처리를 여러 곳에서 다시 쓸 수 있습니다. createFilterByMin처럼 함수를 받지 않고 돌려주기만 해도 고차 함수이며, 한 함수가 함수를 받으면서 돌려줄 수도 있습니다.

커링

커링은 여러 인수를 한꺼번에 받는 함수를, 인수를 하나씩 받는 함수의 연쇄로 바꾸는 기법입니다. f(a, b, c)로 부르던 함수를 f(a)(b)(c)로 부르게 되며, 각 호출은 다음 인수를 받을 함수를 돌려주다가 마지막 인수가 들어왔을 때 결과를 돌려줍니다. 이 이름은 수학자 하스켈 커리(Haskell Curry)에서 왔고, Haskell 같은 언어에서는 모든 함수가 기본으로 이런 모양입니다.

function add(a: number, b: number, c: number): number {
  return a + b + c;
}

const curriedAdd = (a: number) => (b: number) => (c: number) => a + b + c;

console.log(add(1, 2, 3), curriedAdd(1)(2)(3));

const add5 = curriedAdd(5);
const add5And10 = add5(10);
console.log(add5(1)(2), add5And10(3));
6 6
8 18

curriedAdd의 타입은 화살표 함수에서 (a: number) => (b: number) => (c: number) => number로 추론됩니다. curriedAdd(5)는 5를 기억하고 b를 기다리는 함수인 add5를 돌려주고, add5(10)은 5와 10을 기억하고 c를 기다리는 함수인 add5And10을 돌려줍니다. 그래서 add5(1)(2)는 5 + 1 + 2인 8이고, add5And10(3)은 5 + 10 + 3인 18입니다.

커링된 함수는 인수를 하나 받아 함수를 돌려주므로, 그 자체로 함수를 돌려주는 고차 함수입니다. 앞의 인수를 고정한 함수를 이름 붙여 다시 쓸 수 있고, 인수를 하나만 받는 함수끼리는 4편에서 볼 함수 조합에서 이어 붙이기도 쉽습니다.

부분 적용과 커링의 차이

부분 적용은 함수의 인수 일부를 미리 정해 두고, 나머지 인수만 받는 새 함수를 만드는 일반적인 방법입니다. 커링된 함수에 앞 인수를 넘기는 것도 부분 적용의 한 방법이지만, 커링 없이도 부분 적용은 할 수 있습니다.

const addTen = (b: number, c: number): number => add(10, b, c);
console.log(addTen(1, 2));

function curry2<A, B, R>(fn: (a: A, b: B) => R): (a: A) => (b: B) => R {
  return (a) => (b) => fn(a, b);
}

const multiply = (a: number, b: number): number => a * b;
const triple = curry2(multiply)(3);
console.log(triple(7));
13
21

addTen은 첫 인수를 10으로 고정한 부분 적용이지만, 남은 두 인수를 한꺼번에 받으므로 커링된 함수는 아닙니다. 반대로 curry2는 두 인수를 받는 어떤 함수든 하나씩 받는 모양으로 바꾸는 함수이며, A, B, R은 넘긴 multiply에서 모두 number로 추론됩니다. 정리하면 커링은 함수의 모양을 바꾸는 일이고, 부분 적용은 인수 일부를 정해 두는 일이며, 함수를 돌려준다고 해서 모두 커링인 것도 아닙니다.

더 깊이: 인수의 개수와 상관없는 부분 적용의 타입

자바스크립트에는 부분 적용을 하는 내장 메서드도 있습니다. multiply.bind(null, 10)은 첫 인수를 10으로 정해 둔 새 함수를 돌려주며, strict에 포함된 strictBindCallApply가 켜져 있으면 타입스크립트는 bind, call, apply의 인수를 원래 함수의 매개변수에 맞추어 검사합니다. 다만 TypeScript 3.2 릴리스 노트는 이 기능이 제네릭 함수와 오버로드한 함수를 완전히 나타내지 못한다고 적습니다. 제네릭 함수에 쓰면 타입 매개변수가 빈 객체 타입 {}로 바뀌고, 오버로드한 함수에서는 마지막 오버로드만 반영됩니다.

curry2처럼 인수의 개수를 정해 둔 도우미는 쉽게 타입을 적을 수 있지만, 인수가 몇 개든 앞쪽 일부를 고정하는 도우미의 타입은 TypeScript 4.0의 가변 튜플 타입(variadic tuple types)이 들어오고서야 자연스럽게 적을 수 있게 되었습니다. 4.0부터는 튜플 타입 안의 전개에 제네릭을 쓸 수 있고, 나머지 요소를 튜플의 끝이 아닌 곳에도 둘 수 있습니다. 그래서 릴리스 노트의 partialCall은 원래 함수의 매개변수를 [...T, ...U]로 나누어 받고, 미리 넘긴 인수를 T로, 돌려줄 함수가 받을 나머지 인수를 U로 추론합니다. 그 결과 미리 넘긴 인수의 타입이 틀리거나 나중에 넘길 인수가 모자라면 실행 전에 진단됩니다.

커링된 로거와 실행 시점

실무에서 커링이 잘 맞는 예가 로거입니다. 로그 레벨과 태그는 한 번 정해 두고 메시지만 바꿔 가며 부르는 경우가 많기 때문입니다.

type LogLevel = "INFO" | "WARN" | "ERROR";

function createLogger(level: LogLevel): (tag: string) => (message: string) => void {
  return (tag) => (message) => {
    const timestamp = new Date().toISOString();
    console.log(`[${timestamp}] [${level}] [${tag}] ${message}`);
  };
}

const infoLogger = createLogger("INFO");
const userLogger = infoLogger("USER");

userLogger("New user registered!");
userLogger("User login successful.");
createLogger("WARN")("NETWORK")("Network connection lost!");
[(실행 시각)] [INFO] [USER] New user registered!
[(실행 시각)] [INFO] [USER] User login successful.
[(실행 시각)] [WARN] [NETWORK] Network connection lost!

출력에서 (실행 시각) 자리에는 toISOString()이 만든 그 순간의 시각 문자열이 들어가므로, 실행할 때마다 달라집니다. createLogger("INFO")는 레벨을 기억하고 태그를 받을 함수를 돌려주고, infoLogger("USER")는 레벨과 태그를 기억하고 메시지를 받을 함수를 돌려줍니다. 이 두 호출까지는 시각을 읽지도 출력하지도 않으며, 메시지를 넘기는 마지막 호출에서만 new Date()와 console.log가 실행됩니다.

이 로거는 고차 함수이자 커링된 함수이지만, 마지막 호출은 시각에 따라 결과가 달라지고 화면에 출력하는 부수 효과를 냅니다. 고차 함수나 커링이라는 모양이 순수성을 보장하지는 않으므로, 함수 타입을 읽을 때는 각 호출이 무엇을 받고 무엇을 돌려주는지와 함께 효과가 어느 호출에서 일어나는지를 확인합니다. 함수 타입 표기는 공식 Handbook의 함수 타입 표현식에서 확인할 수 있습니다.

정리

  • 고차 함수는 함수를 인수로 받거나 결과로 돌려주며, 두 역할을 함께 할 수도 있습니다.
  • 함수를 인수로 받는 고차 함수는 공통 처리를 맡고, 달라지는 부분은 넘겨받은 함수에 맡기며, 타입 매개변수가 넘긴 함수의 입력과 출력을 이어 줍니다.
  • 함수를 돌려주는 고차 함수는 받은 값을 클로저로 기억한 함수를 만들며, 만드는 시점과 쓰는 시점이 다릅니다.
  • 커링은 여러 인수의 함수를 하나씩 받는 함수의 연쇄로 바꾸는 일이고, 부분 적용은 인수 일부를 정해 둔 새 함수를 만드는 더 일반적인 방법입니다.
  • 고차 함수와 커링도 부수 효과를 낼 수 있으므로, 효과가 어느 호출에서 일어나는지 확인합니다.

연습 문제

1. 호출마다 남는 타입

const with4 = curriedAdd(4);와 const with4And7 = with4(7);을 만들었습니다. 두 변수의 타입은 무엇이고, with4And7(3)과 with4(1)(2)의 값은 무엇인가요?

해설 보기

with4는 (b: number) => (c: number) => number 타입의 함수이고, with4And7은 (c: number) => number 타입의 함수입니다. with4And7(3)은 4 + 7 + 3인 14이고, with4(1)(2)는 4 + 1 + 2인 7입니다. 마지막 인수를 넘기기 전까지는 덧셈 결과가 아니라 다음 인수를 기다리는 함수를 얻습니다.

2. 로거를 만든 시점과 출력 시점

const audit = createLogger("WARN")("AUTH");만 실행했을 때 로그가 출력되나요? 그 뒤 audit("retry")를 부르면 무엇이 출력되나요?

해설 보기

audit을 만드는 두 호출은 레벨과 태그를 기억한 (message: string) => void 함수를 돌려줄 뿐이어서, 이 시점에는 아무것도 출력되지 않습니다. audit("retry")를 불러야 그때의 시각을 읽고 [(실행 시각)] [WARN] [AUTH] retry 모양의 한 줄을 출력합니다.

3. curry2로 바꾼 함수

const repeatText = curry2((text: string, count: number) => text.repeat(count));를 만들었습니다. repeatText("ab")의 타입과 repeatText("ab")(3)의 값은 무엇인가요? addTen과 비교하면 어떤 점이 다른가요?

해설 보기

A는 string, B는 number, R은 string으로 추론되므로 repeatText("ab")는 (b: number) => string 함수이고, repeatText("ab")(3)은 "ababab"입니다. repeatText는 인수를 하나씩 받는 커링된 함수이고, addTen은 첫 인수만 정해 두고 남은 두 인수를 한꺼번에 받는 부분 적용입니다.

다음 글 모나드와 옵셔널 체이닝에서는 값이 없을 수 있다는 사정을 감싸 둔 채 연산을 이어 가는 Maybe를 만들어 보고, 같은 문제를 문법으로 다루는 옵셔널 체이닝과 비교합니다.