도서 요약 - C# 으로 쓰는 함수형 프로그래밍, 엔리코 붜나노

최근 함수형 프로그래밍에 관심이 많아졌습니다.

물론 F# 을 배우면 좋겠지만, C#도 어벙진데, 또 다른 언어를 추가한다는 것이 좀 부담스럽더군요.

마침, @Chris_Shim 님께서 추천해주신 책이 있어, 틈틈히 읽고 있습니다.

Functional Programming in C#, Second Edition

각 챕터에 대해 인상 깊은 내용을 하나 씩 정리할 예정입니다.

참고로, 이 책은 구입을 해도 되고, 위 링크 사이트에 전문이 공개되어 있으니, 혹시라도 관심이 있다면 원문을 읽어 보시기를 추천드립니다.

10 Likes

기대됩니다~ FP가 참 좋은데 현업에 치이면 공부할 틈이 없어서

1 Like

제 1 장 함수형 프로그래밍 소개하기

이 장은 함수형 프로그래밍에 관해 조망하는 장으로, 특히 C# 이 가진 함수형 프로그래밍을 위한 인프라에 관해 설명합니다.

1-1 함수형 프로그래밍이란

함수형 프로그래밍은 함수를 강조한 프로그래밍 패러다임으로, 상태의 불변성을 중요하게 생각함.

1-1-1 보통 값(First class value)으로서의 함수

