콘텐츠로 이동

16장. 함수형 프로그래밍 개념

함수형 프로그래밍(Functional Programming, FP)은 계산을 수학적 함수의 평가로 다루는 프로그래밍 패러다임으로, 컴퓨터 과학의 가장 오래되고 강력한 프로그래밍 접근 방식 중 하나입니다. 1930년대 수학자 알론조 처치(Alonzo Church)가 개발한 **람다 계산법(Lambda Calculus)**에 이론적 기반을 두고 있으며, 1958년 존 매카시(John McCarthy)가 개발한 Lisp를 통해 처음으로 프로그래밍 언어로 구현되었습니다. C#은 본래 객체지향 언어로 설계되었지만, C# 3.0(2007년)에서 람다 식과 LINQ가 도입된 이후 함수형 프로그래밍의 핵심 개념들을 지원하여 **다중 패러다임 언어(Multi-paradigm Language)**로 진화했습니다.

함수형 프로그래밍의 역사적 맥락:

함수형 프로그래밍의 역사는 수학적 논리학과 깊이 연결되어 있습니다. 1936년 알론조 처치의 람다 계산법과 앨런 튜링(Alan Turing)의 튜링 머신이 독립적으로 개발되었고, 이 둘이 계산적으로 동등하다는 것이 증명되면서(처치-튜링 명제, Church-Turing Thesis) 계산 가능성(Computability)의 이론적 기반이 마련되었습니다. 함수형 프로그래밍은 이러한 수학적 토대 위에서 발전했으며, 명령형 프로그래밍(Imperative Programming)과는 근본적으로 다른 사고방식을 제공합니다.

1950년대와 1960년대에는 Lisp, Scheme 같은 순수 함수형 언어들이 등장했고, 1970년대에는 ML(Meta Language)과 그 후속 언어들이 개발되었습니다. 1980년대에 등장한 Haskell은 순수 함수형 프로그래밍의 집대성으로 평가받으며, 오늘날에도 함수형 프로그래밍 연구의 중심에 있습니다. 1990년대 후반부터는 Scala, F#, Clojure 같은 다중 패러다임 언어들이 등장하면서 함수형 프로그래밍이 주류 소프트웨어 개발에 더 쉽게 접근할 수 있게 되었습니다.

함수형 프로그래밍의 핵심 원칙:

함수형 프로그래밍은 다음과 같은 수학적 원칙들을 프로그래밍에 적용합니다:

  1. 불변성(Immutability): 데이터를 변경하지 않고 새로운 데이터를 생성합니다. 이는 **값 의미론(Value Semantics)**을 구현하며, 변수가 아닌 **값(Values)**을 다룹니다.

  2. 순수 함수(Pure Functions): 부작용(Side Effects)이 없고 같은 입력에 항상 같은 출력을 반환합니다. 이는 수학의 함수 개념과 일치하며, **참조 투명성(Referential Transparency)**을 보장합니다.

  3. 일급 함수(First-class Functions): 함수를 값처럼 다루고 전달할 수 있습니다. 함수를 변수에 할당하거나, 다른 함수의 인자로 전달하거나, 함수에서 반환할 수 있습니다.

  4. 고차 함수(Higher-order Functions): 함수를 인자로 받거나 함수를 반환하는 함수입니다. Map, Filter, Reduce 같은 고차 함수는 함수형 프로그래밍의 기본 추상화 도구입니다.

  5. 함수 합성(Function Composition): 작은 함수들을 조합하여 복잡한 함수를 만듭니다. 수학의 함수 합성 (f ∘ g)(x) = f(g(x))과 동일한 개념입니다.

  6. 재귀(Recursion): 반복문 대신 재귀를 사용하여 반복 작업을 수행합니다. 이는 수학적 귀납법(Mathematical Induction)의 프로그래밍적 구현입니다.

함수형 프로그래밍이 중요한 이유:

현대 소프트웨어 개발에서 함수형 프로그래밍이 점점 더 중요해지는 이유는 다음과 같습니다:

  1. 동시성과 병렬 처리: 불변 데이터와 순수 함수는 스레드 안전성을 자연스럽게 보장하여, 멀티코어 시대의 병렬 프로그래밍을 안전하게 만듭니다.

  2. 테스트 가능성: 순수 함수는 외부 상태에 의존하지 않으므로, 단위 테스트가 매우 쉽고 신뢰할 수 있습니다.

  3. 코드 추론: 부작용이 없고 불변 데이터를 사용하면, 코드의 동작을 추론하고 증명하기가 훨씬 쉬워집니다. 이는 **형식 검증(Formal Verification)**의 기반이 됩니다.

  4. 모듈성과 재사용성: 순수 함수는 독립적이고 합성 가능하여, 코드의 모듈성과 재사용성이 높아집니다.

  5. 버그 감소: 상태 변경과 부작용이 최소화되면, 많은 종류의 버그(경쟁 조건, 상태 불일치 등)가 근본적으로 방지됩니다.

C#의 함수형 프로그래밍 지원:

C#은 순수 함수형 언어는 아니지만, 버전이 올라가면서 함수형 프로그래밍 기능이 꾸준히 강화되었습니다:

  • C# 3.0 (2007): 람다 식, LINQ, 표현식 트리, 익명 타입
  • C# 4.0 (2010): 공변성(Covariance)과 반공변성(Contravariance)
  • C# 5.0 (2012): async/await (함수형 스타일의 비동기 처리)
  • C# 6.0 (2015): 표현식 본문 멤버, null 조건부 연산자
  • C# 7.0 (2017): 패턴 매칭, 튜플, 로컬 함수
  • C# 8.0 (2019): Nullable 참조 타입, 개선된 패턴 매칭
  • C# 9.0 (2020): record 타입 (불변 객체 지원)
  • C# 10.0 (2021): record struct, 개선된 람다 식

이러한 기능들은 C#에서 함수형 프로그래밍 스타일을 점점 더 자연스럽게 사용할 수 있게 만들었습니다.

이 장에서 배울 내용

이 장을 통해 독자 여러분은 함수형 프로그래밍의 핵심 개념들을 C#으로 구현하고 활용하는 방법을 체계적으로 학습하게 됩니다:

  • 불변성(Immutability)의 이해와 실천: 데이터를 변경하지 않는 프로그래밍 방식의 이론적 기반과 실무적 가치를 이해합니다. C# 9.0의 record 타입, with 식, 불변 컬렉션 패턴을 통해 불변 객체를 효과적으로 구현하는 방법을 배웁니다. 영속 자료 구조(Persistent Data Structures)의 개념과 구조적 공유(Structural Sharing)를 통한 성능 최적화 기법을 익힙니다.

  • 순수 함수(Pure Functions) 작성과 활용: 참조 투명성(Referential Transparency)과 부작용 제거의 수학적 의미를 이해하고, 순수 함수가 테스트 가능성과 코드 추론에 미치는 영향을 학습합니다. 비순수 함수를 순수 함수로 리팩토링하는 패턴과, 부작용을 경계로 밀어내는(Pushing Effects to the Edges) 아키텍처 패턴을 배웁니다.

  • 고차 함수(Higher-Order Functions)와 함수 합성: 함수를 일급 객체로 다루는 방법과 고차 함수를 통한 추상화 기법을 익힙니다. LINQ의 내부 동작 원리를 이해하고, Map-Filter-Reduce 패턴의 이론적 배경을 학습합니다. 함수 합성(Function Composition)과 함수 파이프라인(Function Pipeline)을 통해 복잡한 데이터 변환 로직을 우아하게 표현하는 방법을 배웁니다.

  • 커링(Currying)과 부분 적용(Partial Application): 여러 인자를 받는 함수를 단일 인자 함수의 체인으로 변환하는 커링의 수학적 기반(Haskell Curry의 작업)을 이해합니다. 부분 적용을 통한 함수 특화(Function Specialization)와 의존성 주입(Dependency Injection) 패턴을 학습하며, 클로저(Closure)와의 관계를 파악합니다.

학습 목표:

  • 함수형 프로그래밍의 수학적, 이론적 기반 이해
  • 불변성을 통한 스레드 안전한 코드 작성
  • 순수 함수를 활용한 테스트 가능하고 예측 가능한 코드 개발
  • 고차 함수와 함수 합성을 통한 선언적 프로그래밍 스타일 습득
  • 실무에서 객체지향과 함수형 패러다임을 효과적으로 혼용하는 방법 체득

