부분 적용과 커링
인자 일부를 먼저 고정해 새 함수를 만들고, 여러 인자 함수를 한 인자씩 받는 함수로 바꾸는 함수형 패턴.
· 마지막 업데이트: 2026-10-25 · 11 min read
개요
상품 가격마다 부가가치세 10%를 붙인다고 하자. 세율과 가격을 받는 addTax(rate, price)가 있으면 호출할 때마다 같은 0.10m을 다시 넘겨야 하고, 한 곳에서 0.01m으로 잘못 쓰면 그 호출만 다른 세율로 계산된다. Select처럼 인자 하나짜리 함수를 받는 자리에 넘기려면 매번 price => addTax(0.10m, price) 같은 람다로 감싸야 한다.
부분 적용(partial application)은 인자 일부를 먼저 채워 두고 나머지만 받는 새 함수를 만드는 일이다. 세율을 0.10으로 채운 withVat에는 나중에 가격만 넘기면 되는데, 그때까지 자신을 만든 곳의 변수를 붙잡아 두는 함수를 클로저(closure)라고 한다.
커링(currying)은 인자를 채워 두는 대신 함수의 모양을 바꾸는 변환으로, (rate, price) => ...처럼 둘을 한꺼번에 받던 함수를 rate => price => ...로 바꾸면 첫 인자를 받은 결과가 두 번째 인자를 받을 함수가 된다. 여기에 첫 인자만 넘기는 것이 부분 적용이라 두 말이 자주 섞이는데, 커링은 함수의 모양을 바꾸고 부분 적용은 인자를 미리 채운다는 차이가 있다.
흐름
addTax에 세율을 직접 채워도, 커링한 addTaxCurried에 세율만 넘겨도 가격 하나를 받는 같은 모양의 함수가 나온다. 여기에 남은 인자 1000m을 넣을 때 실제 계산이 일어난다.
역사
여러 인자를 받는 함수를 인자 하나짜리 함수로 다룰 수 있다는 관찰은 Frege까지 거슬러 올라간다. 스탠퍼드 철학 백과사전의 람다 계산 항목은 Frege가 1893년 『산술의 기본 법칙』(Grundgesetze der Arithmetik)에서 함수를 연구할 때는 인자 하나짜리 함수만 다뤄도 충분하다고 보았다고 설명한다. 같은 항목은 이 절차를 커링이라고 부르면서, 역사적으로는 Frege의 이름을 붙이는 편이 더 정확할 수 있다고 덧붙인다.1
Moses Schönfinkel은 1924년 논문(영역 제목 "On the building blocks of mathematical logic")에서 조합자(combinator)를 도입해 논리식의 묶인 변수를 없애는 방법을 보였다. 같은 백과사전의 조합 논리 항목은 이 과정의 중간 단계가 여러 인자 함수를 인자 하나짜리 함수로 볼 수 있다는 생각에 기대며, 그 생각이 흔히 커링이라고 불린다고 설명한다. Schönfinkel이 조합 논리에 관해 발표한 논문은 이것 하나였고, 이후 Haskell B. Curry가 조합 논리를 다시 발견해 크게 확장했다.2
Haskell 언어 설계자들이 쓴 "A History of Haskell"은 이 전통이 Frege에서 시작해 Schönfinkel과 Haskell Curry를 거치며 다듬어졌고 커링이라는 이름으로 불리게 되었다고 정리한다. 같은 글은 Haskell이 커링된 정의와 튜플 하나를 받는 정의를 모두 지원한다고 적는다.3
왜 사용하는가?
설정에 해당하는 인자를 한 번 채워 함수에 담아 두면, 호출하는 쪽은 남은 인자만 넘긴다. 자주 바뀌지 않는 세율이나 통화를 호출마다 반복하지 않고, Func<decimal, decimal>처럼 금액을 계산하는 함수는 LINQ의 Select에 넘긴다. Where에는 Func<decimal, bool>처럼 조건의 참·거짓을 돌려주는 함수가 필요하므로, 부분 적용한 결과도 그 반환 타입에 맞아야 한다. 전략 패턴 문서의 PriorityFirst(productId)도 우선할 상품 ID를 받아 정렬 함수를 만들어 돌려주는데, 인자를 채운 함수를 전달한다는 점에서 같은 구조다.
C#에서는 커링이나 부분 적용을 직접 람다로 표현한다. 메서드는 인자를 한꺼번에 받으므로 커링된 형태는 Func<decimal, Func<decimal, decimal>>처럼 람다를 겹쳐 적어야 하는데, 인자가 셋만 되어도 타입과 f(a)(b)(c)가 읽기 까다롭다. 이처럼 앞쪽부터 인자를 채우려면 설정처럼 먼저 정해지는 값을 앞에 놓아야 쓸모가 있다.
C# 람다는 값이 아니라 변수를 캡처하므로, 그 변수나 변수가 가리키는 객체가 나중에 바뀌면 이미 만든 함수의 결과도 달라진다. 현재 .NET에서는 캡처가 있는 람다를 만들 때 힙 할당도 생기니 같은 설정의 함수는 반복문 안에서 계속 만들기보다 한 번 만들어 재사용한다.4
예제
같은 세율 계산을 세 방식으로 호출해 보자. withVat은 addTax의 세율을 미리 채운 함수이고, addTaxCurried는 그 계산을 커링한 형태다.
using System;
// 세율과 가격을 한꺼번에 받는 원래 함수
Func<decimal, decimal, decimal> addTax = (rate, price) => price * (1 + rate);
// 부분 적용: rate를 0.10으로 고정하고 price만 받는 새 함수를 만든다.
Func<decimal, decimal> withVat = price => addTax(0.10m, price);
// 커링: rate를 받으면 price를 받는 함수를 돌려주는 형태로 바꾼다.
Func<decimal, Func<decimal, decimal>> addTaxCurried = rate => price => price * (1 + rate);
Console.WriteLine(addTax(0.10m, 1000m));
Console.WriteLine(withVat(1000m));
Console.WriteLine(addTaxCurried(0.10m)(1000m));1100.00
1100.00
1100.00세 호출의 값은 같고, withVat과 addTaxCurried(0.10m)은 모두 가격 하나만 받는다.
범위 넓힌 예제
통화, 소수 자릿수, 금액을 받는 3인자 포매터를 커링해 두고, 통화별 함수를 한 번씩 만들어 Select에 넘긴다. 세율 계산 함수는 만들 때 세율을 검증한다. 뒤의 두 부분은 캡처가 실제로 어떻게 동작하는지 출력으로 보여 준다.
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
// 실행 환경의 지역 설정과 관계없이 같은 출력이 나오도록 고정한다.
CultureInfo.CurrentCulture = CultureInfo.InvariantCulture;
// 통화, 소수 자릿수, 금액을 하나씩 받도록 커링한 포매터
Func<string, Func<int, Func<decimal, string>>> formatPrice =
currency => decimals => amount => currency + " " + amount.ToString("N" + decimals);
// 통화와 자릿수는 한 번만 정하고, 만든 함수를 Select에 그대로 넘긴다.
Func<decimal, string> krw = formatPrice("KRW")(0);
Func<decimal, string> usd = formatPrice("USD")(2);
decimal[] prices = [1200m, 3450.5m];
Console.WriteLine(string.Join(" | ", prices.Select(krw)));
Console.WriteLine(string.Join(" | ", prices.Select(usd)));
// 고정할 값은 함수를 만들 때 한 번만 검증한다.
foreach (var rate in new[] { 0.10m, 1.5m })
{
var calculator = TaxCalculator(rate);
Console.WriteLine(calculator is null
? $"rate {rate}: rejected"
: $"rate {rate}: {calculator(1000m)}");
}
// 람다는 값이 아니라 변수를 캡처한다.
var settings = new TaxSettings { Rate = 0.10m };
Func<decimal, decimal> live = price => price * (1 + settings.Rate);
var fixedRate = settings.Rate; // 지금 값을 지역 변수에 복사해 둔다.
Func<decimal, decimal> snapshot = price => price * (1 + fixedRate);
settings.Rate = 0.20m;
Console.WriteLine($"live: {live(1000m)}, snapshot: {snapshot(1000m)}");
// for의 i는 반복 전체에서 변수 하나이고, foreach의 n은 반복마다 새 변수다.
var fromFor = new List<Func<int>>();
for (var i = 0; i < 3; i++) fromFor.Add(() => i);
var fromForeach = new List<Func<int>>();
foreach (var n in new[] { 0, 1, 2 }) fromForeach.Add(() => n);
Console.WriteLine("for: " + string.Join(",", fromFor.Select(f => f())));
Console.WriteLine("foreach: " + string.Join(",", fromForeach.Select(f => f())));
// 세율이 0과 1 사이가 아니면 계산 함수를 만들지 않는다.
static Func<decimal, decimal>? TaxCalculator(decimal rate) =>
rate is < 0m or > 1m
? null
: price => decimal.Round(price * (1 + rate), 0, MidpointRounding.AwayFromZero);
sealed class TaxSettings
{
public decimal Rate { get; set; }
}KRW 1,200 | KRW 3,451
USD 1,200.00 | USD 3,450.50
rate 0.10: 1100
rate 1.5: rejected
live: 1200.00, snapshot: 1100.00
for: 3,3,3
foreach: 0,1,2N0 서식은 3450.5를 3,451로 반올림해 표시한다. 세율 1.5는 계산 함수를 만드는 시점에 거부되므로 가격 계산까지 가지 않는다. live는 settings 객체를 캡처해 나중에 바뀐 0.20을 읽고, snapshot은 함수를 만들기 전에 복사해 둔 0.10을 쓴다. for의 i는 반복 전체에서 변수 하나이므로 세 람다가 모두 반복이 끝난 뒤의 3을 읽고, foreach는 반복마다 새 변수를 만들기 때문에 0, 1, 2가 나온다.5
TaxCalculator가 null을 돌려주면 세율을 거부했다는 사실만 남으므로 이유까지 알려야 한다면 결과 타입에 실패를 담는다. 커링한 포매터는 통화와 자릿수의 조합을 검사하지 않으며, 고정한 세율이 바뀔 때는 함수도 새로 만들어야 한다. 처음 값을 복사해 고정할지 settings 객체를 계속 읽게 할지도 함수를 만드는 쪽에서 정할 일이다.
TypeScript 예제
TypeScript에서는 화살표 함수를 겹쳐 커링하고, 부분 적용에는 JavaScript의 Function.prototype.bind도 쓸 수 있다. bind의 첫 인자는 this이고 그 뒤의 인자들이 대상 함수의 앞쪽 인자를 채우며, 루프에서 무엇을 캡처하는지는 var와 let을 비교해 보면 드러난다.67
// 통화, 소수 자릿수, 금액을 하나씩 받도록 커링한 포매터
const formatPrice = (currency: string) => (decimals: number) => (amount: number): string =>
currency + " " + amount.toLocaleString("en-US", {
minimumFractionDigits: decimals,
maximumFractionDigits: decimals,
});
const krw = formatPrice("KRW")(0);
const usd = formatPrice("USD")(2);
const prices = [1200, 3450.5];
console.log(prices.map(krw).join(" | "));
console.log(prices.map(usd).join(" | "));
// bind는 JavaScript에 내장된 부분 적용이다. 앞쪽 인자부터 고정한다.
function addTax(rate: number, price: number): number {
return Math.round(price * (1 + rate)); // number는 부동소수점이라 정수로 반올림한다.
}
const withVat = addTax.bind(null, 0.1);
console.log("withVat: " + withVat(1000));
// 클로저는 객체를 나중에 읽으므로, 고정하려면 지금 값을 복사한다.
const settings = { rate: 0.1 };
const live = (price: number) => Math.round(price * (1 + settings.rate));
const fixedRate = settings.rate;
const snapshot = (price: number) => Math.round(price * (1 + fixedRate));
settings.rate = 0.2;
console.log(`live: ${live(1000)}, snapshot: ${snapshot(1000)}`);
// var는 반복 전체에서 변수 하나이고, let은 반복마다 새 바인딩을 만든다.
const fromVar: Array<() => number> = [];
for (var i = 0; i < 3; i++) fromVar.push(() => i);
const fromLet: Array<() => number> = [];
for (let j = 0; j < 3; j++) fromLet.push(() => j);
console.log("var: " + fromVar.map((f) => f()).join(","));
console.log("let: " + fromLet.map((f) => f()).join(","));KRW 1,200 | KRW 3,451
USD 1,200.00 | USD 3,450.50
withVat: 1100
live: 1200, snapshot: 1100
var: 3,3,3
let: 0,1,2bind도 앞쪽 인자만 채울 수 있고, this를 쓰지 않는 함수에는 첫 인자로 null을 넘긴다. settings 객체를 읽는 live가 바뀐 세율을 따라가는 점은 C# 예제와 같다.
같이 보기
예제 실행 환경: C#은 독립된 .NET 10 콘솔 프로그램, TypeScript는 ES2022 이상으로 컴파일한 뒤 Node.js에서 실행한다.
각주
- Alama, Jesse; Korbmacher, Johannes. "The Lambda Calculus". Stanford Encyclopedia of Philosophy (2023 개정). 3절 Brief history of λ-calculus. Frege (1893)와 currying이라는 이름. ↩
- Bimbó, Katalin. "Combinatory Logic". Stanford Encyclopedia of Philosophy (2024 개정). 1.2절과 주 1, 7. Schönfinkel (1924)의 영역은 van Heijenoort 편 From Frege to Gödel (1967), 355~366쪽. ↩
- Hudak, Paul; Hughes, John; Peyton Jones, Simon; Wadler, Philip (2007). "A History of Haskell: Being Lazy With Class". HOPL III. 4.2절 Functions and function application. ↩
- Microsoft Learn. Lambda expressions: Capture of outer variables and variable scope. 캡처한 변수는 그 변수를 참조하는 대리자가 수거될 수 있을 때까지 유지되고, 같은 변수를 캡처한 다른 람다도 바뀐 값을 본다. ↩
- Microsoft Learn. C# 명세 §13.9.5.2 Synchronous foreach. 반복 변수를 루프 안쪽에 선언하므로 반복마다 별도의 변수가 캡처된다. ↩
- MDN. Function.prototype.bind(). Partially applied functions 절. ↩
- MDN. for. Lexical declarations in the initialization block 절. ↩