함수형 패러다임에서 함수는 보통 값으로 취급함.
이는 함수가 변수에 저장 가능하고, 다른 (매개) 변수에 전달 가능하며, 메서드의 반환값으로도 사용될 수 있어야 함을 의미. (C# 에서도 쌉 가능)

var triple = (int x) => x * 3; 
var range = Enumerable.Range(1, 3); 
var triples = range.Select(triple);

string message = "Hello World";
MyAction()(message);

Action<string> MyAction() => 
   (string value) =>  Console.WriteLine(value);

1-1-2 상태 변경 지양

생성 된 값을 변경하거나, 이미 할당된 변수에 재할당하는 것은 지양되어야 함을 강조.

생성된 값을 변경

int[] nums = { 1, 2, 3 };
nums[0] = 7;

값이 생성된 범위에서 값을 변경하는 것을 제자리 변경(In-place update)이라고 합니다.

제자리 변경 대신, 새로운 생성

var isOdd = (int x) => x % 2 == 1;
int[] original = { 7, 6, 1 };
 
var sorted = original.OrderBy(x => x);
var filtered = original.Where(isOdd);

// original = [ 7, 6, 1 ]
// sorted = [ 1, 6, 7]
// filtered = [7, 1]

메서드 중에 비 함수적인 메서드

int[] original = { 5, 7, 1 };
Array.Sort(original);

// original = [ 1, 6, 7 ]

1-1-3 높은 예측성을 가진 프로그램

소프트웨어가 불안정해지는 원인은 상태가 변경되기 때문임.

using static System.Linq.Enumerable;
using static System.Console;
 
var nums = Range(-10000, 20001).Reverse().ToArray();
/ => [10000, 9999, ... , -9999, -10000]
 
var task1 = () => WriteLine(nums.Sum());
var task2 = () => { Array.Sort(nums); WriteLine(nums.Sum()); };

Parallel.Invoke(task1, task2);

// 예측한 결과
// 0
// 0 

// 때때로 나타나는 예측하지 못한 결과
// -9997
// 0

두 task 들이 독립적으로 실행될 때는 아무런 문제가 없음. 그러나, 동시 실행할 때는 예측하지 못한 문제가 나타남.

이는 동기적으로 작성된 어플리케이션의 성능 최적화를 위해 동시성을 도입할 때 마주칠 수 있는 문제 상황을 보여줌.

문제의 원인은 Array.Sortnums 의 상태를 변경시키기 때문.

Array.Sort 는 Linq 가 도입되기 전, 다시 말하면, C# 이 함수적 패러다임을 채택하기 전(C# 3 이전)에 도입되어, 비함수적 특징을 갖는 것입니다.

예측성이 높은 코드

var task3 = () => WriteLine(nums.OrderBy(x => x).Sum());
Parallel.Invoke(task1, task3);

// 결과는 언제나
// 0
// 0

Linq 메서드는 데이터를 변경하지 않고, 데이터에 대한 새로운 시각을 생성함.

동기적으로 작성한 후 동시성을 도입하더라도 아무런 문제가 없기에, 동시성으로 인한 성능 개선을 향유할 수 있음.

FP vs OOP

OOP 의 원칙과 FP 의 원칙은 대척적(Orthogonal)이라 두 패러다임을 섞어 쓰는 것은 불가능해 보임.
그러나 현실적으로 많은 OOP 개발자들은 명력적인 코드 - 상태를 변경하거나 실행 흐름을 제어(if, while, for)해서 메서드를 구현함.

이는 거시적 디자인은 객체 지향적이지만, 미시적 구현은 명령적인 형국.
FP 는 명령적인 구현에 대한 개선 방안으로, OOP 가 추구하는 아래 원칙들을 똑 같이 적용함.

  • 모듈성 — 소프트웨어는 유연하고 재사용 가능한 요소들로 구성되어야 함.
  • 관심사의 분리— 각 요소는 단일 작업만 실행
  • 레이어 나누기— 고수준 요소는 저수준 요소의 의존하지만 반대는 아니다.
  • 느슨한 결합— 요소는 의존 요소의 구현 세부를 알지 말아야 한다. 따라서, 의존 요소의 변경이 사용 요소에 영향을 미쳐서는 안된다.

둘의 차이는 요소를 무엇으로 보는가임.

1-2 C# 은 얼마나 함수적인가?

C# 은 꽤 오래전 부터 delegate 를 지원해서 보통 값으로서의 함수를 지원해 왔고, 그 이후 사탕 문법과 인프라를 끊임 없이 추가해 왔음.

상태 변경 지양 원칙은 변경대신 생성을 의미하기에 필요 없는 낡은 데이터는 버려지는 게 요구됨. => C# 의 GC 는 이 요구를 만족함.

상태의 제자리 변경(in-place update)을 막기 위한 언어적 장치가 없었는데, C# 9 에서 record 의 도입으로 이를 만족함.

record는 상태 변경 지양 원칙을 위해 탄생했지만, 참조형 객체라 성능에 손실이 있을 수 있습니다. 이를 보완하기 위해 record struct 가 있는데, 아이러니 하게도 mutable (제자리 변경 허용) 하기 때문에, readonly record struct 를 쓰는 게 좋습니다.

레코드는 클린 아키텍쳐의 도메인 객체처럼 여러 레이어에서 재탕 삼탕 쓰는 것이 아님.
각 요소에서 필요한 데이터만 구조화하고, 요소 사이에는 변환을 수행하는 것이 올바른 사용 방식임.

record Address(string Country);
record UsAddress(string State) : Address("us");

1-2-1 Linq 의 함수적 본성

Linq 는 FP 에서 영감을 받아 C# 3에 도입됨.
뿐만 아니라, 확장 메서드, 람다식도 함께 도입하여 Linq 를 지원.

Linq 는 시퀀스에 적용되는 보편적인 함수 - 필터링(Where), 정렬(OrderBy), 맵핑(Select)을 지원함.

var twenties = Enumerable.Range(1, 100).
   Where(i => i % 20 == 0).
   OrderBy(i => -i).
   Select(i => $"{i}%")

각 함수는 데이터의 원본을 변경하는 것이 아닌 새로운 데이터를 생성.
이것이 함수적 본성임.

Linq 를 써왔다면, 당신이 이미 FP에 기반한 API 가 어떤 모습이어야 하는 지에 감이 있는 거임.

불행하게도 C# 개발자들은 시퀀스를 제외한 다른 형식을 다룰 때는 명령적 스타일을 고수함. 그 결과 IEnumerable 과 IQueryable를 다룰 때만 함수적 코드가 나타나고, 다른 모든 객체를 다룰 때는 명령적 코드가 나타남.

C# 프로그래머는 LINQ와 같은 함수 라이브러리를 사용할 때의 이점을 알고 있지만 자신의 디자인에 LINQ에 적용된 디자인 원칙을 적용하지 못하고 있음. (이 책을 다 읽으면 가능하다 하네요)

1-2-2 함수적 코딩을 위한 축약 문법

이 부분은 C# 문법 소개라 상당 부분은 코드로 갈음합니다. 간단한 코드여도 시사하는 바가 있으니, 음미해보시기를 추천합니다.

using static System.Math;
 
public record Circle(double Radius)
{
   public double Circumference
      => PI * 2 * Radius;
 
   public double Area
   {
      get
      {
         double Square(double d) => Pow(d, 2);
         return PI * Square(Radius);
      }
   }
}

using static 지시어

FP 스타일로 C# 을 쓸 때는 함수들이 static 으로 정의되는 경우가 많음.
using static, `global using static’ 이 코드 축약을 도와줌.

그러나, `global using static’ 에 너무 많은 함수가 정의되지 않도록 주의해야 함. (이를 네임스페이스 오염 문제라고 한답니다.)

표현식 바디 멤버

FP 스타일에서는 함수는 보통 단일 실행문으로 작성되고, 이 함수들을 적절하게 구성해서 원하는 결과를 도출하도록 만는데, => 키워드는 메서드의 외형을 축약하도록 도와줌.

로컬 함수

지역 변수의 업데이트 대신 로컬 함수를 씀.
그러나 컴파일러는 로컬 함수를 class 로 변환하므로, 가급적 static 으로 선언하는 것이 좋음.

1-2-3 튜플

굳이 거창한 도메인적 의미를 부여할 필요가 없이, 잠깐 쓰고 버려지는 객체에 알 맞음.

public static (string Base, string Quote)
   AsPair(this string ccyPair)
   => ccyPair.SplitAt(3);
 
var pair = "EURUSD".AsPair();

1.2.4 패턴 매칭

이 부분은 이 책보다 닷넷 문서를 정독하시는 게 좋습니다. (아마 "이런 거까지 된다고?"를 연발하실 것입니다.)

6 Likes

제 2 장 함수적 사고

이 장은 함수적 사고, 특히 HOF(High-order function)에 익숙해지는 게 목표입니다.

2-1 함수란

2-1-1 함수는 대응(Map)

수학에서, 함수 f 는 정의역(Domain) X 의 원소 x 를 공역(Codomain, 공변역) Y 의 원소 y 에 대응(Map) 시킴.

f : x → y

대응(->)은 수학 공식처럼 규칙적일 수도 있고, 아무런 규칙 없이 임의적일 수도 있음.

중요한 건 규칙이 아니라, 원소 대 원소의 대응임.

함수에 관한 개념을 프로그래밍 관점, 특히 C#처럼 정적 형식 언어에서 해석하자면,

  • 형식(Type)은 집합 (정의역 또는 공변역) 으로 볼 수 있음.
  • 인스턴스는 집합의 원소로 볼 수 있음.

위 함수 f 를 C#의 메서드로 표현하면,

 Y F(X x) 

메서드 FX 형식 인스턴스를 Y 형식 인스턴스에 대응시키는 함수로 볼 수 있음.

2-1-2 C# 으로 함수 표현하기

메서드 외에도, C#은 함수를 표현하는 다양한 방법을 지원.

  • 대리자
  • 무명 메서드
  • 람다 표현식
  • Dictionary<T, R>

Dictionary<X, Y> (과 HashSet )은 X 인스턴스를 Y 인스턴스에 대응시키는 함수임. 특히, 컴퓨터 연산으로 표현하기 힘든 불규칙적인 대응에 유용함.

2-2 고차 함수(Higher-order function)

프로그래밍에서 일차 함수(First-order function)는 인스턴스와 인스턴스 사이의 대응을 나타냄.

그러나, 함수가 아래의 특징 중 하나를 보인다면, 고차 함수라 부름.

  • 함수를 입력받음
  • 함수를 반환함

Linq 메서드는 거의 대부분 함수를 입력 받는 고차 함수라 C# 사용자는 이미 고차 함수 사용에 익숙함.

고차 함수를 사용하는 이유는

2-2-1 다른 함수에 의존

var isOdd = (int x) => x % 2 != 0; 
var odds = Enumerable(0, 6).Where(isOdd);

Where 함수는 int 인스턴스가 홀수인지 판별하는 행위를 isOdd 함수에 위임함.

조건적 위임도 가능.

class Cache<T> where T : class
{
   public T? Get(Guid id) => //...
   public T Get(Guid id, Func<T> onMiss)
      => Get(id) ?? onMiss();
}

2-2-2 함수의 행위 변경

일명 아답터 고차 함수.
입력 함수에 의존하기 보다는, 입력 함수의 행위를 변경하는 게 목적인 고차 함수.

static Func<T2, T1, R> SwapArgs<T1, T2, R>(this Func<T1, T2, R> f)
   => (t2, t1) => f(t1, t2);

var monthDay = (int month, int day) => $"{day}/{month}";

var christmas = monthDay(12, 25); // "25/12"

var k-christmas = monthDay.SwapArgs().Invoke(12, 25); // "12/25"

메서드 시그니처를 바꾸고자 하는 경우에도 유용함.

2-2-3 새로운 함수 생성

일명 팩토리 고차 함수.

아마, C# 사용자들에게 가장 어색한 함수일 것입니다.

delegate string Capitalize(string s);

Capitalize PartlyCaptalize(int start) => 
   s => s.SubString(0, start) + s.SubString(start).ToUpper();

2-3 코드 중복을 막는 고차함수

I/O 에 의존하는 함수의 일반적인 형태

// Setup I/O
// DoSomthing
// TearDown I/O

I/O 는 콘솔, 파일, 네트워크, DB 커넥션 등 다양한 외부 자원을 가리킵니다.
아래 코드는 간단하게 표현하기 위해 동기적인 모습을 하고 있을 뿐 실재적으로는 비동기 호출을 해야 합니다.

문제는 어떤 DoSomething 을 하더라도, Setup과 TearDown 코드가 항상 중복됨.

// Setup I/O
string connString = "myDatabase"; 
var conn = new SqlConnection(connString));    
conn.Open();                                  
 
// DoSomething

// TeadDown I/O
conn.Close();
conn.Dispose();

함수를 입력 받는 고차 함수로 중복 코드를 제거할 수 있음.

using System.Data;
using System.Data.SqlClient;
 
public static class ConnectionHelper
{
   public static R Connect<R>(
      string connString, 
      Func<IDbConnection, R> doSomething)
   {
      // Set up
      using var conn = new SqlConnection(connString);    
      conn.Open();                                    
      
      return doSomething(conn);                                                          
   } // Tear down
}

위 예제는 책에서 발췌된 것인데, Func<IDbConnection, R> 에 도메인 로직이 들어가면, 도메인 로직에 대한 단위 테스트가 용이하지 않게 됩니다. 도메인 로직은 그 자체로 테스트가 가능하도록, Func<R, X> 의 형식으로 별도로 정의하는 게 좋습니다.

6 Likes

제 3 장 함수 순수성이 중요한 이유

순수 함수란, 부작용(副作用, side effect)이 없는 함수를 가리킵니다.
(비)순수성과 순수성이 필요한 이유를 설명합니다.

3-1 함수의 순수성

프로그램 함수가 수학 함수를 흉내 내더라도 둘에는 근본적인 차이가 있음.

첫째, 수학 함수는 값을 입력 받고 반환하는 것만 수행하는 반면, 프로그램 함수는 반환과 더불어 화면에 뭔가를 보여주는 등의 부작용을 동반하는 경우가 많음.

둘째, 수학 함수는 입력 값만 볼 수 있는 반면, 프로그램 함수는 입력 값 외에도 실행 컨텍스트를 볼 수 있음. 실행 컨텍스트는 외연(Outer range) 변수, 인스턴스 필드, 정적 필드, 시스템 클럭, 데이터 베이스, 원격 서비스 등을 제공함.

이 실행 컨텍스트의 존재로 인해, 프로그램 함수의 행위는 수학 함수의 행위 보다 쉽게 복잡해지고, 이러한 복잡성으로 인해 분석이 어려워짐.

이 것이 프로그램 함수의 순수성을 따지게 된 동기임.

3-1-1 순수성과 부작용

저자의 의도에 따라, “global state” 를 "외연 상태"로 번역했습니다.
“외연” 이란 함수의 범위 내부에서 볼 수 있는 외부 범위를 가리킵니다.
C# 에서는 함수가 포함된 클래스, 정적 변수, 정적 클래스, 런타임 서비스 등이 외연 상태라 할 수 있습니다.

순수성을 해치는 부작용:

  • 외연 상태 변경
  • 입력 값 변경
  • 예외 투척
  • I/O 조작

함수가 위 부작용을 갖거나, 부작용이 없더라도, 반환 값이

  • 외연 상태에 영향을 받으면,

하면 순수하지 않음.

순수 함수

(수학 함수처럼, 부작용 없이) 입력 값으로만 값을 반환하는 함수.

순수 함수의 이점

  • 테스트, 추론 용이
  • 호출 순서와 상관 없이, 동일한 입력 값에 대해 항상 같은 값을 반환

순수 함수의 최대 장점은 용이한 성능 최적화

  • 병렬 처리
  • 게으른 평가 (Lazy evaluation)
  • 메모이제이션 (Memoization, 최초의 실행 결과를 저장하고, 저장 값으로 재실행을 갈음 함)

이러한 최적화 기법을 비순수 함수에 적용하면 버그들이 양산됨. (아래에서 자세히 다룸)

3-1-2 부작용 관리 전략

순수 함수의 이점을 향유하려면, 함수를 순수함수로 정의하면 됨.
이는 함수를 작성할 때 부작용 요소를 지양하라는 의미임.

이 장에서는 위에서 나열된 부작용 중 일부에 대해서만 지양 전략을 소개하고, 나머지는 다른 장에서 다룹니다.

3-1-3 입력 값 상태 변경 지양

부작용이 있는 함수

decimal RecomputeTotal(Order order, List<OrderLine> linesToDelete)
{
   var result = 0m;
   foreach (var line in order.OrderLines)
      if (line.Quantity == 0) linesToDelete.Add(line); // 입력 값 상태 변경.
      else result += line.Product.Price * line.Quantity;
   return result;
}

입력 값을 변경하는 부작용을 가진 함수가 가진 문제는 호출자와 함수의 강한 결합성임 => 호출자는 함수의 부작용에 의존하고, 함수 또한 호출자에 의존(리스트의 생성).

입력 값이 class 에서 struct 로 바뀌면, 입력 값 변경이 유발하는 문제는 더 심각해짐.
(함수에서 수행한 입력 값 변경이 호출자가 가진 상태를 변경하지 못합니다. 변경한 것도 아니고, 안한 것도 아닌, 디버깅하기 어려운 코드가 된다는 의미입니다. )

부작용 해소

입력 값으로만 반환 값 생성.

(decimal NewTotal, IEnumerable<OrderLine> LinesToDelete)
   RecomputeTotal(Order order) => 
   (
      order.OrderLines.Sum(l => l.Product.Price * l.Quantity),
      order.OrderLines.Where(l => l.Quantity == 0)
   );

3-2 외연 상태 변경 지양과 병렬 실행 가능성

"외연 상태 변경"을 지양하도록 설계는 방법과 이 것이 성능 최적화(병렬 처리)에 미치는 영향을 설명합니다.

OOP 프로그래머에게 인스턴스 필드(외연 상태)를 변경는 코드를 작성하지 말라고 하는 것은 집안 기둥에 도끼질 하라는 소리와 같습니다.

“player.Receive(attack) 인데, palyer.HP를 깍지 말라고?”

기둥이 박살날 지, 99 레벨 강화 기둥이 될 지, 일단 계속 두드려 봅시다.

var shoppingList = new List<string>
{
   "coffee beans",
   "BANANAS",
   "Dates"
};
 
new ListFormatter()
   .Format(shoppingList)
   .ForEach(WriteLine);
 
// prints: 1. Coffee beans
//         2. Bananas
//         3. Dates

ListFormatter.Format 이 하는 일은 string 의 케이싱을 바꿈.