16.1 불변성 (Immutability)

불변성은 함수형 프로그래밍의 가장 핵심적이고 근본적인 개념입니다. 불변(Immutable) 객체는 일단 생성된 후에는 그 상태를 변경할 수 없는 객체를 말하며, 이는 수학에서 값(Value)이 변하지 않는 것과 같은 개념입니다. 데이터를 수정하는 대신, 수정된 내용을 반영한 새로운 복사본을 만들어 반환합니다.

불변성의 철학적, 수학적 기반:

불변성의 개념은 수학의 기본 원리에서 비롯됩니다. 수학에서 숫자 3은 항상 3이며, 절대로 4로 변하지 않습니다. 표현식 x = 5가 있을 때, 이는 "x에 5를 대입한다"가 아니라 "x는 5와 같다"는 등식(Equation)**을 의미합니다. 함수형 프로그래밍은 이러한 수학적 불변성을 프로그래밍에 도입하여, 변수(Variable)가 아닌 **상수(Constant) 또는 **바인딩(Binding)**의 개념을 사용합니다.

명령형 프로그래밍(Imperative Programming)에서는 **명령(Command)**의 연속으로 프로그램을 표현하며, 각 명령은 프로그램의 상태를 변경합니다. 이는 튜링 머신(Turing Machine)의 테이프를 수정하는 것과 유사합니다. 반면 함수형 프로그래밍에서는 **표현식(Expression)**의 평가로 프로그램을 표현하며, 각 표현식은 값을 생성하지만 기존 값을 변경하지 않습니다. 이는 람다 계산법(Lambda Calculus)의 항(Term) 축약과 유사합니다.

불변성이 해결하는 문제들:

가변 상태(Mutable State)는 소프트웨어의 복잡성을 증가시키는 주요 원인 중 하나입니다. 1990년 Out of the Tar Pit이라는 영향력 있는 논문에서 Ben Moseley와 Peter Marks는 "가변 상태는 복잡성의 주요 원인"이라고 지적했습니다:

  1. 시간적 결합(Temporal Coupling): 가변 상태를 사용하면 코드의 실행 순서가 중요해집니다. A 함수 다음에 B 함수를 호출해야 하는 식의 순서 의존성이 생깁니다.

  2. 숨겨진 의존성(Hidden Dependencies): 전역 상태나 공유 상태는 함수 간의 암묵적 의존성을 만들어, 코드의 동작을 예측하기 어렵게 만듭니다.

  3. 경쟁 조건(Race Conditions): 멀티스레드 환경에서 공유 가변 상태는 데이터 경쟁과 동기화 문제를 일으킵니다.

  4. 테스트 어려움: 가변 상태를 가진 코드는 테스트 시 상태 설정과 정리(Setup/Teardown)가 복잡해집니다.

  5. 추론의 어려움: 코드의 한 부분이 다른 부분의 상태를 변경할 수 있으면, 프로그램의 동작을 추론하기가 매우 어렵습니다.

불변성의 장점:

  1. 참조 투명성(Referential Transparency): 표현식을 그 값으로 바꿔도 프로그램의 의미가 변하지 않습니다. 이는 대수학적 추론(Algebraic Reasoning)을 가능하게 합니다.

  2. 스레드 안전성(Thread Safety): 불변 객체는 본질적으로 스레드 안전합니다. 여러 스레드가 동시에 읽어도 동기화가 필요 없으며, 데이터 경쟁이 발생하지 않습니다.

  3. 예측 가능성(Predictability): 객체의 상태가 변하지 않으므로, 함수 호출 전후의 상태가 같다는 것을 보장할 수 있습니다.

  4. 캐싱과 메모이제이션(Caching and Memoization): 불변 객체는 안전하게 캐시할 수 있고, 함수 결과를 메모이제이션할 수 있습니다.

  5. 시간 여행 디버깅(Time-Travel Debugging): 이전 상태를 쉽게 보관하고 되돌릴 수 있어, Redux 같은 상태 관리 라이브러리의 기반이 됩니다.

  6. 영속성(Persistence): 불변 데이터 구조는 이전 버전을 유지하면서 새로운 버전을 만들 수 있습니다. 이는 버전 관리 시스템의 원리와 유사합니다.

불변성의 성능 고려사항:

불변성이 항상 복사를 의미하는 것은 아닙니다. **영속 자료 구조(Persistent Data Structures)**는 구조적 공유(Structural Sharing)를 사용하여 효율성을 달성합니다:

  • 구조적 공유: 변경되지 않은 부분은 새 버전과 이전 버전이 공유합니다. Git의 커밋 히스토리와 유사한 개념입니다.
  • Copy-on-Write (COW): 실제로 변경이 필요할 때만 복사를 수행합니다.
  • Path Copying: 트리 구조에서 루트에서 변경 지점까지의 경로만 복사합니다.

이러한 기법들은 O(n) 복사를 O(log n) 또는 O(1) 연산으로 줄입니다.

가변 vs 불변 비교

// 가변(Mutable) 방식 - 상태가 변경됨
class MutablePerson
{
    public string Name { get; set; }
    public int Age { get; set; }
}

var person = new MutablePerson { Name = "홍길동", Age = 25 };
person.Age = 26;  // 원본 객체가 수정됨
Console.WriteLine($"나이: {person.Age}");
// 출력: 나이: 26

// 불변(Immutable) 방식 - 새로운 객체 생성
record ImmutablePerson(string Name, int Age);

var person2 = new ImmutablePerson("김철수", 25);
var olderPerson = person2 with { Age = 26 };  // 새로운 객체 생성
Console.WriteLine($"원본 나이: {person2.Age}");
Console.WriteLine($"새 나이: {olderPerson.Age}");
// 출력:
// 원본 나이: 25
// 새 나이: 26

C#의 불변 타입

C#에는 기본적으로 불변인 타입들이 있습니다:

// string은 불변 타입
string text = "Hello";
string upper = text.ToUpper();  // 새로운 문자열 생성
Console.WriteLine($"원본: {text}");
Console.WriteLine($"대문자: {upper}");
// 출력:
// 원본: Hello
// 대문자: HELLO

// DateTime도 불변 타입
DateTime date = DateTime.Now;
DateTime nextDay = date.AddDays(1);  // 새로운 DateTime 생성
Console.WriteLine($"오늘: {date:yyyy-MM-dd}");
Console.WriteLine($"내일: {nextDay:yyyy-MM-dd}");

record를 사용한 불변 객체

C# 9.0에서 도입된 record 키워드는 불변 객체를 쉽게 만들 수 있게 해줍니다:

// 불변 레코드 정의
record Point(int X, int Y);

var p1 = new Point(10, 20);

// with 식을 사용한 복사 및 수정
var p2 = p1 with { X = 30 };

Console.WriteLine($"p1: ({p1.X}, {p1.Y})");
Console.WriteLine($"p2: ({p2.X}, {p2.Y})");
// 출력:
// p1: (10, 20)
// p2: (30, 20)

불변 컬렉션

불변 컬렉션은 생성 후 내용을 변경할 수 없는 컬렉션입니다:

// 일반 리스트 (가변)
List<int> mutableList = new List<int> { 1, 2, 3 };
mutableList.Add(4);  // 원본이 수정됨

// 불변 리스트 패턴
List<int> numbers = new List<int> { 1, 2, 3 };

// 새로운 요소를 추가한 새 리스트 생성
List<int> newNumbers = new List<int>(numbers) { 4 };

Console.WriteLine($"원본: {string.Join(", ", numbers)}");
Console.WriteLine($"새 리스트: {string.Join(", ", newNumbers)}");
// 출력:
// 원본: 1, 2, 3
// 새 리스트: 1, 2, 3, 4

LINQ와 불변성

LINQ 메서드들은 원본 컬렉션을 변경하지 않고 새로운 컬렉션을 반환합니다:

List<int> numbers = new List<int> { 1, 2, 3, 4, 5 };

// Where는 원본을 변경하지 않음
var evenNumbers = numbers.Where(n => n % 2 == 0).ToList();

Console.WriteLine($"원본: {string.Join(", ", numbers)}");
Console.WriteLine($"짝수: {string.Join(", ", evenNumbers)}");
// 출력:
// 원본: 1, 2, 3, 4, 5
// 짝수: 2, 4

System.Collections.Immutable 라이브러리

