커링된 함수의 장점과 한계를 살펴보고, 튜플 스타일 함수 정의가 더 자연스러울 수 있는 이유를 논의합니다.
명령형 언어에서 함수형 언어로 넘어갈 때, 커링된 함수는 아마 가장 먼저 접하게 되는 새로운 개념 중 하나일 것입니다. 순수 함수형 언어에서는 매개변수 n개를 받는 함수를, 매개변수를 단계적으로 나누는 방식으로 귀납적으로 정의하는 것이 관례입니다. 함수에 첫 번째 인자 #1을 적용하면 매개변수 2..n을 받는 함수가 반환되고, 그 함수에 다시 두 번째 인자 #2를 적용하면 매개변수 3..n을 받는 함수가 반환되며, 이런 식으로 모든 인자가 주어질 때까지 이어진 뒤 최종 결과가 반환됩니다. 예를 들어, 세 수를 더하는 매개변수 3개의 add 함수를 다음과 같이 정의할 수 있습니다:
add x y z = x + y + z
-- 이것은 다음의 문법적 설탕입니다:
add = \x -> (\y -> (\z -> x + y + z))
-- add의 타입은 다음과 같습니다:
add :: Int -> (Int -> (Int -> Int))
-- 왼쪽에서 오른쪽으로 적용할 수 있습니다:
((add 1) 2) 3 -- 6을 반환
우리는 화살표 ->를 우결합으로 두기 때문에 Int -> Int -> Int -> Int라고 쓸 수 있습니다. 또한 함수 적용도 좌결합으로 두기 때문에 add 1 2 3라고 쓰며 괄호 사용을 최소화할 수 있습니다.
제가 주장하고 싶은 것은, 이 스타일로 함수를 정의하면 보기 좋고 우아하긴 하지만 잃게 되는 것도 있다는 점입니다.
프로그래밍 언어가 다중 매개변수 함수 정의를 작성할 때 제공하는 "스타일"은 대략 세 가지가 있습니다. 첫째는 제가 "매개변수 목록" 스타일이라고 부를 명령형 스타일입니다. 여기서는 여러 매개변수를 받는 것이 함수의 내장 기능입니다. Rust 같은 명령형 언어에서는 이것이 기본입니다:
fn f(p1: P1, p2: P2, p3: P3) -> R { ... } // 정의
f(a1, a2, a3) // 호출
f : fn(P1, P2, P3) -> R // 타입
다른 형태는 Haskell 같은 순수 함수형 언어에서 제공하는 "커링" 스타일입니다:
f p1 p2 p3 = ... -- 정의
f a1 a2 a3 -- 호출
f :: P1 -> P2 -> P3 -> R -- 타입
마지막으로 "튜플" 스타일이 있습니다. 겉보기에는 매개변수 목록 스타일과 비슷하지만, 여러 매개변수가 함수 자체의 일부는 아닙니다. 함수는 매개변수를 하나만 받지만 그 매개변수가 튜플이므로 사실상 여러 값을 함께 운반합니다. 보통 Haskell 같은 함수형 언어에서도 가능하지만 표준은 아닙니다:
f(p1, p2, p3) = ... -- 정의
f(a1, a2, a3) -- 호출
f :: (P1, P2, P3) -> R -- 타입
일부 명령형 언어도 이런 함수형 스타일을 제공하긴 하지만, 조금 다루기 불편해집니다. 예를 들어 JavaScript에서는 커링 스타일을 이렇게 쓸 수 있습니다:
const f = p1 => p2 => p3 => ...;
f(a1)(a2)(a3)
Rust에서는 튜플 스타일도 할 수 있습니다:
fn f((p1, p2, p3): (P1, P2, P3)) -> R { ... }
f((a1, a2, a3))
f : fn((P1, P2, P3)) -> R
결국 이런 스타일들은 실제 용도는 다를지라도 이론적으로는 동등합니다. 타입 (P1, P2) -> R와 P1 -> P2 -> R는 동형인데, 이는 그 타입의 함수들 사이에 일대일 대응이 있다는 뜻입니다. 그렇다면 다른 스타일보다 커링 스타일을 선호할 이유는 무엇일까요?
인터넷에 왜 커링된 함수를 쓰느냐고 물어보면, 주된 답은 "부분 적용을 쉽게 만들기 때문"이라는 것입니다. 부분 적용은 여러 인자를 받는 함수의 매개변수 하나를 특정 값으로 고정하고, 나머지 매개변수만 입력으로 받는 새 함수를 얻는 메커니즘입니다. 부분 적용이 커링 스타일에서 매우 자연스럽고 우아하다는 것은 사실입니다. 예를 들어 앞의 매개변수 3개짜리 add를 다시 보면, Haskell에서는 다음과 같이 할 수 있습니다:
add' = add 1
-- 이제 add' = \y -> (\z -> 1 + y + z)
add' :: Int -> Int -> Int
add'' = add' 2
-- 이제 add'' = \z -> 1 + 2 + z
add'' :: Int -> Int
add'' 3 -- 6을 반환
이는 map이나 fold 같은 고차 함수가 있을 때 특히 멋집니다. 예를 들어 부분 적용과 함수 합성을 이용하면 꽤 정신 나갈 정도로 멋진 일도 할 수 있습니다:
length = foldr (+) 0 . map (const 1)
length2d = foldr (+) 0 . map length
length2d [[1, 4, 2], [], [7, 13]] -- 5를 반환
하지만 부분 적용을 할 수 있다는 선택지가 커링된 함수만의 특별한 성질이라고 잘못 생각하는 경우가 많습니다! 매개변수 목록 스타일이나 튜플 스타일의 함수에 대해서도 부분 적용은 얼마든지 할 수 있습니다. add를 튜플 스타일로 다시 정의하면 다음과 비슷한 형태가 됩니다:
add(x, y, z) = x + y + z
add :: (Int, Int, Int) -> Int
add' = let x = 1 in \(y, z) -> add(x, y, z)
add'' = let y = 2 in \z -> add'(y, z)
add''(3) -- 6을 반환
"하지만," 여러분이 이렇게 말하는 소리가 들리는 것 같습니다. "이건 확실히 끔찍해 보이는데." 아니면 아닐 수도 있겠죠. 입에 없는 말을 넣고 싶지는 않습니다. 하지만 보기 좋게 만드는 문법적 설탕을 조금만 정의하면 됩니다. 예를 들어 $라는 "구멍 연산자"를 정의한다고 해 봅시다:
add' = add(1, $, $)
add'' = add'(2, $)
add''(3) -- 6을 반환
제 생각에는 이쪽이 오히려 조금 더 읽기 쉽습니다. 좀 더 복잡한 예제도 이제 다음처럼 보입니다:
length = foldr((+), 0, $) . map(const(1), $)
length2d = foldr((+), 0, $) . map(length, $)
length2d([[1, 4, 2], [], [7, 13]]) -- 5를 반환
저는 실제로 이 형태가 더 명확하다고 느낍니다. length나 length2d에 넣는 데이터의 "흐름"이 드러나는 느낌이 있습니다. 먼저 map의 두 번째 매개변수로 들어가고, 그 결과가 다시 foldr의 세 번째 매개변수로 들어갑니다.
또한 이런 기능이 있으면 첫 번째 매개변수뿐 아니라 다른 매개변수에 대한 부분 적용도 가능합니다. 이는 커링 스타일에서는 기본적으로 되지 않습니다. 예를 들어 map의 두 번째 매개변수를 고정하고 싶다면:
-- 이것이 바로 모든 색입니다. 지금까지 만들어진 모든 색이죠.
allColors = ["red", "green", "blue"]
forEachColor = map($, allColors)
이 기능에도 제약은 있습니다. 예를 들어 중첩된 함수 호출이 여러 개 있을 때는 한계가 있지만, 그런 경우에는 언제나 명시적인 람다 식을 쓸 수 있습니다.
그러므로 커링된 함수 스타일은 우아하긴 하지만 부분 적용을 더 강력하게 만들어 주지는 않습니다. 약간의 문법적 설탕만 있으면 그 능력은 쉽게 흉내 낼 수 있습니다. 하지만 함수형 프로그래머들이 물이나 잠보다도 더 강하게 커링된 함수에 의존하게 되는 데에는 또 다른, 조금 더 "분위기 기반"의 이유가 있다고 저는 의심합니다.
함수형 프로그래밍을 처음 배우고 마침내 커링된 함수 타입을 이해하게 되면, 마치 매트릭스의 내부를 들여다보는 것 같은 기분이 듭니다. ->를 우결합으로, 함수 적용을 좌결합으로 만들면, 커링된 함수에 여러 매개변수를 차례로 적용하는 과정이 정말 아름답게 펼쳐집니다! 함수 적용을 할 때는 괄호를 쓸 필요조차 없습니다. 게다가 부분 적용도 완전히 공짜로 얻으니, 2차원 리스트의 길이를 계산하는 꼬불꼬불한 정의를 써 놓고 스스로 아주 영리하다고 느낄 수 있습니다.
더 아름다운 점은, 이것이 본질적으로 귀납적인 "모양"이라는 것입니다. 그리고 그 모양은 명령형 언어와 함수형 언어의 이분법을 반영합니다. 그들은 배열 같은 매개변수 목록을 가지고 있고, 이는 더 반복적입니다. 하지만 우리는 리스트 같은 커링된 함수를 가지고 있고, 이는 더 귀납적입니다. 이 우아함을 생각하기만 해도 마음이 포근해집니다! 분명 이렇게 되라고 만들어진 게 틀림없습니다!
할 수 있다고 해서 꼭 해야 하는 것은 아닙니다. 튜플 스타일을 선호할 만한 좋은 이유가 몇 가지 있습니다.
우선 성능이 조금은 걱정됩니다. add 2 3 같은 커링된 함수를 호출하면, 먼저 add 2가 새로운 함수 식 \y -> add 2 y로 평가되고, 그 다음에 그것이 3에 적용됩니다. 다중 매개변수 함수를 한 번 호출할 때마다 여러 중간 함수가 만들어집니다. 물론 충분히 좋은 최적화기가 있다면 이런 오버헤드는 제거할 수 있겠지만, 그렇다고 해도 가장 큰 관심사는 아닙니다.
더 중요한 것은 커링된 함수 타입이 모양이 이상하다는 점입니다. 함수의 핵심 아이디어는 입력을 받아 출력을 돌려준다는 것이므로, 타입은 In -> Out처럼 생겼습니다. 이것을 P1 -> P2 -> P3 -> R 같은 커링된 함수 타입과 통일하면 In = P1, Out = P2 -> P3 -> R가 됩니다. 반면 (P1, P2, P3) -> R 같은 튜플 함수 타입과 통일하면 In = (P1, P2, P3), Out = R가 되는데, 이쪽이 더 논리적으로 보입니다.
이 이상한 모양의 결과로 일종의 비대칭성이 생깁니다. 함수가 여러 출력을 반환할 때는 튜플을 반환하지만, 여러 입력을 받을 때는 대신 그것들을 단계적으로 나눕니다. 그 결과 커링된 함수는 종종 합성이 잘 되지 않습니다.
sayHi name age = "Hi I'm " ++ name ++ " and I'm " ++ show age
people = [("Alice", 70), ("Bob", 30), ("Charlotte", 40)]
-- 오류: sayHi는 String -> Int -> String이고, person은 (String, Int)이다
conversation = intercalate "\n" (map sayHi people)
여기서 진짜 문제는 map이 매우 일반적이어서 In -> Out 형태의 함수를 기대하는데, 커링된 함수는 이런 모양이 아니라는 점입니다. 이것을 작동시키려면 매핑 함수로 uncurry sayHi를 대신 넘겨야 합니다. 매개변수가 2개인 이 예제에서는 그리 나쁘지 않지만, 매개변수가 더 많아질수록 상황은 더 나빠집니다.
저는 Rocq 증명 보조기에서 모나드를 반환하는 함수들에 대한 술어를 다루는 프로젝트를 하면서 또 다른 결과를 직접 겪었습니다. 그 술어는 대략 다음과 같았습니다({} 안의 매개변수는 암묵적 타입 매개변수입니다):
Definition P {In Out : Type} (f : In -> State Out) :=
...f에 대한 어떤 명제...
함수를 "권장되는" 방식인 커링 스타일로 정의하면 f : P1 -> ... -> Pn -> State R 같은 형태가 됩니다. 이것은 In -> State Out와 전혀 통일되지 않으므로 P(f)는 아예 타입이 맞지 않습니다! 저는 매번 수동으로 그 함수를 언커리해야 했습니다. :(
물론 함수형 언어들이 갑자기 하루아침에 튜플 스타일로 전환하지는 않을 것이라는 점은 저도 압니다. 이미 수백만 줄의 Haskell 코드가 작성되어 있고, 커링된 함수는 지금 이 생태계에서 그냥 그렇게 하는 방식입니다. 인터넷 어딘가의 누군가가 좋은 생각이라고 여겼다는 이유만으로 아무도 그 모든 것을 바꾸지는 않겠죠 :). 하지만 여러분이 언젠가 함수형 언어나 표준 라이브러리를 만들게 된다면, 튜플 스타일과 부분 적용을 위한 대안 문법을 실험해 보는 것도 고려해 보세요.
이 글을 너무 진지하게 받아들이거나, 튜플 스타일이 항상 더 낫다는 절대적 판단으로 보지는 말아 주세요. map이나 fix 같은 일부 고차 함수에서는 커링된 정의가 너무나도 매력적이라 거부하기 어려운 것도 인정합니다. 다만 대부분의 목적에는 튜플 스타일이 훨씬 더 말이 된다고 저는 생각합니다.
또한 여기서 언급한 것 외에 커링된 함수의 다른 장점이나 단점을 알고 계시다면 꼭 듣고 싶습니다. 저도 함수형 코드를 꽤 많이 써 보긴 했지만, 결코 이 주제의 전문가라고 할 수는 없고 언제나 더 배우고 싶습니다. 그리고 어쩌면 이 글은 증명 보조기 프로젝트를 하는 동안 계속 함수를 언커리해야 해서 짜증이 났기 때문에 쓰게 된 것일지도 모릅니다.
커링 스타일이 더 우월한 경우도 하나 언급하고 싶었습니다. 아주 흔한 경우는 아니지만 말입니다. Gallina(Rocq/Coq 증명 보조기의 언어)나 Agda 같은 의존 타입 언어 안에 살고 있다면, 함수의 반환 타입이 입력들 중 하나에 의존하게 하거나(입력 타입 중 하나가 아니라 입력 값 중 하나에 의존), 혹은 함수의 두 번째 매개변수 타입이 첫 번째 매개변수의 값에 의존하게 만들 수 있습니다. 예를 들어 의존 타입의 흔한 예시는 어떤 자연수 n에 의해 상한이 보장되는 자연수입니다:
(* [fin n]의 한 인스턴스는 숫자와 x < n이라는 증명의 쌍이다 *)
Definition fin (n : nat) := { x : nat & x < n }.
Definition plus1 (n : nat) (i : fin n) : fin (n + 1) :=
(i.1 + 1 ; (* i.1 + 1 < n + 1이라는 증명 *)).
여기서 plus1의 타입은 forall n : nat, fin n -> fin (n + 1)인데, 이는 매개변수 n과 i를 갖는 커링된 의존 함수 타입입니다. forall은 다른 매개변수들과 반환 타입이 첫 번째 매개변수 n의 값에 의존할 수 있음을 나타냅니다. 이것의 튜플 스타일 버전이 있다고 해 봅시다. 그러면 매개변수는 의존 _튜플_이어야 합니다. 즉, 두 번째 튜플 원소의 타입이 첫 번째 튜플 원소의 값에 의존할 수 있어야 합니다:
(* 가상의 문법 *)
Definition plus1 (n : nat ; i : fin n) : fin (n + 1) :=
(i.1 + 1 ; (* i.1 + 1 < n + 1이라는 증명 *)).
이 경우 타입은 forall arg : { n : nat & fin n }, fin (arg.1) 같은 꼴이 될 것입니다. 세상에서 최악은 아니지만, 분명 더 다루기 불편하다고 저는 생각합니다.
의존 타입은 다루기가 엄청나게 어렵기로 악명 높고 타입 추론을 결정 불가능하게 만들기 때문에, 이런 기능이 있는 언어에서 작업할 때조차 함수 매개변수에 의존 타입을 쓰는 것은 피하는 편이 좋습니다. 그렇다고는 해도, 어떤 사람들은 여기에 강한 신뢰를 보이며 [1], 이를 광범위하게 활용하는 대규모 코드베이스를 실제로 작성하기도 합니다.
[1] Cpdt 책의 서론 장. 책의 인용 정보는 다음과 같습니다:
Adam Chlipala. Certified Programming with Dependent Types. URL: http://adam.chlipala.net/cpdt/html/toc.html
또한 MIT Press에서도 볼 수 있습니다.
저작권 emilia-h 2025-2026, CC BY-SA 4.0에 따라 라이선스가 부여됩니다.