비순수적 구현

(OOP 프로그래머의 일반적 접근법)

static class StringExt
{
   public static string ToSentenceCase(this string s)   // 순수
      => s == string.Empty
         ? string.Empty
         : char.ToUpperInvariant(s[0]) + s.ToLower()[1..];
}
 
class ListFormatter
{
   int counter;
 
   string PrependCounter(string s) => 
      $"{++counter}. {s}";  // 비순수 (외연 상태를 변경)
 
   public List<string> Format(List<string> list)
      => list
         .Select(StringExt.ToSentenceCase)   // 순수 같은 순수
         .Select(PrependCounter)    // 순수 같은 비순수
         .ToList();
}

질문 : shoppingList가 수 백만 개인 경우에, 병렬 처리를 도입하여 성능을 개선할 수 있을까?

3-2-1 순수 함수는 병렬 처리가 잘 됨.

ToSentenceCase는 순수 함수임.

millions.Select(ToSentenceCase).ToList();
millions.AsParallel().Select(ToSentenceCase).ToList();

두 실행 문의 결과는 언제나 같음.
순수 함수는 동시성을 통한 성능 개선을 공짜로 얻을 수 있음을 의미.

3-2-2 비순수 함수의 병렬 처리

비 순수 함수 PrependCounter에 동일한 동시성 도임.

list.AsParallel().Select(PrependCounter).ToList()

// 결과
932335. Item999998
932336. Item999999
932337. Item1000000

이러한 동시성 문제가 나타난 원인은 -- 연산자가 원자적 실행이 아니라서, 상태에 대한 Update 와 Read 사이에 갭이 발생하기 때문(count에 대한 데이터 레이스).

이러한 동시성 문제는 병렬 처리(Parallelism) 뿐만 아니라, (다른 동시성 수단인) 비동기 처리(Asyncronym) 와 다중 스레드 실행(Multithreading) 에서도 나타남.

물론, 동시성 문제를 해결하는 수단이 있음.
그러나 그 수단 자체가 성능을 갉아 먹기도 하고, 신중하게 사용하지 않으면 오히려 시스템이 불안정해짐(dead lock 유발).

3-2-3 상태 변경 지양

동시성 문제를 해결하는 가장 좋은 방법은 문제의 싹을 제거하는 것임.

설계 초기 단계부터 공유된 상태를 두지 말 것.

동기적 설계

using static System.Linq.Enumerable;
 
static class ListFormatter
{
   public static List<string> Format(List<string> list)  => 
      list.Select(StringExt.ToSentenceCase)
      .Zip(Range(1, list.Count), (s, i) => $"{i}. {s}")
      .ToList();
}

Format은 순수해졌기 (상태에 의존하지 않기) 때문에 static 한정자를 붙일 수 있음.

Visual Studio 나 Code 에서, 메서드를 static 으로 한정하라는 컴파일러 제안이 보인다면, 그 메서드에 부작용이 발견되지 않았다는, 다시 말하면, 순수 함수라는 의미로 받아들일 수 있습니다.

병렬 처리 도입

AsParallel() 요거 한줄만 넣으면 됨.

using static System.Linq.ParallelEnumerable; 
 
static class ListFormatter
{
   public static List<string> Format(List<string> list)
      => list.AsParallel()
         .Select(StringExt.ToSentenceCase)
         .Zip(Range(1, list.Count), (s, i) => $"{i}. {s}")
         .ToList();
}

순순 함수이기 때문에 동시성 문제가 발생하지 않음.
동시성 문제가 없기 때문에 동시성 문제 해결 수단인 lock, Interlocked, ResetEvent, Semaphore 등을 사용할 일도 없음.

이런 성능의 이점을 살리기 위해, Select 에는 가급적 순수 함수를 전달해야 합니다.

순수 함수는 static 으로 한정되는데, C# 사용자는 static 에 알러지 반응함.
(static 의 위험성을 잘 알고 있기 때문에)

static 이 위험한 원인은,

  • 정적 필드 변경 : 동시성 문제 발생.
  • I/O 수행 : 유닛 테스트 불가능.

static 으로 한정할 때는 위 행위를 하지만 않으면 안전함.

3-3 순수성과 테스트 용이성

유닛 테스트는 반복적이어야 함.
=> 언제, 어떤 머신에서, I/O 연결 여부와 상관 없이 실행하더라도 결과는 같아야 함.

그러나, I/O 가 결부되면, 테스트의 반복성을 유지하기가 어려움.

3-3-1 I/O 부작용 격리

상태 변경 지양은 구현 문제이지만, I/O 는 요구 사항이라 지양하는 게 불가능함.
또한, I/O 접근 함수는 순수할 수 없음.

  • I/O 너머의 자원은 언제나 유동적임.
    같은 url 을 입력해도 반환값은 다를 수 있음.
    호출되는 시기에 따라 값이 달라짐. (DateTime.Now)

  • 함수가 I/O의 예외를 콘트롤 할 수 없음.

그럼에도 순수성의 이점을 향유하려면, 순수성과 I/O를 분리(Isolation)하는 방법 밖에 없음.

using static System.Console;
 
WriteLine("Enter your name:");
var name = ReadLine();
WriteLine($"Hello {name}");

이 소소한 코드라도 순수 함수로 나타낼 수 있는 연산 로직이 있음.

using static System.Console;
 
WriteLine("Enter your name:");
var name = ReadLine();
WriteLine(GreeringFor(name));

static string GreetingFor(string name) => $"Hello {name}";

그러나, I/O 가 결부된 코드에서 순수 함수를 분리하는 것이 항상 쉬운 것은 아님.

3-3-2 값 검증 시나리오

record MakeTransfer(
   string FromAccount, 
   string ToAccount, 
   decimal Amount, 
   DateTime Date = default)
{
   public static MakeTransfer Dummy => new (default, default, default);
}

public class DateNotPastValidator
{
   public bool IsValid(MakeTransfer transfer)
      => (DateTime.UtcNow.Date <= transfer.Date.Date);
}

테스트

[Test]
public void WhenTransferDateIsFuture_ThenValidationPasses()
{
   var sut = new DateNotPastValidator();
   var transfer = MakeTransfer.Dummy with
   {
      Date = new DateTime(2025, 3, 30)  
   };
 
   var actual = sut.IsValid(transfer);
   Assert.AreEqual(true, actual);
}

이 테스트는 테스트 작성일에만 Pass 되고, 그 이후에는 Fail 됨.
=> 테스트가 반복적이지 않음을 의미.

IsValid가 반환값이 외연 상태(시스템 타임)에 의해 결정되는 비순수 함수이기 때문임.

3-3-3 비순수 함수를 테스트하는 것이 어려운 이유

“유닛 테스트” 에서 "유닛"이란 한 덩어리로 봐야 하는 함수임.

비순수 함수는 매개 변수만 의존하는 게 아니라, 프로그램이나 외부 자원에 의존함. 따라서, 비순수 함수의 테스트를 반복적으로 만드려면, 의존하는 모든 요소를 테스트에 고려해야 함.

이런 설정을 테스트 마다 설정하는 건 쉽지 않고, 귀찮기도 함.

3-4 I/O 를 수행하는 코드의 테스트의 반복성 재고 방법

테스트 대상 코드

public class DateNotPastValidator
{
   public bool IsValid(MakeTransfer transfer)
      => (DateTime.UtcNow.Date <= transfer.Date.Date);
}

DateNotPastValidator는 테스트 시기 마다 다른 값을 반환하기에, 이 코드에 대한 테스트는 반복성이 결여됨.

이에 대해 해결책으로는 우선,

3-4-1 의존성 주입

OOP 프로그래머에게 익숙한 방식

public interface IDateTimeService
{
   DateTime UtcNow { get; }
}
public class DateNotPastValidator(IDateTimeService dateTimeService)
{
   public bool IsValid(MakeTransfer transfer)
      => (dateTimeService.UtcNow <= transfer.Date.Date);
}

테스트 코드

public class DateNotPastValidatorTest
{
   static DateTime presentDate = new DateTime(2021, 3, 12);
 
   private class FakeDateTimeService : IDateTimeService 
   {
      public DateTime UtcNow => presentDate;
   }
 
   [Test]
   public void WhenTransferDateIsPast_ThenValidationFails()
   {
      var svc = new FakeDateTimeService();
      var sut = new DateNotPastValidator(svc);
      var transfer = MakeTransfer.Dummy with
      {
         Date = presentDate.AddDays(-1)
      };
      Assert.AreEqual(false, sut.IsValid(transfer));
   }
}

인터페이스 접근 방식의 단점

소소한 기능을 가진 형식들이 폭증함.
( 인터페이스 한 개 + 구현 객체 두 개) * 인터페이스 갯수

폭증된 코드의 대부분은 보일러 플레이트임.

3-4-2 보일러 코드 없는 방식

인터페이스 대신 값 주입

public record DateNotPastValidator(DateTime Today)
   : IValidator<MakeTransfer>
{
   public bool IsValid(MakeTransfer transfer)
      => Today <= transfer.Date.Date;
}

public void ConfigureServices(IServiceCollection services)
{
   services.AddTransient<DateNotPastValidator>
      (_ => new DateNotPastValidator(DateTime.UtcNow.Date));
}

DateNotPastValidator 는 Transient 로 생성될 때 마다, DateTime 값이 주입됨.

인터페이스 주입 점근법 보다 코드량이 줄어 듦.

값 대신 함수 주입

DateTime.UtcNow 을 I/O 에 접근하는 것과 유사함.
생성할 때 마다, I/O에 접근하면, 모든 생성이 비싸짐.

이를 해결하기 위해 아래와 같이 함수를 주입 받도록 변경하면, 모든 생성이 비싸지는 것을 예방할 수 있음.

public record DateNotPastValidator(Func<DateTime> Clock)
   : IValidator<MakeTransfer>
{
   public bool IsValid(MakeTransfer transfer)
      => Clock().Date <= transfer.Date.Date;
}

주입 코드

public void ConfigureServices(IServiceCollection services)
{
   services.AddSingleton<DateNotPastValidator>
      (_ => new DateNotPastValidator(() => DateTime.UtcNow.Date));
}

테스트 코드

readonly DateTime today = new(2021, 3, 12);
 
[Test]
public void WhenTransferDateIsToday_ThenValidatorPasses()
{
   // 생성된 것을 재사용.
   var sut = new DateNotPastValidator(() => today); 
   var transfer = MakeTransfer.Dummy with { Date = today };
 
   Assert.AreEqual(true, sut.IsValid(transfer));
}

함수에 (강력한) 형식 부여

delegate는 함수에 형식성을 부여하는 수단임.

public delegate DateTime Clock();
 
public record DateNotPastValidator(Clock Clock)
   : IValidator<MakeTransfer>
{
   public bool IsValid(MakeTransfer transfer)
      => Clock().Date <= transfer.Date.Date;
}

Clock 은 단순 Func 에서 "시계"라는 의미가 부여됨.

delegateFunc, Action 에 바로 할당할 수 없는 한계가 있습니다.
(아직 닷넷에서 지원을 하지 않습니다)
이 한계는 delegate.Invoke로 회피할 수 있습니다.

delegate int Double(int n);

var doubles = numbers.Select(Double.Invoke);

3-5 순수성과 컴퓨팅 환경의 진화

현대 컴퓨팅 환경은 프로그램이 직접 연산을 수행하는 것보다 클라우드 서비스에 연산을 위임하는 경우가 더 많아, I/O 의 사용이 증가됨.

I/O의 사용 증가는 순수성을 달성하기가 점점 어려워짐을 의미.
동시에 비동기 I/O 호출이 늘어나 순수성이 더 요구되는 아이러니가 있음.
또한, 멀티 코어 환경이 기본인 상황은 순수성을 요구함.

위에 나열된 I/O 격리 기법이 달라진 환경에 적을하는데 도움이 됨.

이 시리즈는 여기에서 마무리하겠습니다.
좋은 내용이라, 다른 한 번 읽어 보시기 바랍니다.

4 Likes

좋은 글 잘 읽었습니다. 덕분에 많은 공부가 되었습니다. 고맙습니다 :+1:

1 Like