.NET은 System.Collections.Immutable NuGet 패키지를 통해 효율적인 불변 컬렉션을 제공합니다. 이러한 컬렉션들은 영속 자료 구조(Persistent Data Structures)로 구현되어 구조적 공유를 통해 효율성을 달성합니다:

// NuGet 설치: dotnet add package System.Collections.Immutable
using System.Collections.Immutable;

// ImmutableList - 영속적 리스트
var list1 = ImmutableList<int>.Empty;
var list2 = list1.Add(1);  // O(log n), 구조적 공유
var list3 = list2.Add(2);
var list4 = list3.Add(3);

// list1, list2, list3는 모두 유효하며 변경되지 않음
Console.WriteLine($"list1: {list1.Count} 항목");  // 0
Console.WriteLine($"list2: {list2.Count} 항목");  // 1
Console.WriteLine($"list4: {list4.Count} 항목");  // 3

// ImmutableDictionary - 영속적 딕셔너리
var dict1 = ImmutableDictionary<string, int>.Empty;
var dict2 = dict1.Add("apple", 1);
var dict3 = dict2.Add("banana", 2).Add("orange", 3);

// 원본은 변경되지 않음
Console.WriteLine($"dict1 항목 수: {dict1.Count}");  // 0
Console.WriteLine($"dict3 항목 수: {dict3.Count}");  // 3

영속 자료 구조의 작동 원리:

ImmutableList<T>는 내부적으로 AVL 트리(Adelson-Velsky and Landis Tree) 를 사용합니다. 요소를 추가하거나 제거할 때, 전체 트리를 복사하는 대신 영향을 받는 경로만 복사합니다 (Path Copying):

Original Tree:      New Tree (after adding 4):
      3                    3
     / \                  / \
    1   5        ->     1'  5
   / \                 / \
  0   2              0   2'
                          \
                           4

노드 1'과 2'은 새로 생성되고, 
노드 0, 5는 두 트리에서 공유됩니다.

이 방식으로 O(n) 복사를 O(log n) 연산으로 줄이며, 메모리도 절약됩니다. 이는 Chris Okasaki의 "Purely Functional Data Structures"(1998)에서 체계화된 기법입니다.

실무 적용 시나리오:

불변 컬렉션은 다음 상황에서 특히 유용합니다:

  1. 멀티스레딩: 스레드 간 데이터 공유 시 락 없이 안전하게 사용
  2. 이벤트 소싱: 상태의 각 버전을 유지하면서 새 이벤트 적용
  3. 실행 취소/재실행: 이전 상태를 저장하여 되돌리기 기능 구현
  4. 캐싱: 불변 데이터는 안전하게 캐시 가능
  5. 스냅샷: 특정 시점의 상태를 복사 없이 저장

실용 예제 - 불변 계좌

record BankAccount(string Owner, decimal Balance)
{
    // 입금 - 새로운 계좌 객체 반환
    public BankAccount Deposit(decimal amount)
    {
        if (amount <= 0)
            throw new ArgumentException("입금액은 양수여야 합니다.");
        return this with { Balance = Balance + amount };
    }

    // 출금 - 새로운 계좌 객체 반환
    public BankAccount Withdraw(decimal amount)
    {
        if (amount <= 0)
            throw new ArgumentException("출금액은 양수여야 합니다.");
        if (Balance < amount)
            throw new InvalidOperationException("잔액이 부족합니다.");
        return this with { Balance = Balance - amount };
    }
}

// 사용 예제
var account = new BankAccount("홍길동", 1000);
Console.WriteLine($"초기 잔액: {account.Balance}원");

var account2 = account.Deposit(500);
Console.WriteLine($"입금 후: {account2.Balance}원");

var account3 = account2.Withdraw(300);
Console.WriteLine($"출금 후: {account3.Balance}원");

Console.WriteLine($"원본 계좌: {account.Balance}원");
// 출력:
// 초기 잔액: 1000원
// 입금 후: 1500원
// 출금 후: 1200원
// 원본 계좌: 1000원

16.2 순수 함수 (Pure Functions)

순수 함수(Pure Functions)는 함수형 프로그래밍의 두 번째 핵심 개념으로, 수학의 함수 개념을 프로그래밍에 직접적으로 적용한 것입니다. 순수 함수는 다음 두 가지 근본적인 특성을 만족해야 합니다:

  1. 참조 투명성(Referential Transparency): 같은 입력에 대해 항상 같은 출력을 반환합니다. 함수 호출을 그 반환값으로 대체해도 프로그램의 동작이 변하지 않습니다.

  2. 부작용 없음(No Side Effects): 함수 외부의 상태를 변경하거나 관찰 가능한 상호작용을 하지 않습니다. 전역 변수를 수정하거나, 파일에 쓰거나, 네트워크 호출을 하는 등의 부작용이 없습니다.

순수 함수의 수학적 기반:

순수 함수는 수학의 함수 개념과 정확히 일치합니다. 수학에서 f(x) = x²이라는 함수는 입력 x에 대해 항상 x의 제곱을 반환하며, 세상의 어떤 것도 변경하지 않습니다. 마찬가지로 f(3) = 9는 항상 참이며, f(3)을 호출하는 것과 9를 사용하는 것은 완전히 동등합니다. 이것이 바로 **참조 투명성(Referential Transparency)**입니다.

참조 투명성은 1967년 Christopher Strachey가 처음 정의한 개념으로, 표현식을 그 값으로 대체해도 프로그램의 의미가 변하지 않는 속성을 말합니다. 이는 대수학(Algebra)에서 등식의 양변을 대체할 수 있는 것과 같은 원리입니다:

만약 x = 5이고 y = x + 3이면
y = x + 3 = 5 + 3 = 8

이러한 대수학적 추론(Algebraic Reasoning)이 프로그래밍에서도 가능하게 만드는 것이 순수 함수의 핵심 가치입니다.

부작용(Side Effects)의 정의와 종류:

부작용은 함수가 반환값 이외에 프로그램의 외부 상태에 영향을 미치거나, 외부 상태에 의존하는 모든 동작을 의미합니다. 다음은 부작용의 예입니다:

  1. 상태 변경:
  2. 전역 변수나 정적 변수 수정
  3. 인자로 받은 가변 객체의 필드 변경
  4. 클래스의 인스턴스 필드 변경

  5. I/O 작업:

  6. 파일 읽기/쓰기
  7. 네트워크 통신
  8. 데이터베이스 쿼리/수정
  9. 콘솔 입출력

  10. 비결정적 동작:

  11. 현재 시간 읽기 (DateTime.Now)
  12. 난수 생성 (Random.Next())
  13. 외부 서비스 호출

  14. 예외 발생:

  15. 예외를 던지는 것도 일종의 부작용으로 볼 수 있음 (제어 흐름 변경)

순수 함수의 이점:

  1. 테스트 용이성(Testability): 순수 함수는 외부 상태에 의존하지 않으므로, 목(Mock)이나 스텁(Stub) 없이 직접 테스트할 수 있습니다. 입력과 출력만 검증하면 됩니다.

  2. 병렬화 가능성(Parallelizability): 순수 함수는 서로 독립적이므로, 안전하게 병렬로 실행할 수 있습니다. 데이터 경쟁이나 동기화 문제가 없습니다.

  3. 메모이제이션(Memoization): 같은 입력에 대해 항상 같은 결과를 반환하므로, 결과를 캐시하여 재사용할 수 있습니다. 동적 프로그래밍(Dynamic Programming)의 기반입니다.

  4. 추론과 증명(Reasoning and Verification): 순수 함수는 수학적으로 분석하고 증명하기 쉽습니다. 형식 검증(Formal Verification)의 대상이 됩니다.

  5. 리팩토링 안전성: 순수 함수는 독립적이므로, 다른 코드에 영향을 주지 않고 안전하게 수정할 수 있습니다.

  6. 조합 가능성(Composability): 순수 함수는 레고 블록처럼 조합할 수 있습니다. 두 순수 함수를 합성하면 또 다른 순수 함수가 됩니다.

참조 투명성의 수학적 의미:

참조 투명성은 다음과 같은 등식 추론(Equational Reasoning)을 가능하게 합니다:

// 순수 함수
int add(int a, int b) => a + b;
int square(int x) => x * x;

// 다음 두 표현식은 동등합니다:
int result1 = square(add(2, 3));
int result2 = square(5);  // add(2, 3)을 5로 대체

