1. 소개
이제 이론에서 실전으로 넘어갈 때 자연스럽게 묻게 되는 질문: “왜 .NET과 C# 개발자가 이런 함수형 프로그래밍 기법을 배워야 하지?”
확실히 C#은 F#이나 Haskell 같은 순수 함수형 언어는 아니야. 하지만 3.0부터 시작해서 C# 14까지 오면서 FP 스타일로 쓸 수 있는 도구들이 많이 추가됐고, 이걸 잘 쓰면 코드 품질과 표현력이 크게 좋아져.
특히 아래 같은 상황에서 잘 먹힙니다:
- 컬렉션 작업 — LINQ, Map/Reduce, 필터링, 집계, 정렬 같은 데이터 조작.
- 순수 함수 — 상태와 부작용이 적으니 버그가 줄고 디버깅이 쉬워짐.
- 고차 함수 — 범용적이고 재사용 가능한 컴포넌트를 만들기 편함.
- 멀티스레드에서의 불변성 — 병렬·비동기 시나리오에서 안전한 코드의 핵심.
- 함수 합성 — 복잡한 비즈니스 로직을 간결하고 읽기 쉽게, 테스트하기 좋게 만듦.
표: C#에서 OOP 접근과 FP 접근 비교
| 작업 | 명령형 (OOP/구식) | 함수형 (FP) |
|---|---|---|
| 리스트 필터링 | |
|
| 리스트 변환 | |
|
| 조건 검색 | |
|
| 집계 | |
|
| 캐싱 | |
|
2. LINQ: C#에서 가장 함수적인 것
만약 “함수형 프로그래밍”이 리스트, 필터, 그리고 .Where, .Select, .Aggregate 같은 거라고 생각했다면 축하해 — 그게 맞아! LINQ는 C#에서 FP의 정수 같은 존재야.
간단히 복습: LINQ는 어떻게 동작하는가
LINQ는 컬렉션을 함수 인자를 받는 메서드 체인으로 조작해. 보통은 람다를 넘기지. 예를 들어:
var numbers = new List<int> { 1, 2, 3, 4, 5 };
// 짝수만 골라 두 배로 만들기
var result = numbers
.Where(x => x % 2 == 0)
.Select(x => x * 2);
foreach (var number in result)
Console.WriteLine(number);
무슨 일이 벌어지나?
- .Where — 고차 함수: 함수를 받음 (x => x % 2 == 0) 그리고 다른 컬렉션을 반환함.
- .Select — 역시 함수를 받음 (x => x * 2).
- 원본 컬렉션을 변경하지 않고 새로운 결과를 얻음.
이 스타일은 읽기 쉽고 확장하기 편함(추가로 .OrderBy, .Take, .Distinct 등 붙일 수 있음).
참고! 람다 표현식은 즉석에서 delegate를 만드는 편한 방법이야. LINQ는 C#의 FP 지원 없이는 불가능했을 거야.
도식: 컬렉션의 함수형 처리
컬렉션 --> Where(x => bool) --> Select(x => y) --> 새로운 결과
3. 함수 합성과 데이터 파이프라인
FP에서는 함수 합성을 자주 써: 복잡한 연산을 작은 함수들의 체인으로 만들어 각자 역할만 하게 하는 방식.
예: 문자열 처리 파이프라인
상태를 변경하는 스타일 (OOP):
string s = " hello world ";
s = s.Trim();
s = s.ToUpper();
s = s + "!";
Console.WriteLine(s); // HELLO WORLD!
좀 더 함수형으로 — 함수 파이프라인:
Func<string, string> trim = x => x.Trim();
Func<string, string> upper = x => x.ToUpper();
Func<string, string> addBang = x => x + "!";
// 함수 합성으로 순차 적용
Func<string, string> pipeline = x => addBang(upper(trim(x)));
Console.WriteLine(pipeline(" hello world ")); // HELLO WORLD!
가장 단순한 합성(composition) 컴비네이터:
Func<T, R> Compose<T, U, R>(Func<T, U> f, Func<U, R> g) =>
x => g(f(x));
// 이제 Compose로 pipeline 만들기:
var pipeline2 = Compose(trim, upper);
pipeline2 = Compose(pipeline2, addBang);
Console.WriteLine(pipeline2(" hello again ")); // HELLO AGAIN!
4. 불변성 다루기: 버그로부터의 보호
불변성(immutability)은 FP의 기본 블록이야. 데이터 구조를 직접 바꾸지 않고 새로 반환해. 멀티스레드 환경에서 특히 중요해.
예: 잘못된 방식(가변)
List<int> numbers = new List<int> { 1, 2, 3 };
numbers[0] = 42;
예: 올바른 방식(함수형)
var numbers = new List<int> { 1, 2, 3 };
var newNumbers = numbers.Select((x, i) => i == 0 ? 42 : x).ToList();
현대 C#에는 ImmutableList<T> 같은 컬렉션과 System.Collections.Immutable 네임스페이스의 다른 타입들이 있어:
using System.Collections.Immutable;
var immutableNumbers = ImmutableList.Create(1, 2, 3);
var changed = immutableNumbers.SetItem(0, 42); // 새 리스트를 반환함!
5. 실무에서의 고차함수
고차함수는 조건문 폭탄 없이 범용 컴포넌트를 만들 수 있게 해줘.
예: 사용자 필터의 범용화
class User
{
public string Name { get; set; }
public int Age { get; set; }
}
var users = new List<User>
{
new User { Name = "바샤", Age = 26 },
new User { Name = "카챠", Age = 17 },
new User { Name = "료샤", Age = 35 }
};
List<User> FilterUsers(List<User> source, Predicate<User> predicate)
{
return source.Where(u => predicate(u)).ToList();
}
// 사용 예:
var adults = FilterUsers(users, u => u.Age >= 18);
var longNames = FilterUsers(users, u => u.Name.Length > 3);
6. Pattern matching과 switch 표현식
현대 C#은 패턴 매칭을 적극적으로 쓰지: switch 표현식이 복잡한 if 체인을 대신하는 경우가 많아.
object value = 123;
string description = value switch
{
int i when i > 100 => "큰 숫자",
string s when s.Length > 3 => "긴 문자열",
null => "값 없음",
_ => "알 수 없음"
};
Console.WriteLine(description); // 큰 숫자
7. 메모이제이션: 함수 결과 캐싱
메모이제이션은 같은 인자에 대해 함수 결과를 캐시하는 기법. C#에서 직접 쉽게 구현할 수 있어.
Func<int, int> SlowFib = null; // 재귀 피보나치 함수
var cache = new Dictionary<int, int>();
SlowFib = n =>
{
if (cache.ContainsKey(n))
return cache[n];
if (n <= 1)
cache[n] = n;
else
cache[n] = SlowFib(n - 1) + SlowFib(n - 2);
return cache[n];
};
Console.WriteLine(SlowFib(40)); // 엄청 빨라짐!
8. 커링과 부분 적용
부분 적용(partial application)은 함수의 일부 인자를 고정하는 것. C#에서는 람다로 간단히 구현 가능.
Func<int, int, int> add = (a, b) => a + b;
// 첫 번째 인자 고정
Func<int, int> add10 = b => add(10, b);
Console.WriteLine(add10(5)); // 15
Console.WriteLine(add10(100)); // 110
9. 함수로 쓰는 선언형 스타일
명령형:
var result = new List<int>();
foreach (var n in numbers)
{
if (n > 0)
result.Add(n * n);
}
선언형:
var result = numbers
.Where(n => n > 0)
.Select(n => n * n)
.ToList();
10. 실습 과제
학생용 "할 일 매니저"에서 할 일 필터링 모듈을 구현해보자.
모델:
class StudentTask
{
public string Title { get; set; }
public bool IsCompleted { get; set; }
public int Priority { get; set; }
}
초기 데이터:
var tasks = new List<StudentTask>
{
new StudentTask { Title = "숙제하기", IsCompleted = false, Priority = 2 },
new StudentTask { Title = "커피 마시기", IsCompleted = true, Priority = 3 },
new StudentTask { Title = "강의 보기", IsCompleted = false, Priority = 1 }
};
범용 필터:
List<StudentTask> FilterTasks(
List<StudentTask> all,
Predicate<StudentTask> predicate)
{
return all.Where(t => predicate(t)).ToList();
}
// 완료되지 않은 우선순위 > 1 인 작업 찾기
var importantTasks = FilterTasks(tasks, t => !t.IsCompleted && t.Priority > 1);
// 결과 출력
foreach (var task in importantTasks)
Console.WriteLine(task.Title);
프레디케이트(combinator) 만들기:
Predicate<StudentTask> IsActive = t => !t.IsCompleted;
Predicate<StudentTask> IsHighPriority = t => t.Priority > 1;
// 여러 조건 조합, 방법 1
var specialTasks = FilterTasks(tasks, t => IsActive(t) && IsHighPriority(t));
// 방법 2: 두 프레디케이트를 합치는 함수(combinator)
Predicate<StudentTask> And(Predicate<StudentTask> a, Predicate<StudentTask> b) => t => a(t) && b(t);
var specialTasks2 = FilterTasks(tasks, And(IsActive, IsHighPriority));
11. C#에서 함수형 접근을 쓸 때 특징과 흔한 실수
첫째, C#은 강한 타입 언어라는 걸 잊지 말자. 특히 함수가 delegate나 복잡한 람다를 반환할 때 타입을 명시해야 하는 경우가 있고, 타입 추론 실패로 컴파일 에러가 발생할 수 있어.
둘째, 순수 함수가 기대되는 곳에 부작용 있는 함수를 넘기지 마. 외부 상태 변경은 예측 가능성을 망가뜨려. 가능한 한 함수는 자신 영역 밖의 상태를 변경하지 않도록 하자.
셋째, closure로 외부 변수를 캡처할 때 조심해, 특히 비동기나 멀티스레드 코드에서. 람다로 넘긴 후 그 변수가 바뀌면 의도치 않은 버그가 생길 수 있어.
마지막으로, 지나친 FP 스타일은 팀에서 유지보수를 어렵게 만들 수 있어. 코드가 깔끔해지는 곳에만 FP를 도입하고, 단지 '멋있어 보이려고' 적용하지는 말자.
GO TO FULL VERSION