제 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를 분리(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 에서 "시계"라는 의미가 부여됨.
delegate
는 Func
, Action
에 바로 할당할 수 없는 한계가 있습니다.
(아직 닷넷에서 지원을 하지 않습니다)
이 한계는 delegate.Invoke
로 회피할 수 있습니다.
delegate int Double(int n);
var doubles = numbers.Select(Double.Invoke);
3-5 순수성과 컴퓨팅 환경의 진화
현대 컴퓨팅 환경은 프로그램이 직접 연산을 수행하는 것보다 클라우드 서비스에 연산을 위임하는 경우가 더 많아, I/O 의 사용이 증가됨.
I/O의 사용 증가는 순수성을 달성하기가 점점 어려워짐을 의미.
동시에 비동기 I/O 호출이 늘어나 순수성이 더 요구되는 아이러니가 있음.
또한, 멀티 코어 환경이 기본인 상황은 순수성을 요구함.
위에 나열된 I/O 격리 기법이 달라진 환경에 적을하는데 도움이 됨.
이 시리즈는 여기에서 마무리하겠습니다.
좋은 내용이라, 다른 한 번 읽어 보시기 바랍니다.