// 따라서:
result1 == result2  // 항상 true

이러한 대체 가능성은 컴파일러 최적화의 기반이 되며, 개발자가 코드를 이해하고 추론하는 데 큰 도움을 줍니다. 2. 부작용 없음(No Side Effects): 함수 외부의 상태를 변경하지 않음

순수 함수는 테스트하기 쉽고, 이해하기 쉬우며, 재사용하기 좋습니다.

순수 함수 vs 비순수 함수

// ❌ 비순수 함수 - 외부 상태에 의존
int externalValue = 10;
int ImpureAdd(int x)
{
    return x + externalValue;  // 외부 변수에 의존
}

// ❌ 비순수 함수 - 부작용이 있음
int counter = 0;
int ImpureIncrement()
{
    counter++;  // 외부 상태를 변경
    return counter;
}

// ❌ 비순수 함수 - 랜덤값 반환
int ImpureRandom()
{
    return new Random().Next();  // 매번 다른 값 반환
}

// ✅ 순수 함수 - 입력만 사용, 부작용 없음
int PureAdd(int x, int y)
{
    return x + y;
}

// 테스트
Console.WriteLine($"5 + 3 = {PureAdd(5, 3)}");
Console.WriteLine($"5 + 3 = {PureAdd(5, 3)}");
// 출력: (항상 같은 결과)
// 5 + 3 = 8
// 5 + 3 = 8

순수 함수의 예제

// ✅ 순수 함수 - 문자열 변환
string ToUpperCase(string text)
{
    return text.ToUpper();
}

// ✅ 순수 함수 - 리스트 필터링
List<int> FilterEven(List<int> numbers)
{
    return numbers.Where(n => n % 2 == 0).ToList();
}

// ✅ 순수 함수 - 계산
double CalculateArea(double radius)
{
    return Math.PI * radius * radius;
}

// 사용 예제
Console.WriteLine(ToUpperCase("hello"));
Console.WriteLine(string.Join(", ", FilterEven(new List<int> { 1, 2, 3, 4, 5 })));
Console.WriteLine($"원의 넓이: {CalculateArea(5):F2}");
// 출력:
// HELLO
// 2, 4
// 원의 넓이: 78.54

부작용의 종류

함수형 프로그래밍에서 피해야 할 부작용들:

// ❌ 콘솔 출력 (I/O는 부작용)
void LogMessage(string message)
{
    Console.WriteLine(message);  // 외부 세계와 상호작용
}

// ❌ 파일 쓰기 (I/O는 부작용)
void SaveToFile(string data)
{
    File.WriteAllText("data.txt", data);  // 파일 시스템 변경
}

// ❌ 전역 변수 수정
int globalCounter = 0;
void IncrementGlobal()
{
    globalCounter++;  // 전역 상태 변경
}

// ❌ 인자로 받은 객체 수정
void ModifyList(List<int> list)
{
    list.Add(100);  // 외부 리스트 수정
}

순수 함수로 리팩토링

비순수 함수를 순수 함수로 변환하는 방법:

// ❌ 비순수 함수
class Calculator
{
    private int total = 0;

    public void Add(int value)
    {
        total += value;  // 상태 변경
    }

    public int GetTotal()
    {
        return total;
    }
}

// ✅ 순수 함수 스타일
record CalculatorState(int Total)
{
    public CalculatorState Add(int value)
    {
        return new CalculatorState(Total + value);
    }
}

// 사용 예제
var calc = new CalculatorState(0);
var calc2 = calc.Add(10);
var calc3 = calc2.Add(20);

Console.WriteLine($"초기: {calc.Total}");
Console.WriteLine($"10 추가: {calc2.Total}");
Console.WriteLine($"20 추가: {calc3.Total}");
// 출력:
// 초기: 0
// 10 추가: 10
// 20 추가: 30

순수 함수를 사용한 데이터 변환

record Student(string Name, int Score);

// 순수 함수들
bool IsPass(Student student) => student.Score >= 60;
string GetGrade(int score) =>
    score >= 90 ? "A" :
    score >= 80 ? "B" :
    score >= 70 ? "C" :
    score >= 60 ? "D" : "F";

// 데이터 처리
var students = new List<Student>
{
    new("홍길동", 85),
    new("김철수", 92),
    new("이영희", 78),
    new("박민수", 55)
};

// 순수 함수를 조합하여 데이터 처리
var passedStudents = students
    .Where(IsPass)
    .Select(s => new { s.Name, Grade = GetGrade(s.Score) })
    .ToList();

Console.WriteLine("합격 학생:");
foreach (var student in passedStudents)
{
    Console.WriteLine($"- {student.Name}: {student.Grade}");
}
// 출력:
// 합격 학생:
// - 홍길동: B
// - 김철수: A
// - 이영희: C

부작용 다루기: 경계로 밀어내기

실무 프로그램에서 부작용을 완전히 제거할 수는 없습니다. 파일 I/O, 데이터베이스 접근, 네트워크 통신은 필수적입니다. 핵심은 부작용을 프로그램의 경계로 밀어내어(Pushing Side Effects to the Edges), 핵심 비즈니스 로직을 순수하게 유지하는 것입니다.

Functional Core, Imperative Shell 패턴:

Gary Bernhardt가 제안한 이 패턴은 순수한 핵심(Core)과 불순한 껍질(Shell)로 프로그램을 구조화합니다:

// ✅ 순수한 핵심 로직
record OrderResult(decimal Total, decimal Tax, decimal GrandTotal);

// 순수 함수: 계산만 수행
OrderResult CalculateOrder(List<decimal> prices, decimal taxRate)
{
    decimal total = prices.Sum();
    decimal tax = total * taxRate;
    decimal grandTotal = total + tax;
    return new OrderResult(total, tax, grandTotal);
}

// ✅ 불순한 껍질: I/O 처리
void ProcessOrder()  // 이 함수만 부작용이 있음
{
    // 입력 (부작용)
    var prices = ReadPricesFromDatabase();
    var taxRate = GetTaxRateForRegion();

    // 순수 계산 (부작용 없음 - 테스트 가능!)
    var result = CalculateOrder(prices, taxRate);

    // 출력 (부작용)
    SaveToDatabase(result);
    SendEmailConfirmation(result);
    Console.WriteLine($"주문 완료: {result.GrandTotal:C}");
}

이 패턴의 장점:

  1. 테스트 용이성: CalculateOrder는 데이터베이스나 네트워크 없이 순수하게 테스트 가능
  2. 추론 가능성: 핵심 로직의 동작을 쉽게 이해하고 증명 가능
  3. 재사용성: 순수 함수는 다른 컨텍스트에서 재사용 가능
  4. 병렬화: 순수 계산 부분은 안전하게 병렬 처리 가능

의존성 역전과 순수 함수:

의존성 주입(Dependency Injection)을 사용하면 부작용을 주입 가능한 의존성으로 만들 수 있습니다:

// 인터페이스로 부작용 추상화
interface ILogger
{
    void Log(string message);
}

// 순수 함수: 로그 메시지를 '생성'만 함
List<string> ProcessData(List<int> numbers)
{
    var logs = new List<string>();

    foreach (var num in numbers)
    {
        if (num < 0)
            logs.Add($"경고: 음수 발견 {num}");
        if (num > 100)
            logs.Add($"경고: 큰 수 발견 {num}");
    }

    return logs;  // 부작용 없이 로그 메시지만 반환
}

// 껍질에서 실제 로깅 수행 (부작용)
void ProcessWithLogging(List<int> numbers, ILogger logger)
{
    var logs = ProcessData(numbers);  // 순수 함수 호출

    foreach (var log in logs)
        logger.Log(log);  // 여기서만 부작용 발생
}

이제 ProcessData는 순수 함수가 되어 쉽게 테스트할 수 있습니다:

// 테스트 - 목(Mock) 불필요!
var logs = ProcessData(new List<int> { -5, 50, 150 });
Assert.Equal(2, logs.Count);
Assert.Contains("음수", logs[0]);
Assert.Contains("큰 수", logs[1]);

Result 타입을 사용한 오류 처리:

예외를 던지는 것도 일종의 부작용입니다. 순수 함수에서는 Result 타입을 사용하여 성공/실패를 값으로 표현합니다:

// Result 타입 정의
record Result<T>
{
    public bool IsSuccess { get; init; }
    public T? Value { get; init; }
    public string? Error { get; init; }

