CodeGym /행동 /C# SELF /C#에서의 함수형 프로그래밍(FP) 실전 적용

C#에서의 함수형 프로그래밍(FP) 실전 적용

C# SELF
레벨 51 , 레슨 4
사용 가능

1. 소개

이제 이론에서 실전으로 넘어갈 때 자연스럽게 묻게 되는 질문: “왜 .NET과 C# 개발자가 이런 함수형 프로그래밍 기법을 배워야 하지?”
확실히 C#은 F#이나 Haskell 같은 순수 함수형 언어는 아니야. 하지만 3.0부터 시작해서 C# 14까지 오면서 FP 스타일로 쓸 수 있는 도구들이 많이 추가됐고, 이걸 잘 쓰면 코드 품질과 표현력이 크게 좋아져.

특히 아래 같은 상황에서 잘 먹힙니다:

  • 컬렉션 작업LINQ, Map/Reduce, 필터링, 집계, 정렬 같은 데이터 조작.
  • 순수 함수 — 상태와 부작용이 적으니 버그가 줄고 디버깅이 쉬워짐.
  • 고차 함수 — 범용적이고 재사용 가능한 컴포넌트를 만들기 편함.
  • 멀티스레드에서의 불변성 — 병렬·비동기 시나리오에서 안전한 코드의 핵심.
  • 함수 합성 — 복잡한 비즈니스 로직을 간결하고 읽기 쉽게, 테스트하기 좋게 만듦.

표: C#에서 OOP 접근과 FP 접근 비교

작업 명령형 (OOP/구식) 함수형 (FP)
리스트 필터링
foreach + if + Add
.Where(predicate)
리스트 변환
foreach + 계산 + Add
.Select(lambda)
조건 검색
foreach + if/return
.FirstOrDefault(predicate)
집계
카운터 변수를 가진 루프
.Aggregate(seed, func)
캐싱
수동 Dictionary + 검사
closure를 사용하는 함수

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를 도입하고, 단지 '멋있어 보이려고' 적용하지는 말자.

2
과제
C# SELF, 레벨 51, 레슨 4
잠금
함수 합성으로 문자열 처리하기
함수 합성으로 문자열 처리하기
1
설문조사/퀴즈
함수형 프로그래밍, 레벨 51, 레슨 4
사용 불가능
함수형 프로그래밍
함수형 프로그래밍 소개
코멘트
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION