대메뉴 바로가기 본문 바로가기

데이터 기술 자료

데이터 기술 자료 상세보기
제목 함수형 프로그래밍 F# : SOLID 원칙과 고차 함수
등록일 조회수 6369
첨부파일  

함수형 프로그래밍 F#

SOLID 원칙과 고차 함수



객체지향 프로그래밍은 객체 단위로 조합해 기능을 확장한다. 그러나 함수형 프로그래밍은 좀 더 간단한 함수 단위로 조합해 기능을 확장할 수 있다. 함수형 프로그래밍에서는 고차 함수를 제공하기 때문에 인터페이스와 클래스 없이 함수만으로 조합할 수 있는 환경을 제공한다. 이번 시간에는 고차 함수의 주요 특징을 객체지향 SOLID 설계 원칙으로부터 살펴본다.



객체지향 설계에 관심이 많은 개발자라면 GoF 디자인 패턴과 아키텍처 패턴에 대해 들어봤을 것이다. 이런 대표적인 패턴들은 SOLID 원칙을 잘 준수하고 있다. SOLID 원칙은 로버트 C. 마틴(Robert Cecil Martin)이 2000년대 초반에 명명한 객체지향 설계 원칙이다. SOLID 원칙은 유지보수성과 재사용성이 높은 소프트웨어 설계를 위해 낮은 결합도(Coupling)와 높은 응집도(Cohesion)로 방향을 제시한다. SOLID 원칙의 세부 내용은 <표 1>과 같이 5개의 세부 원칙으로 구성돼 있다. SOILD 원칙은 기본적으로 객체지향 프로그래밍을 대상으로 하지만 낮은 결합도와 높은 응집도를 지향한다는 관점에서 함수형 프로그래밍에도 유효하다. SOLID 원칙들 중 가장 대표적인 ‘단일 책임 원칙’과 ‘의존관계 역전 원칙’ 그리고 ‘개방-폐쇄 원칙’이 어떻게 함수형 프로그래밍에 적용되는지 살펴볼 것이다. <리스트 1>은 양의 정수 컬렉션 데이터에서 소수와 짝수를 구해 출력하는 예제 프로그램이다.





<리스트 1> 양의 정수 짝수와 소수 출력 Using System; using System.Collections.Generic; namespace _01_Even_and_Prime { class Program { static void Main(string[] args) { int[] numbers = { 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15 }; Print("All:", numbers); Print("Evens:", GetEvens(numbers)); Print("Primes:", GetPrimes(numbers)); } public static void Print(string label, int[] source) { Console.WriteLine(label); foreach (int item in source) Console.Write(" {0}", item); Console.WriteLine(" "); } static int[] GetEvens(int[] numbers) { List result = new List(); if (numbers != null) { foreach (int number in numbers) { if ((number % 2) == 0) result.Add(number); } } return result.ToArray(); } static int[] GetPrimes(int[] numbers) { List result = new List(); if (numbers != null) { foreach (int number in numbers) { bool isPrime = true; for (int i = 2; i < number; i++) { if (number % i == 0) { isPrime = false; break; } } if (isPrime) result.Add(number); } } return result.ToArray(); } } }



<리스트 1>을 컴파일한 후 실행하면 <그림 1>과 같이 1에서 15까지 양의 정수에서 짝수와 소수가 정상적으로 출력되는 것을 확인할 수 있다.



<리스트 1>의 구현 코드를 자세히 살펴보면, 짝수는 GetEvens 함수에서 처리하며 소수는 GetPrimes 함수에서 독립적으로 구현돼 있어 재사용되는 코드가 전혀 없다. 또한 명령형(Imperative) 프로그래밍 패러다임 특징으로 인해 양의 정수 데이터에서 짝수와 소수를 구하는 문제를 해결하기 위해 어떻게(How) 할 것이지 각 단계를 구체적으로 기술하고 있다. 선언형(Declarative)에 뿌리를 두고 있는 함수형 프로그래밍 패러다임에서는 SOLID 원칙 입장에서 이런 문제점들을 어떻게 해결하고 있는지 살펴보자.



단일 책임 원칙

단일 책임 원칙에서 GetEvens와 GetPrimes 함수를 다시 살펴보자. <그림 2>와 같이 두 함수 모두 공통으로 ‘데이터 접근’과 ‘판단 알고리즘’ 그리고 ‘데이터 추가’의 세 가지 책임 함수를 갖고 있다. ‘한 클래스는 하나의 책임만 가져야 한다’의 의미를 클래스에서 함수 영역까지 확대한다면 GetEvens와 GetPrimes 함수는 <그림 2>에서 확인한 책임에 따라 분리해야 한다. 함수 또는 클래스는 하나의 책임만 가지며, 그 책임을 캡슐화한다. 하나의 책임을 캡슐화(Encapsulation)한 함수나 클래스는 높은 응집도를 갖는다. 그 결과 책임 단위로 결합(Composable)해 요구사항을 처리할 수 있다.



책임 분리를 위해 가장 먼저 수행할 리팩토링(Refactoring) 작업은 책임 단위로 함수를 분리(Extract Method)하는 것이다. 세 가지 책임 중에서 우선 ‘판단 알고리즘’을 GetEvens와 GetPrimes 함수로부터 분리하면 <리스트 2>와 같다. 짝수 판단 알고리즘은 IsEven 함수로, 소수 판단 알고리즘은 IsPrime 함수로 분리할 수 있다.



<리스트 2> 판단 알고리즘을 함수로 분리 static int[] GetEvens(int[] numbers) { List result = new List(); if (numbers != null) { foreach (int number in numbers) { if (IsPrime(number)) result.Add(number); } } return result.ToArray(); } static int[] GetPrimes(int[] numbers) { List result = new List(); if (numbers != null) { foreach (int number in numbers) { if (IsPrime(number)) result.Add(number); } } return result.ToArray(); } static bool IsEven(int number) { return (number % 2) == 0; } static bool IsPrime(int number) { for (int i = 2; i < number; i++) { if (number % i == 0) return false; } return true; }



<리스트 2>을 통해 판단 알고리즘이 개별 함수(IsEven, IsPrime)로 분리됐지만, 여전히 GetEvens과 GetPrimes 함수에 의존하고 있다. 의존성으로 인해 아직까지 완전히 책임이 분리되지 않는 것을 확인할 수 있다.



의존관계 역전 원칙

GetEvens와 GetPrimes에서 판단 알고리즘인 IsEven과 IsPrime의 의존성 제거를 위한 대표적인 방법은 인터페이스를 이용하는 것이다. 인터페이스는 제공할 서비스의 메소드 시그니처(Signature)만을 갖고 있기 때문에 인터페이스를 사용하는 곳에서는 구체적인 구현 내용을 알 수 없다. GetEvens와 GetPrimes에서 판단 알고리즘을 인터페이스로 분리하면 짝수와 소수를 판단하는 구체적인 알고리즘으로부터 분리할 수 있게 된다. <리스트 3>은 판단 알고리즘의 구체적인 구현 내용을 은닉시키기 위한 IPredicate 인터페이스를 정의한다. 그리고 정의된 인터페이스로부터 상속을 받은 클래스에 짝수와 소수 판단 알고리즘을 각각 구현한다.



<리스트 3> 인터페이스 상속을 통한 판단 알고리즘 구현 public interface IPredicate { bool Predicate(int number); } public class Even : IPredicate { public bool Predicate(int number) { return (number % 2) == 0; } } public class Prime : IPredicate { public bool Predicate(int number) { for (int i = 2; i < number; i++) { if (number % i == 0) return false; } return true; } }



판단 알고리즘이 인터페이스로 분리됐기 때문에 GetEvens와 GetPrimes 함수에서는 <리스트 4>와 같이 해당 인터페이스를 메소드 인자로 전달 받도록 변경한다.



<리스트 4> 판단 알고리즘을 함수 파라미터로 전달 static int[] GetEvens(int[] numbers, IPredicate predicate) { List result = new List(); if (numbers != null) { foreach (int number in numbers) { if (predicate.Predicate(number)) result.Add(number); } } return result.ToArray(); } static int[] GetPrimes(int[] numbers, IPredicate predicate) { List result = new List(); if (numbers != null) { foreach (int number in numbers) { if (predicate.Predicate(number)) result.Add(number); } } return result.ToArray(); }



개방-폐쇄 원칙

판단 알고리즘 사용자(GetEvens와 GetPrimes)와 판단 알고리즘(IsEven과 IsPrime)의 책임이 인터페이스로 분리됐다. 그리고 인터페이스로 서로가 의존하기 때문에 판단 알고리즘 사용자는 판단 알고리즘에 구체적인 내용을 알 수 없다. 책임이 클래스 단위로 분리(단일 책임 원칙)돼 있으며, 분리된 클래스는 구체적인 클래스가 아닌 인터페이스 단위로 의존(의존관계 역전 원칙)하고 있기 때문이다. 단일 책임 원칙과 의존관계 역전 원칙을 준수한 결과, 특정 판단 알고리즘에 개선이 필요하다면 판단 알고리즘 단위로 개선 작업을 수행할 수 있다. 추가로 판단 알고리즘이 필요하다면 판단 알고리즘 사용자(GetEvens와 GetPrimes)에 대한 변경 없이(Closed) IPredicate 인터페이스를 상속 받은 클래스만 만들면 새로운 기능이 추가(Open)된다. <리스트 5>에서는 분리된 판단 알고리즘을 주입하는 과정을 확인할 수 있다.



<리스트 5> 판단 알고리즘 주입 static void Main(string[] args) { int[] numbers = { 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15 }; Print("All:", numbers); Print("Evens:", GetEvens(numbers, new Even())); Print("Primes:", GetPrimes(numbers, new Prime())); }



‘단일 책임 원칙’, ‘의존관계 역전 원칙’, ‘개방-폐쇄 원칙’을 통해 수정한 GetEvens와 GetPrimes 함수를 확인해 보면 <그림 3>과 같이 함수 이름만 제외하고 100% 동일한 코드인 것을 확인할 수 있다.



함수 이름을 Filter로 하고 <리스트 6>과 같이 GetEvens와 GetPrimes의 개별 함수를 하나의 함수로 통합할 수 있다.



<리스트 6> GetEvens와 GetPrimes 함수 통합 static int[] Filter(int[] numbers, IPredicate predicate) { List result = new List(); if (numbers != null) { foreach (int number in numbers) { if (predicate.Predicate(number)) result.Add(number); } } return result.ToArray(); }



이전까지 개별 단위로 구현돼 재사용이 없던 GetEvens와 GetPrimes 함수가 <리스트 7>과 같이 구체적인 판단 알고리즘에 독립된 Filter 함수도 재사용할 수 있게 됐다.



<리스트 7> Filter 함수 재사용 static void Main(string[] args) { int[] numbers = { 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15 }; Print("All:", numbers); //Print("Evens:", GetEvens(numbers, new Even())); Print("Evens:", Filter(numbers, new Even())); //Print("Primes:", GetPrimes(numbers, new Prime())); Print("Primes:", Filter(numbers, new Prime())); }



데이터 접근 책임 분리

지금까지 판단 알고리즘 책임을 분리시켰다. 그럼 판단 알고리즘 분리와 동일한 방법으로 데이터 접근 책임도 분리해 보자. 판단 알고리즘을 분리하기 위해 명시적으로 IPredicate를 정의했지만, 데이터 접근을 위한 인터페이스는 이미 .NET에서 IEnumerable 인터페이스로 제공하고 있다. IEnumerable 인터페이스는 foreach와 함께 내부적으로 데이터 접근 방법을 은닉하게 된다. <리스트 8>의 Filter 함수는 데이터 접근 방법과 판단 알고리즘이 모두 인터페이스로 은닉돼 있다. 그 때문에 구체적인 구현 내용은 메소드 인자로 런타임에 의해 결정된다.



<리스트 8> 데이터 접근 책임 분리 //static int[] Filter(int[] numbers, Predicate predicate) static IEnumerable Filter(IEnumerable numbers, IPredicate predicate) { List result = new List(); if (numbers != null) { foreach (int number in numbers) { if (predicate(number)) result.Add(number); } } return result; }



데이터 접근 책임 또한 판단 알고리즘과 같이 인터페이스로 분리됐다. 그 덕분에 데이터 접근 방법을 Filter 함수 수정 없이 외부에서 결정할 수 있다. <리스트 9>는 이를 확인하기 위한 프로그램이다. OrderByDescending은 내림 차순 정렬 데이터 접근을 제공하는 함수이다. OrderByDescending을 통해 전달된 Filter 함수와 OrderByDescending 없이 바로 데이터가 전달된 Filter 함수를 확인할 수 있다.



<리스트 9> 내림 차순 정렬 static void Main(string[] args) { int[] numbers = { 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15 }; Print("All:", numbers); Print("Evens:", Filter(numbers, new Even())); Print("Evens-OrderByDescending:", Filter(OrderByDescending(numbers), new Even())); Print("Primes:", Filter(numbers, new Prime())); Print("Primes-OrderByDescending:", Filter(OrderByDescending(numbers), new Prime())); } static public IEnumerable OrderByDescending(IEnumerable source) { List list = new List(); list.AddRange(source); Comparison compare = delegate(int a, int b) { return ((IComparable)b).CompareTo(a); }; list.Sort(compare); return list; }



OrderByDescending 없이 전달된 Filter 함수는 원본 데이터 순서대로 결과가 출력되며 OrderByDescending을 통해 호출된 Filter 함수는 내림 차순으로 결과가 출력되는 것을 확인할 수 있다(<그림 4> 참조).





고차 함수

지금까지는 객체지향 프로그래밍 입장에서 책임을 인터페이스 기준으로 분리시켜 책임 단위로 기능을 조합할 수 있음을 알아봤다. 이를 통해 책임이 클래스 단위로 캡슐화돼 있으며, 캡슐화된 책임이 메소드 인자로 전달되고 있는 것을 확인했다. 함수형 프로그래밍에서는 클래스 단위가 아닌 함수 단위로 책임이 캡슐화되며, 캡슐화된 함수가 메소드 인자로 전달될 수 있다. 특히 캡슐화된 함수를 인자로 받거나 결과를 내보낼 수 있는 함수는 고차 함수(High-Order Function 또는 고계 함수)라고 한다.

함수형 프로그래밍에서는 함수 단위로 캡슐화할 수 있기 때문에 인터페이스와 클래스 없이 함수만으로 책임을 분리하고 의존성을 제거함으로써 확장에는 열려 있으나 변경에는 닫히도록 설계할 수 있다. 객체지향보다 불필요한 코드를 최소화해 문제의 본질에 집중할 수 있는 간결한 코드를 구현할 수 있는 것이다.

.NET 프레임워크 3.5부터 결과 값이 있는 메소드를 캡슐화하기 위한 Func을 제공하고 있다. <리스트 10>에서 사용한 Func은 메소드 입력 인자 타입이 int형이며, 결과 타입이 bool인 함수를 캡슐화한다. Func을 이용한 Filter 함수는 입력 타입이 int형이며 결과 타입이 bool인 함수를 인자로 받을 수 있는 고차 함수가 됐다. Func은 구체적인 메소드 구현 내용이 없기 때문에 인터페이스 역할을 수행하게 된다. Filter 함수가 고차 함수로 변경됐기 때문에 더 이상 인터페이스와 클래스 구현 없이 함수만으로 책임을 분리해 전달할 수 있게 됐다.



<리스트 10> 고차 함수 Filter static void Main(string[] args) { int[] numbers = { 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15 }; Print("All:", numbers); Print("Evens:", Filter(numbers, IsEven)); Print("Evens-OrderByDescending:", Filter(OrderByDescending(numbers), IsEven)); Print("Primes:", Filter(numbers, IsPrime)); Print("Primes-OrderByDescending:", Filter(OrderByDescending(numbers), IsPrime)); } static IEnumerable Filter(IEnumerable numbers, Func predicate) { List result = new List(); if (numbers != null) { foreach (int number in numbers) { if (predicate(number)) result.Add(number); } } return result.ToArray(); } public static bool IsEven(int number) { return (number % 2) == 0; } public static bool IsPrime(int number) { for (int i = 2; i < number; i++) { if (number % i == 0) return false; } return true; }



고차 함수로 수정된 Filter와 데이터 접근 책임을 구현한 OrderByDescending 함수를 C#에서 제공하는 확장 메소드(Extension Method)로 변경하면 <리스트 11>과 같다.



<리스트 11> Filter와 OrderByDescending 확장 메소드 public static class Extensions { public static IEnumerable Filter(this IEnumerable numbers, Func predicate) { List result = new List(); if (numbers != null) { foreach (TSource number in numbers) { if (predicate(number)) result.Add(number); } } return result; } static public IEnumerable OrderByDescending (this IEnumerable source) { List list = new List(); list.AddRange(source); Comparison compare = delegate(TSource a, TSource b) { return ((IComparable)b).CompareTo(a); }; list.Sort(compare); return list; } }



Filter와 OrderByDescending 함수 모두 IEnumerable 인터페이스를 확장했기 때문에 <리스트 12>와 같이 ‘Filter(OrderByDescending(numbers), IsEven);’ 스타일이 아닌 ‘numbers.OrderByDescending().Filter(IsEven);’과 같이 연속적인 메소드 호출 파이프라인으로 표현할 수 있다. 파이프라인은 책임 단위로 함수가 분리되기 때문에 코드의 가독성을 높일 수 있다.



<리스트 12> 확장 메소드를 활용한 파이프라인 static void Main(string[] args) { int[] numbers = { 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15 }; Print("All:", numbers); //Print("Evens:", Filter(numbers, IsEven)); Print("Evens:", numbers .Filter(IsEven)); //Print("Evens-OrderByDescending:", Filter(OrderByDescending(numbers), IsEven)); Print("Evens-OrderByDescending:", numbers .OrderByDescending() .Filter(IsEven)); //Print("Primes:", Filter(numbers, IsPrime)); Print("Primes:", numbers .Filter(IsPrime)); //Print("Primes-OrderByDescending:", Filter(OrderByDescending(numbers), IsPrime)); Print("Primes-OrderByDescending:", numbers .OrderByDescending() .Filter(IsPrime)); }



지금까지 구현한 고차 함수 Filter와 인터페이스로 분리된 OrderByDescending 함수는 이미 LINQ을 통해 모두 고차 함수로 제공하고 있다. <리스트 13>은 지금까지 확인한 짝수와 소수를 구하는 요구사항을 LINQ로 구현한 것이다. LINQ에서 제공하는 OrderByDescending과 Where 메소드는 인자로 함수를 전달 받을 수 있는 고차 함수이다. 인자로 전달된 함수는 구체적인 구현 로직을 갖게 된다. 책임 단위로 분리된 LINQ 고차 함수는 파이프라인으로 연결된 것을 확인할 수 있다.



<리스트 13> LINQ을 이용한 고차 함수 using System.Linq; class Program { static void Main(string[] args) { int[] numbers = { 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15 }; Print("All:", numbers); Print("Evens:", numbers .Where(IsEven)); Print("Evens-OrderByDescending:", numbers .OrderByDescending(Selector) .Where(IsEven)); Print("Primes:", numbers .Where(IsPrime)); Print("Primes-OrderByDescending:", numbers .OrderByDescending(Selector) .Where(IsPrime)); } static int Selector(int number) { return number; } public static bool IsEven(int number) { return (number % 2) == 0; } public static bool IsPrime(int number) { for (int i = 2; i < number; i++) { if (number % i == 0) return false; } return true; } }



파이프라인 단위로 함수의 책임을 확인해 보면 <표 2>와 같다. 자연어 수준의 높은 코드 추상화로 요구사항을 표현할 수 있다. 선언형에 뿌리를 두고 있는 함수형 프로그래밍은 명령형 프로그래밍과 같이 ‘문제를 해결하기 위해 어떻게(How) 할 것인가’를 표현하지 않는다(숨긴다). 대신 ‘문제 해(解)의 특성을 설명하기 위해 무엇(What)을 할 것인가’를 표현하는 데 무게를 둔다. 그 때문에 함수형 프로그래밍은 명령형 프로그래밍보다 더 높은 추상화를 제공한다. <리스트 14>는 함수형 언어인 F#을 이용해 짝수를 구하는 예제 프로그램이다.





<리스트 14> F#을 이용한 짝수 구하기 [1 .. 15] |> List.sortBy(fun x -> -x) |> List.filter(fun x -> x % 2 = 0) |> List.iter(printfn "%d")



‘|>’는 파이프라인 연산자이며 List의 sortBy, filter, iter 함수는 함수를 인자로 받을 수 있는 고차 함수이다. 인자로 전달할 함수는 람다 표현식을 나타내는 fun을 이용해 ‘x -> -x’와 ‘x -> x % 2’를 전달하고 있다.



출처 : 마이크로소프트웨어 5월호

제공 : 데이터전문가 지식포털 DBguide.net