F# 함수형 프로그래밍 개념 소개
F# 함수형 프로그래밍 개념 소개
함수형 프로그래밍은 함수와 불변 데이터 사용을 중시하는 프로그래밍 스타일이에요. 타입이 있는 함수형 프로그래밍(typed functional programming)은 함수형 프로그래밍이 F#처럼 정적 타입과 결합된 형태를 말해요. 함수형 프로그래밍에서는 일반적으로 다음 개념을 강조해요.
- 함수를 주된 구성 요소로 사용합니다
- 명령문(statement) 대신 식(expression) 사용
- 변수보다 불변 값(immutable value)
- 명령형 프로그래밍보다 선언형 프로그래밍
이 시리즈 전체에 걸쳐 F#을 이용해 함수형 프로그래밍의 개념과 패턴을 천천히 탐구해 볼 거예요. 그 과정에서 F# 자체도 함께 익히게 되죠.
출처: Introduction to Functional Programming Concepts in F# (Microsoft Learn, F# 공식 문서)
본문
용어(Terminology)
함수형 프로그래밍도 다른 프로그래밍 패러다임과 마찬가지로, 결국 익혀야 할 고유한 용어들이 있어요. 여기 계속 마주치게 될 흔한 용어들을 정리해 볼게요.
- Function(함수) — 함수는 입력이 주어지면 출력을 만들어 내는 구성 요소예요. 더 엄밀히 말하면, 어떤 집합의 원소를 다른 집합의 원소로 매핑(mapping)해 주는 것이죠. 이런 형식적 정의는 특히 데이터 컬렉션을 다루는 함수들에서 여러 방식으로 구체적으로 드러나요. 함수형 프로그래밍에서 가장 기본이면서도 중요한 개념입니다.
- Expression(식) — 식은 코드 안에서 값을 만들어 내는 구성 요소를 말해요. F#에서 이 값은 반드시 바인딩(binding)되거나 명시적으로 무시되어야 해요. 식은 언제든 함수 호출로 간단히 대체할 수 있습니다.
- Purity(순수성) — 순수성은 함수의 속성으로, 같은 인자에 대해 항상 같은 값을 반환하고 평가 과정에서 부작용(side effect)이 없다는 뜻이에요. 순수 함수는 인자에만 온전히 의존합니다.
- Referential Transparency(참조 투명성) — 참조 투명성은 식이 가지는 속성으로, 식을 그 결괏값으로 바꿔도 프로그램의 동작에 아무런 영향을 주지 않는 것을 말해요.
- Immutability(불변성) — 불변성은 값을 제자리에서 변경할 수 없다는 뜻이에요. 제자리에서 바꿀 수 있는 변수(variable)와는 대조적이죠.
Examples(예제)
아래 예제들이 이 핵심 개념들을 하나씩 보여줄 거예요.
Functions(함수)
함수형 프로그래밍에서 가장 흔하면서도 기본이 되는 구성 요소는 함수예요. 정수에 1을 더하는 간단한 함수 하나를 볼게요.
let addOne x = x + 1
이 함수의 타입 시그니처는 다음과 같아요.
val addOne: x:int -> int
이 시그니처는 "addOne은 x라는 int를 받아 int를 만들어 낸다"라고 읽을 수 있어요. 더 정확히는 addOne이 정수 집합의 원소를 정수 집합의 원소로 매핑해 주는 거죠. 여기서 -> 토큰이 바로 그 매핑을 뜻해요. F#에서는 보통 함수 시그니처만 봐도 그 함수가 무슨 일을 하는지 감을 잡을 수 있습니다.
그럼 시그니처가 왜 중요할까요? 타입이 있는 함수형 프로그래밍에서는 함수의 실제 구현보다 타입 시그니처 자체가 더 중요할 때가 많아요. addOne이 정수에 값 1을 더한다는 사실은 실행 시점에 흥미로운 일이지만, 프로그램을 설계할 때는 addOne이 int를 받아서 int를 돌려준다는 사실이 이 함수를 어떻게 활용할지를 결정해 주죠. 게다가 이 함수를 시그니처에 맞게 올바르게 썼다면, 문제가 생겨도 진단할 곳은 addOne 함수의 본문뿐이에요. 이것이 바로 타입이 있는 함수형 프로그래밍을 사용하는 이유입니다.
Expressions(식)
식은 값으로 평가되는 구성 요소예요. 어떤 동작을 수행하는 명령문과 달리, 식은 값을 돌려주는 동작이라고 생각할 수 있어요. 함수형 프로그래밍에서는 명령문 대신 식을 거의 항상 사용합니다.
앞서 본 addOne 함수를 다시 보죠. addOne의 본문이 바로 식이에요.
// 'x + 1' is an expression!
let addOne x = x + 1
addOne 함수의 반환 타입을 정하는 것도 바로 이 식의 결과예요. 예를 들어 이 함수를 이루는 식을 string 같은 다른 타입으로 바꿀 수 있어요.
let addOne x = x.ToString() + "1"
그러면 함수의 시그니처는 이렇게 바뀝니다.
val addOne: x:'a -> string
F#의 모든 타입에 ToString()을 호출할 수 있기 때문에 x의 타입은 제네릭이 되고(이를 자동 일반화(Automatic Generalization)라고 불러요), 결과 타입은 string이 됩니다.
식은 함수의 본문에만 있는 게 아니에요. 다른 데서 쓸 값을 만들어 내는 식도 얼마든지 있죠. 대표적인 게 if예요.
// Checks if 'x' is odd by using the mod operator
let isOdd x = x % 2 <> 0
let addOneIfOdd input =
let result =
if isOdd input then
input + 1
else
input
result
if 식은 result라는 값을 만들어 내요. 여기서 result를 아예 생략하고 addOneIfOdd 함수의 본문 자체를 if 식으로 만들 수도 있다는 점을 기억해 두면 좋아요. 식의 핵심은 언제나 값을 만들어 낸다는 것입니다.
돌려줄 것이 없을 때 쓰는 특별한 타입으로 unit이 있어요. 이 간단한 함수를 볼게요.
let printString (str: string) =
printfn $"String is: {str}"
시그니처는 다음과 같아요.
val printString: str:string -> unit
unit 타입은 실제로 반환되는 값이 없다는 뜻이에요. 그 작업의 결과로 돌려줄 값이 없더라도 "일을 해야 하는" 루틴이 있을 때 유용하죠.
이건 명령형 프로그래밍과는 크게 대조돼요. 명령형에서는 if가 명령문이고, 값을 만들어 내는 일은 종종 가변 변수를 바꿔가며 처리하죠. 예를 들어 C#에서는 이 코드가 이렇게 쓰일 수 있어요.
bool IsOdd(int x) => x % 2 != 0;
int AddOneIfOdd(int input)
{
var result = input;
if (IsOdd(input))
{
result = input + 1;
}
return result;
}
참고로 C#과 다른 C 계열 언어들은 삼항 연산자(conditional operator)를 지원해서 식 기반의 조건 프로그래밍이 가능해요.
함수형 프로그래밍에서는 명령문으로 값을 변경하는 일이 드물어요. 일부 함수형 언어가 명령문과 변경(mutation)을 지원하긴 하지만, 함수형 프로그래밍에서 이 개념들을 쓰는 건 흔치 않은 일입니다.
Pure functions(순수 함수)
앞서 언급했듯 순수 함수는 다음 조건을 만족하는 함수예요.
- 같은 입력에 대해 항상 같은 값으로 평가됩니다.
- 부작용(side effect)이 없습니다.
이 맥락에서는 수학적 함수를 떠올리는 게 도움이 돼요. 수학에서 함수는 인자에만 의존하고 부작용을 갖지 않죠. 수학 함수 f(x) = x + 1에서 f(x)의 값은 x의 값에만 의존해요. 함수형 프로그래밍의 순수 함수도 똑같습니다.
순수 함수를 작성할 때는 함수가 인자에만 의존해야 하고, 부작용을 일으키는 동작을 해서는 안 돼요.
다음은 전역 가변 상태에 의존하기 때문에 순수하지 않은 함수의 예시예요.
let mutable value = 1
let addOneToValue x = x + value
addOneToValue 함수는 분명히 불순(impure)해요. value는 언제든 1이 아닌 다른 값으로 바뀔 수 있기 때문이죠. 이렇게 전역 값에 의존하는 패턴은 함수형 프로그래밍에서 피해야 합니다.
다음은 부작용을 수행하기 때문에 순수하지 않은 또 다른 예시예요.
let addOneToValue x =
printfn $"x is %d{x}"
x + 1
이 함수는 전역 값에 의존하진 않지만 x의 값을 프로그램의 출력으로 내보내요. 이 자체가 잘못된 것은 아니지만, 함수가 순수하지 않다는 뜻이 되죠. 프로그램의 다른 부분이 출력 버퍼 같은 프로그램 외부의 무언가에 의존한다면, 이 함수를 호출하는 것이 프로그램의 그 다른 부분에 영향을 줄 수 있어요.
printfn 문을 제거하면 함수는 순수해집니다.
let addOneToValue x = x + 1
이 함수가 printfn이 있던 이전 버전보다 본질적으로 더 낫다는 뜻은 아니에요. 다만 이 함수가 하는 일은 값을 돌려주는 것뿐이라는 걸 보장해 주죠. 몇 번을 호출해도 결과는 같아요. 그저 값을 만들어 낼 뿐이에요. 순수성이 주는 이런 예측 가능성은 많은 함수형 프로그래머가 추구하는 부분입니다.
Immutability(불변성)
마지막으로, 타입이 있는 함수형 프로그래밍의 가장 기본적인 개념 중 하나가 불변성이에요. F#에서는 모든 값이 기본적으로 불변(immutable)이에요. 즉, 명시적으로 mutable로 표시하지 않는 한 값을 제자리에서 변경할 수 없습니다.
실제로 불변 값을 다룬다는 것은 프로그래밍하는 방식을 "뭔가를 바꿔야 해"에서 "새로운 값을 만들어 내야 해"로 바꾸는 것을 의미해요.
예를 들어 어떤 값에 1을 더하는 것은 기존 값을 변경하는 것이 아니라 새로운 값을 만들어 내는 일이에요.
let value = 1
let secondValue = value + 1
F#에서 다음 코드는 value라는 값을 변경하는 것이 아니라 동등성(equality)을 검사하는 거예요.
let value = 1
value = value + 1 // Produces a 'bool' value!
일부 함수형 프로그래밍 언어는 변경(mutation)을 아예 지원하지 않아요. F#에서는 지원하지만, 값의 기본 동작은 아닙니다.
이 개념은 데이터 구조까지 더 넓게 이어져요. 함수형 프로그래밍에서 set(집합) 같은 불변 데이터 구조는 처음 기대하는 것과는 다른 방식으로 구현되는 경우가 많아요. 개념적으로, set에 항목을 추가한다는 것은 set을 바꾸는 것이 아니라 항목이 추가된 새 set을 만들어 내는 거죠. 그 내부에서는 종종 값 하나를 효율적으로 추적할 수 있는 다른 데이터 구조를 이용해, 데이터의 적절한 표현을 결과로 돌려주는 방식으로 처리됩니다.
값과 데이터 구조를 이렇게 다루는 스타일은 중요해요. 어떤 것을 수정하는 연산도 그것의 새 버전을 만드는 것으로 취급하도록 강제하기 때문이죠. 그래야 프로그램 안에서 동등성과 비교 가능성(comparability)이 일관되게 유지됩니다.
더 알아보기
다음 절에서는 함수를 본격적으로 다루며, 함수형 프로그래밍에서 함수를 활용하는 다양한 방법을 살펴볼 거예요.
- F#에서 함수 사용하기 — 함수를 각종 맥락에서 어떻게 활용하는지 심층적으로 다룹니다.
- Thinking Functionally 시리즈 — F#으로 함수형 프로그래밍을 배우기에 훌륭한 또 다른 자료예요. 실용적이고 읽기 쉬운 방식으로 함수형 프로그래밍의 기초를 다루며, 개념을 설명할 때 F#의 기능을 사용해요.