C# · 심층 가이드
C# 함수형 완전 정리
순수 함수와 불변 업데이트에서 시작해 Maybe와 Result 흉내내기, fold, 커링, 지연 이터레이터까지 C#에서 함수형 스타일을 쓰는 열다섯 가지 방법입니다.
C#은 함수형 언어가 아니지만 함수형 스타일을 쓰기에는 재료가 꽤 갖춰져 있습니다. 람다와 LINQ가 오래전에 자리를 잡았고, record가 불변 데이터를, 패턴 매칭과 switch 식이 대수적 자료형 흉내를 어느 정도 감당합니다. 다만 Option이나 Result가 표준으로 들어 있지는 않아서, 이 절의 상당수는 언어가 주는 조각으로 그 개념을 직접 조립하는 연습이 됩니다. 라이브러리를 통째로 들이지 않고도 어디까지 갈 수 있는지, 그리고 어디서부터는 무리인지를 가늠하는 데 목적이 있습니다.
순수 함수와 불변 업데이트가 나머지를 떠받치는 두 기둥입니다. 여기서 Maybe 스타일 처리와 Result 스타일 매핑으로 가면 값이 없을 수 있는 상황과 실패할 수 있는 상황을 예외 대신 타입으로 표현하는 방법이 나오고, 패턴 분기가 그 값을 꺼내는 짝이 됩니다. compose, partial 적용, curried 함수는 한 묶음으로 읽어야 pipeline에서 왜 그 형태가 필요했는지 이어집니다.
lazy iterator에서 yield return을 쓸 때는 실행 시점을 조심해야 합니다. 이터레이터 메서드는 호출만으로는 몸체가 한 줄도 돌지 않고, 첫 열거가 시작될 때 비로소 실행됩니다. 그래서 메서드 맨 앞에 넣은 인자 검증도 그때까지 미뤄져, 잘못된 인자를 넘긴 호출 지점이 아니라 한참 뒤의 foreach에서 예외가 터집니다. 검증은 일반 메서드에서 하고 열거 부분만 따로 떼어 내는 방식이 정석입니다. 재귀 쪽도 마찬가지로, C#은 꼬리 재귀 최적화를 보장하지 않으니 깊은 재귀는 반복문이나 명시적 스택으로 바꿔야 합니다.
01순수 함수
배열의 네 값을 Aggregate로 왼쪽부터 접어 합계 7을 출력합니다. 누적 람다가 바깥 상태를 전혀 건드리지 않으므로 몇 번을 돌려도 결과가 같은 순수 함수의 전형입니다. 같은 계산을 Sum으로도 할 수 있지만, Sum은 오버플로 시 예외를 던지고 이 람다는 조용히 값이 뒤집힙니다.
C# code
// 순수 함수
using System;
using System.Linq;
var values = new[] { 1, 2, 3, 1 };
Console.WriteLine(values.Aggregate(0, (sum, value) => sum + value));누적 람다가 바깥 변수를 하나도 캡처하지 않으면 컴파일러가 델리게이트를 한 번만 만들어 캐시하므로, 여러 번 호출해도 추가 할당이 생기지 않습니다.
람다 안에서 바깥 리스트에 값을 담거나 카운터를 올리는 순간 순수하지 않게 되어, 같은 시퀀스를 두 번 열거하면 결과가 달라집니다.
02불변 업데이트
state를 고치지 않고 Page를 1 늘린 새 익명 객체 next를 만들어 2를 출력합니다. 상태를 통째로 교체하는 갱신 방식이 그대로 드러납니다. 익명 타입의 속성은 읽기 전용이라 대입 자체가 막혀 있으며, C# 9부터는 record와 with 식이 같은 일을 더 짧게 처리합니다.
C# code
// 불변 업데이트
using System;
var state = new { Page = 1, Tags = new[] { "x" } };
var next = new { Page = state.Page + 1, Tags = state.Tags.Append("y").ToArray() };
Console.WriteLine(next.Page);record로 바꾸면 state with { Page = state.Page + 1 }처럼 바뀌는 속성만 적으면 되어, 항목이 많아져도 나머지를 일일이 옮겨 적지 않아도 됩니다.
여기서는 태그를 새 배열로 만들었지만 원본 배열을 그대로 넘겼다면 두 상태가 같은 배열을 공유하게 되어, 한쪽을 고치는 순간 다른 쪽도 함께 바뀝니다.
03Maybe 스타일 처리
여기 있는 것은 합계 9를 만드는 Aggregate 호출 한 줄뿐이고, Maybe나 널 허용 타입은 나오지 않습니다. 값이 없을 수 있는 흐름으로 넓힐 자리는 시드 자리입니다. 시드를 준 Aggregate는 빈 시퀀스에서 시드를 그대로 돌려주지만, 시드 없는 오버로드는 예외를 던집니다.
C# code
// Maybe 스타일 처리
using System;
using System.Linq;
var values = new[] { 1, 2, 3, 3 };
Console.WriteLine(values.Aggregate(0, (sum, value) => sum + value));값이 없을 수 있는 흐름은 Nullable<T>와 ?., ?? 조합으로 상당 부분 표현되며, 참조 타입이라면 nullable 참조 형식을 켜서 검사를 컴파일러에 맡길 수 있습니다.
없을 때 기본값을 주는 메서드의 결과를 그대로 이어 쓰면, 값이 없었다는 사실이 0이나 null로 뭉개져 한참 뒤 엉뚱한 곳에서 터집니다.
04Result 스타일 매핑
성공과 실패를 나누는 타입은 없고, 기존 상태에서 다음 상태를 파생시키는 매핑만 보여 줍니다. Append는 원본 배열을 건드리지 않고 새 시퀀스를 만들며, ToArray가 그것을 실제 배열로 복사합니다. 복사 비용은 원소 수에 비례하니 반복문 안에서 매번 부르면 부담이 됩니다.
C# code
// Result 스타일 매핑
using System;
var state = new { Page = 1, Tags = new[] { "x" } };
var next = new { Page = state.Page + 1, Tags = state.Tags.Append("y").ToArray() };
Console.WriteLine(next.Page);Append는 원본 시퀀스를 감싸는 반복자 하나만 만들 뿐이라, ToArray를 붙이기 전까지는 실제 복사가 일어나지 않습니다.
루프 안에서 Append와 ToArray를 반복하면 매번 전체를 복사해 원소 수의 제곱에 비례하는 비용이 듭니다. 모을 때는 리스트에 담고 마지막에 한 번만 변환해야 합니다.
05fold 합계
시드 0에서 출발해 원소를 차례로 더해 11을 만드는, 이름 그대로의 왼쪽 폴드입니다. 합계 말고도 누적값 타입이 원소 타입과 달라도 되는 계산, 예컨대 문자열 이어 붙이기에 어울립니다. 단순 정수 합계라면 원소마다 델리게이트를 호출하지 않는 Sum 쪽이 더 빠릅니다.
C# code
// fold 합계
using System;
using System.Linq;
var values = new[] { 1, 2, 3, 5 };
Console.WriteLine(values.Aggregate(0, (sum, value) => sum + value));누적값의 타입은 시드가 결정하므로, new StringBuilder()를 시드로 주면 문자열을 이어 붙여 나가는 폴드를 만들 수 있습니다.
뺄셈이나 나눗셈처럼 순서가 결과를 바꾸는 연산에서는 누적값과 원소의 자리를 뒤집으면 값이 완전히 달라집니다. 첫 인자가 누적값이라는 순서를 지켜야 합니다.
06compose
함수를 합성하는 코드는 없고 상태 하나를 다음 상태로 옮기는 계산만 들어 있습니다. 두 변환을 이어 붙이려면 Func를 받아 안쪽부터 호출하는 도우미를 직접 만들어야 합니다. C#에는 합성 연산자가 없고, Delegate.Combine은 합성이 아니라 순차 호출이라 목적이 다릅니다.
C# code
// compose
using System;
var state = new { Page = 1, Tags = new[] { "x" } };
var next = new { Page = state.Page + 1, Tags = state.Tags.Append("y").ToArray() };
Console.WriteLine(next.Page);합성 도우미는 Func<A, C> Compose(Func<A, B> f, Func<B, C> g) => x => g(f(x)); 한 줄로 만들 수 있고, 제네릭으로 두면 어떤 타입 조합에도 재사용됩니다.
합성한 함수가 예외를 던지면 스택 트레이스에 컴파일러가 만든 람다 이름만 남아 어느 단계에서 실패했는지 알기 어렵습니다. 단계가 많다면 이름 있는 지역 함수가 낫습니다.
07partial 적용
부분 적용 문법은 보이지 않고 시드와 람다를 받은 Aggregate가 13을 출력합니다. 인자 하나를 미리 고정하고 싶다면 람다가 바깥 변수를 캡처하게 만드는 방식이 쓰입니다. C#에는 전용 문법이 없어 Func를 반환하는 지역 함수로 흉내 내는 것이 보통입니다.
C# code
// partial 적용
using System;
using System.Linq;
var values = new[] { 1, 2, 3, 7 };
Console.WriteLine(values.Aggregate(0, (sum, value) => sum + value));람다가 캡처하는 것은 변수의 값이 아니라 변수 자체이므로, 인자를 고정해 함수를 만든 뒤 원래 변수를 바꾸면 이미 만들어 둔 함수의 동작도 함께 달라집니다.
for 반복 변수를 캡처해 함수를 여러 개 만들면 전부 같은 변수를 가리켜 마지막 값만 남습니다. 반복 안에서 지역 변수로 복사한 뒤 캡처해야 합니다.
08curried 함수
커링이라기보다 익명 타입으로 새 상태를 만드는 코드에 가깝습니다. 커링은 인자를 하나씩 받아 다시 함수를 돌려주는 Func 중첩으로 표현합니다. 참고로 익명 타입은 이름을 적을 수 없어 메서드 반환 타입이 되지 못하니, 상태를 함수 밖으로 넘길 생각이라면 record로 바꿔야 합니다.
C# code
// curried 함수
using System;
var state = new { Page = 1, Tags = new[] { "x" } };
var next = new { Page = state.Page + 1, Tags = state.Tags.Append("y").ToArray() };
Console.WriteLine(next.Page);커링한 함수는 Func<int, Func<int, int>> 타입이 되어 add(1)(2)처럼 호출하며, 중간 결과를 변수에 담으면 첫 인자가 고정된 함수로 재사용할 수 있습니다.
익명 타입을 object나 dynamic으로 담아 밖으로 내보내면 속성 접근이 컴파일 시점 검사를 벗어나, 이름을 잘못 적어도 실행 중에야 실패합니다.
09pipeline
파이프 연산자 대신 Aggregate 한 단계만 놓여 결과 15가 출력됩니다. 단계를 늘리려면 앞에 Where나 Select를 붙이면 됩니다. 그렇게 이어 붙인 앞 단계들은 지연 평가라서, 실제 순회는 Aggregate가 호출되는 시점에 딱 한 번만 일어납니다.
C# code
// pipeline
using System;
using System.Linq;
var values = new[] { 1, 2, 3, 9 };
Console.WriteLine(values.Aggregate(0, (sum, value) => sum + value));Where와 Select를 이어 붙여도 중간 배열이 만들어지지 않고, 원소 하나가 모든 단계를 통과한 뒤 다음 원소로 넘어가는 방식으로 진행됩니다.
파이프라인 중간에 ToList를 끼워 넣으면 그 지점에서 전체가 메모리에 올라오고 이후 단계가 순회를 다시 시작합니다. 필요 없는 위치의 ToList는 지워야 합니다.
10lazy iterator
지연 평가는 Append가 만든 시퀀스에 잠깐 나타났다가 곧바로 붙은 ToArray 때문에 즉시 배열로 굳습니다. 결과를 여러 번 순회할 것이라면 지금처럼 굳혀 두는 편이 낫습니다. 반대로 ToArray를 떼어 지연 상태로 두면, 원본이 바뀐 뒤의 순회 결과도 함께 달라집니다.
C# code
// lazy iterator
using System;
var state = new { Page = 1, Tags = new[] { "x" } };
var next = new { Page = state.Page + 1, Tags = state.Tags.Append("y").ToArray() };
Console.WriteLine(next.Page);직접 지연 시퀀스를 만들려면 yield return을 쓰는 반복자 메서드가 가장 간단하며, 호출해도 본문은 첫 열거가 시작되기 전까지 한 줄도 실행되지 않습니다.
반복자 메서드 안에 인자 검증을 넣으면 그 검사도 열거를 시작해야 실행되어, 잘못된 인자가 호출 지점이 아니라 한참 뒤에 예외로 드러납니다.
11패턴 분기
분기 처리 없이 모든 원소를 더해 17을 출력합니다. 조건에 따라 누적 방식을 바꾸려면 람다 안에 switch 식을 넣는 형태가 자연스럽습니다. C#의 switch 식은 모든 경우를 덮지 못하면 컴파일 경고가 뜨고, 실행 중 걸리는 값이 오면 예외가 발생합니다.
C# code
// 패턴 분기
using System;
using System.Linq;
var values = new[] { 1, 2, 3, 11 };
Console.WriteLine(values.Aggregate(0, (sum, value) => sum + value));switch 식은 >= 10 and < 20처럼 관계 패턴과 논리 패턴을 조합할 수 있어, 구간 분기를 조건문보다 훨씬 짧게 적을 수 있습니다.
패턴의 not null과 != null은 같아 보여도, 사용자 정의 비교 연산자가 있는 타입에서는 패턴 쪽만 그 연산자를 거치지 않아 결과가 달라질 수 있습니다.
12reducer 상태 누적
이전 상태를 읽어 갱신된 새 상태를 돌려주는, 리듀서 한 번의 실행을 그대로 옮겨 놓은 코드입니다. 이런 조각을 Aggregate에 넘기면 액션 목록을 최종 상태로 접을 수 있습니다. 익명 타입은 속성 이름과 타입, 선언 순서가 모두 같으면 컴파일러가 하나의 타입으로 합쳐 씁니다.
C# code
// reducer 상태 누적
using System;
var state = new { Page = 1, Tags = new[] { "x" } };
var next = new { Page = state.Page + 1, Tags = state.Tags.Append("y").ToArray() };
Console.WriteLine(next.Page);액션 목록을 최종 상태로 접으려면 초기 상태를 시드로 주고 리듀서를 누적 함수로 넘기면 되며, 중간 상태를 모두 남기려면 직접 순회하며 기록해야 합니다.
리듀서 안에서 이전 상태의 컬렉션을 그대로 수정하면 되돌리기나 상태 비교가 깨집니다. 새 컬렉션을 만들어 담아야 이전 상태가 그대로 보존됩니다.
13all predicate
술어 검사는 없고 네 값을 더해 19를 계산합니다. 모든 원소가 조건을 만족하는지 보려면 All이 맞고, 이쪽은 첫 위반 원소를 만나는 즉시 순회를 멈춥니다. 다만 All은 빈 시퀀스에 대해 항상 true를 반환하므로, 데이터가 비었는지는 따로 확인해야 합니다.
C# code
// all predicate
using System;
using System.Linq;
var values = new[] { 1, 2, 3, 13 };
Console.WriteLine(values.Aggregate(0, (sum, value) => sum + value));All과 Any는 서로 부정 관계라, 조건을 만족하지 않는 원소가 하나라도 있는지 볼 때는 Any(x => !p(x))가 더 직관적입니다.
Any()는 원소 존재만 보지만 Any(p)는 조건에 맞는 원소를 찾을 때까지 훑습니다. 만족하는 원소가 뒤쪽에만 있으면 사실상 전체를 순회합니다.
14flatMap
중첩 구조를 펴는 부분은 없고 배열 끝에 원소 하나를 덧붙이는 Append만 쓰였습니다. 여러 태그 목록을 하나로 합치는 상황이라면 SelectMany가 제 도구입니다. Append는 System.Linq의 확장 메서드라, 암시적 using이 꺼진 프로젝트에서는 네임스페이스를 직접 열어야 합니다.
C# code
// flatMap
using System;
var state = new { Page = 1, Tags = new[] { "x" } };
var next = new { Page = state.Page + 1, Tags = state.Tags.Append("y").ToArray() };
Console.WriteLine(next.Page);Append와 Prepend는 원본을 바꾸지 않고 원소 하나가 덧붙은 새 시퀀스를 만들며, 배열이나 리스트가 아닌 임의의 시퀀스에도 그대로 쓸 수 있습니다.
SelectMany로 합칠 때 안쪽 시퀀스가 지연 실행이면 바깥 열거가 진행되는 동안에만 유효해, 결과를 나중에 쓰려고 보관하면 이미 소진된 상태일 수 있습니다.
15함수 값을 반환하는 함수
실행 결과는 값 21이고, 함수를 돌려주는 함수는 아직 코드에 없습니다. 그런 팩토리는 반환 타입을 Func로 선언해 만들면 됩니다. 이때 반환된 람다가 바깥 지역 변수를 캡처하면 그 변수는 스택이 아니라 컴파일러가 생성한 클래스 필드로 옮겨져 호출이 끝난 뒤에도 살아남습니다.
C# code
// 함수 값을 반환하는 함수
using System;
using System.Linq;
var values = new[] { 1, 2, 3, 15 };
Console.WriteLine(values.Aggregate(0, (sum, value) => sum + value));캡처된 변수는 클로저 객체의 필드가 되므로 그 함수가 살아 있는 한 함께 남습니다. 큰 객체를 캡처한 람다를 이벤트에 등록해 두면 그 객체가 회수되지 않습니다.
팩토리가 만들어 준 함수마다 별도의 클로저 객체가 생기므로, 요청마다 새로 만들어 캐시에 담아 두면 함수 개수만큼 메모리가 늘어납니다.
정리하며
- 표준
Option과Result가 없으므로 필요한 만큼만 직접 만들어 씁니다 yield return메서드는 첫 열거 전까지 실행되지 않아 인자 검증이 지연됩니다- 꼬리 재귀 최적화가 보장되지 않으니 깊은 재귀는 반복이나 명시적 스택으로 바꿉니다
- 불변 업데이트는 원본을 두고 새 값을 만들되 내부 컬렉션 공유 여부를 확인합니다
더 깊이 들어가고 싶다면 C# 학습 라이브러리에서 다른 주제 가이드를 이어서 보거나, 언어 비교에서 같은 개념이 다른 언어에서 어떻게 표현되는지 확인해 보세요.