    public static Result<T> Success(T value) => 
        new() { IsSuccess = true, Value = value };

    public static Result<T> Failure(string error) => 
        new() { IsSuccess = false, Error = error };
}

// 순수 함수: 예외를 던지지 않고 Result 반환
Result<decimal> Divide(decimal a, decimal b)
{
    if (b == 0)
        return Result<decimal>.Failure("0으로 나눌 수 없습니다");

    return Result<decimal>.Success(a / b);
}

// 사용
var result = Divide(10, 2);
if (result.IsSuccess)
    Console.WriteLine($"결과: {result.Value}");
else
    Console.WriteLine($"오류: {result.Error}");

이 방식은 Rust의 Result<T, E>, Haskell의 Either, Scala의 Try와 같은 개념입니다. 오류를 타입 시스템으로 추적할 수 있어, 예외를 놓칠 가능성이 줄어듭니다.


16.3 고차 함수 (Higher-Order Functions)

고차 함수(Higher-Order Functions)는 함수형 프로그래밍의 가장 강력한 추상화 도구 중 하나로, 다음 중 하나 이상의 특성을 가지는 함수입니다:

  1. 함수를 인자로 받음: 함수를 매개변수로 전달받아 호출할 수 있습니다.
  2. 함수를 반환값으로 돌려줌: 함수를 생성하여 반환할 수 있습니다.

고차 함수의 개념은 수학의 **함수 해석학(Functional Analysis)**과 **범주론(Category Theory)**에서 유래했으며, 1960년대 Christopher Strachey가 프로그래밍 언어 의미론에 도입했습니다. 고차 함수를 사용하면 알고리즘의 "뼈대"와 "구체적인 동작"을 분리할 수 있어, 코드의 재사용성과 추상화 수준이 극적으로 향상됩니다.

고차 함수의 수학적 기반:

수학에서 함수는 집합 간의 대응 관계입니다. 예를 들어 f: ℝ → ℝ는 실수에서 실수로의 함수입니다. 고차 함수는 함수 공간(Function Space) 사이의 대응 관계로 볼 수 있습니다:

map: (A → B) × List<A> → List<B>
filter: (A → Bool) × List<A> → List<A>
reduce: (B × A → B) × B × List<A> → B

이러한 고차 함수들은 함수를 "일급 값(First-class Values)"으로 다룰 수 있을 때만 가능합니다.

고차 함수가 해결하는 문제:

전통적인 프로그래밍에서는 비슷한 패턴의 코드를 반복해서 작성해야 했습니다:

// ❌ 반복적인 코드 (고차 함수 없이)
List<int> doubled = new List<int>();
foreach (var x in numbers) doubled.Add(x * 2);

List<int> squared = new List<int>();
foreach (var x in numbers) squared.Add(x * x);

List<int> incremented = new List<int>();
foreach (var x in numbers) incremented.Add(x + 1);

고차 함수를 사용하면 공통 패턴을 추상화할 수 있습니다:

// ✅ 고차 함수를 사용한 추상화
var doubled = numbers.Select(x => x * 2);
var squared = numbers.Select(x => x * x);
var incremented = numbers.Select(x => x + 1);

고차 함수의 이점:

  1. 코드 재사용: 알고리즘의 구조를 재사용하면서 세부 동작만 변경할 수 있습니다.

  2. 관심사의 분리(Separation of Concerns): "어떻게 순회하는가"와 "무엇을 하는가"를 분리합니다.

  3. 선언적 프로그래밍: "어떻게(How)" 대신 "무엇을(What)"을 명시합니다.

  4. 합성 가능성: 고차 함수들을 체이닝하여 복잡한 변환 파이프라인을 만들 수 있습니다.

  5. 테스트 용이성: 전달되는 함수만 테스트하면 됩니다.

Map, Filter, Reduce 패턴:

함수형 프로그래밍의 가장 기본적이고 강력한 세 가지 고차 함수 패턴입니다:

  • Map (C#의 Select): 각 요소를 변환합니다.

    map(f, [x1, x2, x3]) = [f(x1), f(x2), f(x3)]
    

  • Filter (C#의 Where): 조건을 만족하는 요소만 선택합니다.

    filter(p, [x1, x2, x3]) = [xi | p(xi) = true]
    

  • Reduce (C#의 Aggregate): 요소들을 하나의 값으로 축약합니다.

    reduce(f, init, [x1, x2, x3]) = f(f(f(init, x1), x2), x3)
    

이 세 가지 연산만으로도 대부분의 리스트 처리를 표현할 수 있습니다. 이는 Google의 MapReduce 프레임워크의 이론적 기반이기도 합니다.

함수를 인자로 받는 함수

// 고차 함수: 리스트와 변환 함수를 받음
List<int> Transform(List<int> numbers, Func<int, int> operation)
{
    var result = new List<int>();
    foreach (int num in numbers)
    {
        result.Add(operation(num));
    }
    return result;
}

// 사용 예제
List<int> numbers = new List<int> { 1, 2, 3, 4, 5 };

var doubled = Transform(numbers, x => x * 2);
var squared = Transform(numbers, x => x * x);
var plusTen = Transform(numbers, x => x + 10);

Console.WriteLine($"2배: {string.Join(", ", doubled)}");
Console.WriteLine($"제곱: {string.Join(", ", squared)}");
Console.WriteLine($"+10: {string.Join(", ", plusTen)}");
// 출력:
// 2배: 2, 4, 6, 8, 10
// 제곱: 1, 4, 9, 16, 25
// +10: 11, 12, 13, 14, 15

함수를 반환하는 함수

// 고차 함수: 함수를 반환
Func<int, int> MakeMultiplier(int factor)
{
    return x => x * factor;
}

// 특정 배수를 만드는 함수들 생성
var double = MakeMultiplier(2);
var triple = MakeMultiplier(3);
var quadruple = MakeMultiplier(4);

Console.WriteLine($"5의 2배: {double(5)}");
Console.WriteLine($"5의 3배: {triple(5)}");
Console.WriteLine($"5의 4배: {quadruple(5)}");
// 출력:
// 5의 2배: 10
// 5의 3배: 15
// 5의 4배: 20

LINQ의 고차 함수

LINQ의 많은 메서드들이 고차 함수입니다:

List<int> numbers = new List<int> { 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 };

// Where - 술어 함수를 받음
var evens = numbers.Where(n => n % 2 == 0);
Console.WriteLine($"짝수: {string.Join(", ", evens)}");

// Select - 변환 함수를 받음
var squares = numbers.Select(n => n * n);
Console.WriteLine($"제곱: {string.Join(", ", squares)}");

// OrderBy - 키 선택 함수를 받음
var descending = numbers.OrderByDescending(n => n);
Console.WriteLine($"내림차순: {string.Join(", ", descending)}");

// Aggregate - 누산 함수를 받음
var sum = numbers.Aggregate(0, (acc, n) => acc + n);
Console.WriteLine($"합계: {sum}");
// 출력:
// 짝수: 2, 4, 6, 8, 10
// 제곱: 1, 4, 9, 16, 25, 36, 49, 64, 81, 100
// 내림차순: 10, 9, 8, 7, 6, 5, 4, 3, 2, 1
// 합계: 55

함수 합성 (Function Composition)

여러 함수를 조합하여 새로운 함수를 만드는 기법:

// 함수 합성 헬퍼
Func<A, C> Compose<A, B, C>(Func<A, B> f, Func<B, C> g)
{
    return x => g(f(x));
}

// 개별 함수들
Func<int, int> addTwo = x => x + 2;
Func<int, int> multiplyThree = x => x * 3;
Func<int, string> toString = x => $"결과: {x}";

// 함수 합성
var addThenMultiply = Compose(addTwo, multiplyThree);
var fullPipeline = Compose(addThenMultiply, toString);

Console.WriteLine(fullPipeline(5));
// 출력: 결과: 21
// (5 + 2) * 3 = 21

실용 예제 - 데이터 처리 파이프라인

// 데이터 처리 함수들
Func<string, string> trimWhitespace = s => s.Trim();
Func<string, string> toLowerCase = s => s.ToLower();
Func<string, bool> isNotEmpty = s => !string.IsNullOrEmpty(s);
Func<string, int> countWords = s => s.Split(' ', StringSplitOptions.RemoveEmptyEntries).Length;

// 고차 함수를 사용한 파이프라인
List<string> texts = new List<string>
{
    "  Hello World  ",
    "   ",
    "C# Programming",
    "  Functional  Programming  "
};

var wordCounts = texts
    .Select(trimWhitespace)
    .Where(isNotEmpty)
    .Select(toLowerCase)
    .Select(s => new { Text = s, Words = countWords(s) })
    .ToList();

Console.WriteLine("텍스트 분석:");
foreach (var item in wordCounts)
{
    Console.WriteLine($"'{item.Text}' - {item.Words}단어");
}
// 출력:
// 텍스트 분석:
// 'hello world' - 2단어
// 'c# programming' - 2단어
// 'functional  programming' - 2단어

커스텀 고차 함수

// 조건에 따라 다른 연산 수행
int ApplyIf(int value, bool condition, Func<int, int> transform)
{
    return condition ? transform(value) : value;
}

// 반복 적용
int ApplyTimes(int value, int times, Func<int, int> operation)
{
    int result = value;
    for (int i = 0; i < times; i++)
    {
        result = operation(result);
    }
    return result;
}

// 사용 예제
int num = 10;

var result1 = ApplyIf(num, num > 5, x => x * 2);
Console.WriteLine($"조건부 적용: {result1}");

var result2 = ApplyTimes(2, 4, x => x * 2);
Console.WriteLine($"4번 2배: {result2}");
// 출력:
// 조건부 적용: 20
// 4번 2배: 32

16.4 커링 (Currying)과 부분 적용

커링(Currying)은 여러 개의 인자를 받는 함수를 단일 인자를 받는 함수들의 체인으로 변환하는 기법입니다. 이 이름은 논리학자이자 수학자인 Haskell Curry(1900-1982)의 이름에서 유래했지만, 실제로는 Moses Schönfinkel이 1924년에 처음 개발했습니다(때때로 Schönfinkeling이라고도 불립니다). Curry는 이 개념을 조합 논리학(Combinatory Logic)과 람다 계산법에서 발전시켰습니다.

커링의 수학적 기반:

수학에서 2변수 함수 f(x, y)는 커링을 통해 다음과 같이 변환될 수 있습니다:

f: X × Y → Z
curry(f): X → (Y → Z)

즉, 두 인자를 한 번에 받는 함수를 "첫 번째 인자를 받아 '두 번째 인자를 받는 함수'를 반환하는 함수"로 변환합니다. 이는 **지수법칙(Exponential Law)**이라고 불리는 집합론의 동형 관계와 관련이 있습니다:

Z^(X×Y) ≅ (Z^Y)^X

커링의 이론적 중요성:

람다 계산법에서 모든 함수는 본질적으로 단일 인자 함수입니다. 다중 인자 함수는 커링을 통해 표현됩니다. 이는 람다 계산법의 단순성과 표현력의 기반이 되며, Haskell 같은 순수 함수형 언어에서는 모든 함수가 자동으로 커리됩니다.

커링 vs 부분 적용:

커링과 부분 적용은 종종 혼동되지만, 명확히 구분됩니다:

  • 커링(Currying): 함수의 구조를 변환하는 것입니다. n개 인자를 받는 함수를 1개 인자를 받는 함수 n개의 체인으로 변환합니다.

  • 부분 적용(Partial Application): 함수에 일부 인자를 미리 적용하여 새로운 함수를 만드는 것입니다. 인자의 개수가 줄어듭니다.

커링은 구조적 변환이고, 부분 적용은 값의 바인딩입니다. 커리된 함수는 부분 적용을 자연스럽게 지원합니다.

커링의 실용적 가치:

  1. 함수 특화(Function Specialization): 일반적인 함수에서 특화된 함수를 쉽게 만들 수 있습니다.

  2. 의존성 주입(Dependency Injection): 설정이나 의존성을 먼저 주입하고, 나중에 실제 데이터를 전달할 수 있습니다.

  3. 함수 조합(Function Composition): 단일 인자 함수들은 합성하기 쉽습니다.

  4. 지연 평가(Lazy Evaluation): 필요한 시점에만 인자를 제공할 수 있습니다.

  5. DSL 구축: 도메인 특화 언어를 만들 때 유용한 패턴입니다.

클로저와의 관계:

커링과 부분 적용은 **클로저(Closure)**를 통해 구현됩니다. 클로저는 함수가 정의된 환경(Environment)을 캡처하여, 함수가 그 환경의 변수에 접근할 수 있게 합니다:

Func<int, Func<int, int>> MakeAdder(int x)
{
    // 반환되는 함수는 x를 캡처합니다 (클로저)
    return y => x + y;
}

var add5 = MakeAdder(5);  // add5는 x=5를 "기억"합니다
Console.WriteLine(add5(3));  // 8
Console.WriteLine(add5(7));  // 12

이러한 클로저는 컴파일러가 내부적으로 클래스를 생성하여 구현합니다:

// 컴파일러가 생성하는 대략적인 코드
class Closure
{
    public int x;  // 캡처된 변수
    public int Apply(int y) => x + y;
}

커링의 성능 고려사항:

C#에서 커링은 여러 단계의 함수 호출과 클로저 객체 생성을 수반하므로, 성능에 민감한 코드에서는 주의가 필요합니다:

  • 함수 호출 오버헤드: n개 인자 함수를 커링하면 n번의 함수 호출이 필요합니다.
  • 메모리 할당: 각 부분 적용마다 새로운 클로저 객체가 생성될 수 있습니다.
  • JIT 최적화: 최신 JIT 컴파일러는 일부 경우에 이러한 오버헤드를 최적화합니다.

따라서 성능이 중요한 핫 패스(Hot Path)보다는 설정, 초기화, DSL 같은 곳에서 커링을 사용하는 것이 좋습니다.

일반 함수 vs 커리된 함수

// 일반 함수 - 모든 인자를 한 번에 받음
int Add3Numbers(int a, int b, int c)
{
    return a + b + c;
}

// 커리된 함수 - 한 번에 하나의 인자만 받음
Func<int, Func<int, Func<int, int>>> Add3NumbersCurried =
    a => b => c => a + b + c;

// 사용 예제
Console.WriteLine($"일반 함수: {Add3Numbers(1, 2, 3)}");
Console.WriteLine($"커리된 함수: {Add3NumbersCurried(1)(2)(3)}");
// 출력:
// 일반 함수: 6
// 커리된 함수: 6

부분 적용 (Partial Application)

// 2개 인자를 받는 함수
int Multiply(int a, int b) => a * b;

// 부분 적용 - 첫 번째 인자를 고정
Func<int, int> MultiplyBy5(int b) => Multiply(5, b);
Func<int, int> MultiplyBy10(int b) => Multiply(10, b);

Console.WriteLine($"5 × 3 = {MultiplyBy5(3)}");
Console.WriteLine($"10 × 3 = {MultiplyBy10(3)}");
// 출력:
// 5 × 3 = 15
// 10 × 3 = 30

커링 헬퍼 함수

// 2인자 함수를 커링
Func<T1, Func<T2, TResult>> Curry<T1, T2, TResult>(Func<T1, T2, TResult> func)
{
    return a => b => func(a, b);
}

// 3인자 함수를 커링
Func<T1, Func<T2, Func<T3, TResult>>> Curry<T1, T2, T3, TResult>(
    Func<T1, T2, T3, TResult> func)
{
    return a => b => c => func(a, b, c);
}

// 사용 예제
Func<int, int, int> add = (a, b) => a + b;
var curriedAdd = Curry(add);

var add5 = curriedAdd(5);
Console.WriteLine($"5 + 3 = {add5(3)}");
Console.WriteLine($"5 + 7 = {add5(7)}");
// 출력:
// 5 + 3 = 8
// 5 + 7 = 12

실용 예제 - 로깅 함수

// 커리된 로깅 함수
Func<string, Func<string, Func<string, string>>> CreateLogger =
    prefix => level => message => $"[{prefix}] [{level}] {message}";

// 부분 적용으로 특화된 로거 생성
var appLogger = CreateLogger("APP");
var dbLogger = CreateLogger("DB");

var appInfo = appLogger("INFO");
var appError = appLogger("ERROR");
var dbInfo = dbLogger("INFO");

Console.WriteLine(appInfo("애플리케이션 시작"));
Console.WriteLine(appError("오류 발생"));
Console.WriteLine(dbInfo("데이터베이스 연결 성공"));
// 출력:
// [APP] [INFO] 애플리케이션 시작
// [APP] [ERROR] 오류 발생
// [DB] [INFO] 데이터베이스 연결 성공

실용 예제 - HTTP 요청 빌더

// 커리된 HTTP 요청 설정
Func<string, Func<string, Func<string, string>>> BuildRequest =
    method => url => body => $"{method} {url}\nBody: {body}";

// 부분 적용으로 HTTP 메서드별 빌더 생성
var get = BuildRequest("GET");
var post = BuildRequest("POST");
var put = BuildRequest("PUT");

// URL별 빌더
var postToApi = post("/api/users");
var putToApi = put("/api/users");

Console.WriteLine(postToApi("{ \"name\": \"홍길동\" }"));
Console.WriteLine();
Console.WriteLine(putToApi("{ \"name\": \"김철수\" }"));
// 출력:
// POST /api/users
// Body: { "name": "홍길동" }
//
// PUT /api/users
// Body: { "name": "김철수" }

커링의 장점

// 설정 함수들
Func<string, Func<int, Func<bool, string>>> ConfigureServer =
    host => port => ssl => $"Server: {host}:{port}, SSL: {ssl}";

// 단계별 설정
var localhost = ConfigureServer("localhost");
var localhostDev = localhost(3000);
var localhostProd = localhost(443);

Console.WriteLine(localhostDev(false));
Console.WriteLine(localhostProd(true));
// 출력:
// Server: localhost:3000, SSL: False
// Server: localhost:443, SSL: True

파이프라인과 커링

// 커리된 문자열 처리 함수
Func<string, Func<string, string>> Prepend = prefix => text => prefix + text;
Func<string, Func<string, string>> Append = suffix => text => text + suffix;
Func<string, string> ToUpper = text => text.ToUpper();

// 부분 적용
var addHello = Prepend("Hello, ");
var addExclamation = Append("!");

// 함수 합성
string ProcessText(string text)
{
    return addExclamation(ToUpper(addHello(text)));
}

Console.WriteLine(ProcessText("world"));
// 출력: Hello, WORLD!

16장 정리 및 요약

이 장에서는 함수형 프로그래밍의 핵심 개념들을 C#로 구현하고 활용하는 방법을 학습했습니다.

핵심 개념 정리

  1. 불변성 (Immutability)
  2. 데이터를 변경하지 않고 새로운 복사본 생성
  3. C# record 타입과 with 식 활용
  4. 스레드 안전성과 예측 가능성 향상
  5. LINQ는 기본적으로 불변성을 따름

  6. 순수 함수 (Pure Functions)

  7. 같은 입력에 항상 같은 출력 반환
  8. 부작용이 없어 테스트와 디버깅이 쉬움
  9. 외부 상태에 의존하지 않음
  10. 함수 합성과 재사용이 용이

  11. 고차 함수 (Higher-Order Functions)

  12. 함수를 인자로 받거나 반환
  13. 코드의 추상화 수준 향상
  14. LINQ 메서드들이 대표적인 예
  15. 함수 합성을 통한 파이프라인 구축

  16. 커링과 부분 적용

  17. 다인자 함수를 단일 인자 함수 체인으로 변환
  18. 부분 적용으로 특화된 함수 생성
  19. 설정과 파라미터화에 유용
  20. 함수 재사용성 극대화

함수형 프로그래밍의 장점

  • 예측 가능성: 부작용이 없어 동작 예측이 쉬움
  • 테스트 용이성: 순수 함수는 단위 테스트가 간단함
  • 병렬 처리: 불변 데이터는 동시성 문제 없음
  • 모듈화: 작은 순수 함수들을 조합하여 복잡한 로직 구현
  • 디버깅: 상태 변경 추적이 불필요

C#에서의 함수형 프로그래밍

C#은 객체지향과 함수형 프로그래밍을 모두 지원하는 다중 패러다임 언어입니다: - record 타입으로 불변 객체 쉽게 생성 - 람다 식과 LINQ로 선언적 프로그래밍 - FuncAction으로 고차 함수 구현 - 표현식 트리로 코드를 데이터로 다룸

실무 적용 팁

  1. LINQ 활용: 컬렉션 처리는 LINQ로 선언적으로 작성
  2. 불변 데이터: 가능한 한 record와 불변 객체 사용
  3. 순수 함수: 비즈니스 로직은 순수 함수로 구현
  4. 적절한 혼용: 모든 코드를 함수형으로 작성할 필요는 없음
  5. 성능 고려: 성능이 중요한 부분은 명령형으로 작성

다음 단계

17장에서는 비동기 프로그래밍을 학습합니다. 함수형 프로그래밍의 개념들은 비동기 코드를 작성할 때도 유용하게 활용됩니다. 특히 순수 함수와 불변성은 동시성 프로그래밍에서 매우 중요한 역할을 합니다.

실습 문제

문제 1: 불변 객체 구현

// record를 사용하여 불변 객체 구현
record Product(string Name, decimal Price, int Quantity)
{
    // 가격 인상 메서드 (새 객체 반환)
    public Product IncreasePrice(decimal amount)
    {
        return this with { Price = Price + amount };
    }

    // 재고 추가 메서드 (새 객체 반환)
    public Product AddStock(int amount)
    {
        return this with { Quantity = Quantity + amount };
    }
}

// 테스트
var product = new Product("노트북", 1000000, 10);
var updated = product.IncreasePrice(100000).AddStock(5);

Console.WriteLine($"원본: {product.Name} - {product.Price:C}, 재고: {product.Quantity}");
Console.WriteLine($"변경: {updated.Name} - {updated.Price:C}, 재고: {updated.Quantity}");

문제 2: 순수 함수로 데이터 처리

record Order(int Id, string Customer, decimal Amount, string Status);

// 순수 함수들
bool IsLargeOrder(Order order) => order.Amount >= 100000;
bool IsCompleted(Order order) => order.Status == "완료";
string FormatOrder(Order order) => $"{order.Customer}: {order.Amount:C}";

// 데이터 처리
var orders = new List<Order>
{
    new(1, "홍길동", 150000, "완료"),
    new(2, "김철수", 50000, "대기"),
    new(3, "이영희", 200000, "완료"),
    new(4, "박민수", 80000, "완료")
};

// 완료된 대량 주문만 선택
var largeCompletedOrders = orders
    .Where(IsCompleted)
    .Where(IsLargeOrder)
    .Select(FormatOrder)
    .ToList();

Console.WriteLine("완료된 대량 주문:");
largeCompletedOrders.ForEach(Console.WriteLine);

문제 3: 고차 함수 활용

// 고차 함수: 변환 함수를 받아 리스트 처리
List<TResult> ProcessList<T, TResult>(
    List<T> items,
    Func<T, bool> filter,
    Func<T, TResult> transform)
{
    return items.Where(filter).Select(transform).ToList();
}

// 테스트
var numbers = new List<int> { 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 };

var evenSquares = ProcessList(
    numbers,
    n => n % 2 == 0,
    n => n * n
);

Console.WriteLine($"짝수의 제곱: {string.Join(", ", evenSquares)}");

문제 4: 커링 적용

// 커리된 할인 계산 함수
Func<decimal, Func<decimal, decimal>> ApplyDiscount =
    discountRate => price => price * (1 - discountRate);

// 특정 할인율 함수 생성
var tenPercentOff = ApplyDiscount(0.10m);
var twentyPercentOff = ApplyDiscount(0.20m);

Console.WriteLine($"10% 할인: {tenPercentOff(10000):C}");
Console.WriteLine($"20% 할인: {twentyPercentOff(10000):C}");
// 출력:
// 10% 할인: ₩9,000
// 20% 할인: ₩8,000

이러한 함수형 프로그래밍 개념들은 코드의 품질을 높이고, 버그를 줄이며, 유지보수를 쉽게 만듭니다. 실무에서 적절히 활용하면 더 안전하고 표현력 있는 코드를 작성할 수 있습니다!


16장 정리 및 요약

이 장에서는 함수형 프로그래밍의 네 가지 핵심 개념을 C#의 맥락에서 깊이 있게 학습했습니다. 함수형 프로그래밍은 단순한 프로그래밍 기법이 아니라, 수학적 기반을 가진 근본적인 사고방식의 전환입니다.

핵심 개념의 재조명

1. 불변성(Immutability)의 본질과 가치

불변성은 데이터를 값(Value)으로 다루는 철학적 전환입니다. 명령형 프로그래밍의 "상태 변경"에서 함수형 프로그래밍의 "값 생성"으로의 패러다임 전환은 다음과 같은 근본적인 이점을 제공합니다:

  • 시간의 복잡성 제거: 가변 상태는 시간이라는 차원을 프로그램에 도입하여 복잡성을 증가시킵니다. 불변성은 시간적 의존성을 제거합니다.
  • 참조 투명성 보장: 불변 데이터로만 작업하면 자연스럽게 참조 투명성이 달성됩니다.
  • 동시성의 안전성: 락(Lock)이나 동기화 없이도 안전한 병렬 처리가 가능합니다.
  • 이벤트 소싱과 CQRS: 불변 이벤트 스트림을 기반으로 하는 현대적 아키텍처 패턴의 기반입니다.

C# 9.0의 record 타입과 with 식은 불변성을 언어 차원에서 지원하여, 함수형 프로그래밍을 더 자연스럽게 만들었습니다. 영속 자료 구조(Persistent Data Structures)와 구조적 공유(Structural Sharing)를 통해 성능 걱정 없이 불변성의 이점을 누릴 수 있습니다.

2. 순수 함수(Pure Functions)와 참조 투명성

순수 함수는 프로그래밍을 수학적 함수 평가로 바라보는 관점입니다. Christopher Strachey가 1967년에 정의한 참조 투명성은 다음을 가능하게 합니다:

  • 대수학적 추론: 등식 추론(Equational Reasoning)을 통해 프로그램의 동작을 수학적으로 증명할 수 있습니다.
  • 안전한 리팩토링: 표현식을 그 값으로 자유롭게 대체할 수 있어, 리팩토링이 안전합니다.
  • 메모이제이션: 같은 입력에 대한 결과를 캐시하여 성능을 최적화할 수 있습니다.
  • 병렬화: 순수 함수는 실행 순서에 무관하여 자유롭게 병렬화할 수 있습니다.

부작용(Side Effects)을 완전히 제거할 수는 없지만, 프로그램의 경계로 밀어내어(Pushing Effects to the Edges) 순수한 핵심 로직과 불순한 I/O 로직을 분리할 수 있습니다. 이는 Hexagonal Architecture나 Clean Architecture의 기반이 됩니다.

3. 고차 함수(Higher-Order Functions)와 추상화

고차 함수는 함수를 일급 값(First-class Values)으로 다루어, 알고리즘의 구조와 세부 동작을 분리합니다:

  • Map-Filter-Reduce 패턴: 대부분의 리스트 처리를 이 세 가지 고차 함수로 표현할 수 있습니다. Google의 MapReduce는 이 개념을 대규모 분산 시스템에 적용한 것입니다.
  • 함수 합성: 작은 순수 함수들을 조합하여 복잡한 변환을 표현합니다. Unix 파이프라인의 철학을 프로그래밍에 적용한 것입니다.
  • 선언적 프로그래밍: "어떻게(How)" 대신 "무엇을(What)"을 명시하여 코드의 의도를 명확히 합니다.
  • 전략 패턴의 간소화: 고차 함수는 전략 패턴을 클래스 없이 구현합니다.

LINQ는 고차 함수의 가장 성공적인 응용 사례로, SQL의 선언적 표현력을 프로그래밍 언어에 통합했습니다.

4. 커링(Currying)과 부분 적용

Haskell Curry의 이름을 딴 커링은 함수를 더 작은 함수로 분해하는 기법입니다:

  • 함수 특화: 일반적인 함수에서 특화된 함수를 자동으로 생성합니다.
  • 의존성 주입: 설정을 먼저 주입하고 데이터를 나중에 제공하는 패턴을 자연스럽게 표현합니다.
  • DSL 구축: 도메인 특화 언어를 만들 때 강력한 도구가 됩니다.

클로저(Closure)를 통한 구현은 함수가 환경을 캡처하여 상태를 유지하면서도 순수성을 잃지 않는 방법을 보여줍니다.

함수형 프로그래밍의 실무 적용

객체지향과 함수형의 조화:

C#은 다중 패러다임 언어로서, 두 패러다임의 장점을 결합할 수 있습니다:

  • 핵심 비즈니스 로직: 순수 함수와 불변 데이터로 구현하여 테스트 가능하고 추론하기 쉽게 만듭니다.
  • 인프라스트럭처: 객체지향의 캡슐화와 다형성을 활용하여 유연한 구조를 만듭니다.
  • 경계(Boundaries): 순수한 핵심과 불순한 I/O를 명확히 분리합니다.

함수형 프로그래밍이 빛나는 영역:

  1. 데이터 변환 파이프라인: LINQ를 사용한 선언적 데이터 처리
  2. 동시성과 병렬 처리: 불변 데이터와 순수 함수로 안전한 병렬 처리
  3. 상태 관리: 이벤트 소싱, Redux 패턴, 함수형 리액티브 프로그래밍
  4. 도메인 모델링: 대수적 데이터 타입(Algebraic Data Types)과 패턴 매칭
  5. 테스트: 순수 함수는 목 없이 테스트 가능

주의할 점:

  1. 성능: 불변성과 고차 함수는 추가 할당과 함수 호출을 수반합니다. 성능이 중요한 곳에서는 측정 후 판단하세요.
  2. 학습 곡선: 함수형 사고는 명령형 배경을 가진 개발자에게 처음에는 낯설 수 있습니다.
  3. 팀 컨벤션: 팀 전체가 함수형 스타일에 익숙해야 효과적입니다.
  4. 점진적 적용: 모든 코드를 함수형으로 작성할 필요는 없습니다. 적합한 곳에 점진적으로 적용하세요.

더 나아가기

추가 학습 주제:

  • 함수형 리액티브 프로그래밍(FRP): Rx.NET, System.Reactive
  • 대수적 데이터 타입: F#의 Discriminated Unions, C#의 패턴 매칭
  • 모나드(Monads): Option/Maybe, Result, Task 등의 추상 패턴
  • 렌즈(Lenses): 불변 데이터 구조의 깊은 업데이트
  • 타입 클래스(Type Classes): Haskell의 추상화 메커니즘
  • 범주론(Category Theory): 함수형 프로그래밍의 수학적 기반

권장 도서 및 자료:

  • "Functional Programming in C#" by Enrico Buonanno
  • "Structure and Interpretation of Computer Programs" by Abelson & Sussman
  • "Category Theory for Programmers" by Bartosz Milewski
  • "Why Functional Programming Matters" by John Hughes (논문)
  • "Out of the Tar Pit" by Ben Moseley and Peter Marks (논문)

F# 학습의 가치:

.NET의 함수형 언어인 F#을 학습하면 함수형 프로그래밍을 더 깊이 이해할 수 있습니다. F#은 C#과 완벽하게 상호운용되므로, 프로젝트의 일부를 F#으로 작성할 수도 있습니다.

마치며

함수형 프로그래밍은 1930년대의 수학적 발견에서 시작하여, 21세기 현대 소프트웨어 개발의 필수 도구가 되었습니다. 동시성, 빅데이터, 클라우드 컴퓨팅의 시대에 함수형 프로그래밍의 가치는 더욱 커지고 있습니다.

C#은 객체지향과 함수형 프로그래밍을 모두 지원하는 강력한 다중 패러다임 언어입니다. 이 장에서 학습한 개념들을 실무에 적용하면:

  • 더 테스트 가능하고 유지보수하기 쉬운 코드
  • 버그가 적고 추론하기 쉬운 로직
  • 안전한 동시성과 병렬 처리
  • 선언적이고 표현력 있는 코드 스타일

을 달성할 수 있습니다.

함수형 프로그래밍은 목적이 아니라 수단입니다. 문제를 해결하는 강력한 도구 상자에 함수형 기법을 추가하여, 상황에 맞는 최선의 해결책을 선택할 수 있는 능력을 키우시기 바랍니다.

다음 단계:

17장에서는 비동기 프로그래밍을 학습합니다. 함수형 프로그래밍의 개념들, 특히 순수 함수와 불변성은 비동기 코드를 안전하게 작성하는 데 큰 도움이 됩니다. C#의 async/await는 사실상 **모나드(Monad)**의 한 형태로, 함수형 프로그래밍의 아이디어를 비동기 처리에 적용한 것입니다.