[모던 자바 인 액션] chap18. 함수형 관점으로 생각하기

2023. 4. 5. 13:34Java

내용
왜 함수형 프로그래밍을 사용하는가?
함수형 프로그래밍은 어떻게 정의하는가?
선언형 프로그래밍과 참조 투명성
함수형 스타일의 자바 구현 가이드 라인
반복과 재귀

 

1. 시스템 구현과 유지보수

함수형 프로그래밍이 제공하는 부작용 없음불변성이라는 개념이 유지보수의 문제를 해결하는데 도움을 준다.

 

1. 공유된 가변 데이터

변수가 예상하지 못한 값을 갖는 이유는 결국 우리가 유지보수하는 시스템의 여러 메서드에서 공유된 가변 데이터 구조를 읽고 갱신하기 때문이다. 어떤 자료구조도 바꾸지 않는 시스템이 있다고 가정하자. 예상하지 못하게 자료구조의 값이 바뀔 일이 없으니 얼마나 유지보수하기 쉽겠는가! 자신을 포함하는 클래스의 상태 그리고 다른 객체의 상태를 바꾸지 않으며 return 문을 통해서만 자신의 결과를 반환하는 메서드를 순수 메서드 또는 부작용 없는 메서드라고 부른다. 구체적으로 부작용은 뭘까? 함수 내에 퐇마되지 못한 기능을 부작용이라고 한다.

  • 자료구조를 고치거나 필드에 값을 할당 (setter 메서드 같은 생성자 이외의 초기화 동작)
  • 예외 발생
  • 파일에 쓰기 등의 I/O 동작 수행

불변객체를 이용해서 부작용을 없애는 방법도 있다. 부작용 없는 시스템 컴포넌트에서는 메서드가 서로 간섭하는 일이 없으므로 잠금을 사용하지 않고도 멀티코어 병렬성을 사용할 수 있다. 또한 프로그램의 어떤 부분이 독립적인지 바로 이해할 수 있다. 부작용 없는 시스템의 개념은 함수형 프로그래밍에서 유래되었다.

 

2. 선언형 프로그래밍 - 작업을 어떻게 수행할 것인지에 집중하는 방법

'어떻게'에 집중하는 프로그래밍 형식은 고전의 객체지향 프로그래밍에서 이용하는 방식이다.

Transaction mostExpensive = transactions.get(0);
if(mostExpensive == null)
	throw new IllegalArgumentException("Empty list of transactions")

for(Transaction t: transactions.subList(1, transactions.size())) {
	if(t.getValue() > mostExpensive.getValue()) {
    	mostExpensive = t;
    }
}

'어떻게'가 아닌 '무엇을'에 집중하는 방식도 있다.

Optional<Transaction> mostExpensive = transactions.stream()
	.max(comparing(Transaction::getValue);

이처럼 '무엇을'에 집중하는 방식을 선언형 프로그래밍이라고 부른다. 선언형 프로그래밍에서는 우리가 원하는 것이 무엇이고 시스템이 어떻게 그 목표를 달성할 것인지 등의 규칙을 정한다. 문제 자체가 코드로 명확하게 드러난다는 점이 선언형 프로그래밍의 강점이다.

 

3. 왜 함수형 프로그래밍인가?

함수형 프로그래밍은 선언형 프로그래밍을 따르는 대표적인 방식이며, 이전에 설명한 것처럼 부작용이 없는 계산을 지향한다. 선언형 프로그래밍과 부작용을 멀리한다는 두 가지 개념은 좀 더 쉽게 시스템을 구현하고 유지보수하는 데 도움을 준다.

 

2. 함수형 프로그래밍이란 무엇인가?

'함수를 이용하는 프로그래밍이다'라고 간단히 답변할 수 있다. '그럼 함수는 무엇인가?'라는 질문이 다시 꼬리를 문다. 함수형 프로그래밍에서 함수란 수학적인 함수와 같다. 즉, 함수는 0개 이상의 인수를 가지며, 한 개 이상의 결과를 반환하지만 부작용이 없어야 한다. '함수 그리고 if-then-else 등의 수학적 표현만 사용'하는 방식을 순수 함수형 프로그래밍이라고 하며 '시스템의 다른 부분에 영향을 미치지 않는다면 내부적으로는 함수형이 아닌 기능도 사용'하는 방식을 함수형 프로그래밍이라고 한다.

 

1. 함수형 자바 - 실질적으로 자바로는 완벽한 순수 함수형 프로그래밍을 구현하기 어렵다.

예를 들어 자바의 I/O 모델 자체에는 부작용 메서드가 포함된다(Scanner.nextLine을 호출하면 파일의 행을 소비한다. 즉, Scanner.nextLihne을 두 번 호출하면다른 결과가 반환될 가능성이 있다.) 하지만 시스템의 컴포넌트가 순수한 함수형인 것처럼 동작하도록 코드를 구현할 수 있다.

  • 함수나 메서드는 지역 변수만을 변경해야 함수형이라 할 수 있다.
  • 함수나 메서드에서 참조하는 객체가 있다면 그 객체는 불변 객체여야 한다. 예외적으로 메서드 내에서 생성한 객체의 필드는 갱신할 수 있다는 사실을 살펴볼 것이다. 단, 새로 생성한 객체의 필드 갱신이 외부에 노출되지 않아야 하고 다음에 메서드를 다시 호출한 결과에 영향을 미치지 않아야 한다.
  • 함수형이라면 함수나 메서드가 어떤 예외도 일으키지 않아야 한다. 예외가 발생하면 이전에 설명한 것처럼 블랙박스 모델에서  return으로 결과를 반환할 수 없게 될 수 있기 때문이다. 이러한 제약은 함수형을 수학적으로 활용하는 데 큰 걸림돌이 될 것이다.
  • 수학적 함수는 주어진 인수값에 대응하는 하나의 결과를 반환한다. 실제 대부분의 수학 연산은 부분 함수로 활용된다. 어떤 입력값이 있을 때 이는 정확하게 하나의 결과로 도출된다. 하지만 입력값이 undefined라면 결과가 아예 나오지 않는다. (예를 들면 0으로 어떤 수를 나눈다던가, sqrt의 인수가 음수인 상황 등) 예외를 사용하지 않고 나눗셈 같은 함수를 어떻게 표현하는가? 이는 Optional<T>를 이용해 해결할 수 있다.
  • 함수형에서는 비함수형 동작을 감출 수 있는 상황에서만 부작용을 포함하는 라이브러리 함수를 사용해야 한다(즉, 먼저 자료구조를 복사한다든가 발생할 수 있는 예제를 적절하게 내부적으로 처리함으로써 자료구조의 변경을 호출자가 알 수 없도록 감춰야 한다).

우리가 만든 함수형 코드에서는 일종의 로그 파일로 디버깅 정보를 출력하도록 구현하는 것이 좋다. 물론 이처럼 디버깅 정보를 출력하는 것은 함수형의 규칙에 위배되지만 로그 출력을 제외하고는 함수형 프로그래밍의 장점을 문제없이 누릴 수 있다.

 

2. 참조 투명성 - '부작용을 감춰야 한다'라는 제약은 참조 투명성 개념으로 귀결된다.

즉, 같은 인수로 함수를 호출했을 때 항상 같은 결과를 반환한다면 참조적으로 투명한 함수라고 표현한다. 다시 말해, 함수는 어떤 입력이 주어졌을 때 언제, 어디서 호출하든 같은 결과를 생성해야 한다. 따라서 Random.nextInt는 함수형이 될 수 없다. 마찬가지로 자바의 Scanner 객체로 사용자의 키보드 입력을 받는다면 참조 투명성을 위배한다. nextLine 메서드를 호출했을 때 매번 다른 결과가 나올수 있기 때문이다. 하지만 두 개의 final int 변수를 더하는 연산에서는 두 변수를 바꿀 수 없으므로 이 연산은 항상 같은 결과를 생성한다.

 

3. 객체지향 프로그래밍과 함수형 프로그래밍

프로그래밍 형식을 스펙트럼으로 표현하자면 한 쪽 끝에는 모든 것을 객체로 간주하고 프로그램이 객체의 필드를 갱신하고, 메서드를 호출하고, 관련 객체를 갱신하는 방식으로 동작하는 익스트림 객체지향 방식이 위치한다.

반대쪽 끝에는 참조적 투명성을 중시하는, 즉 변화를 허용하지 않는 함수형 프로그래밍 형식이 위치한다. 실제로 자바 프로그래머는 이 두가지 프로그래밍 형식을 혼합한다.

 

4. 함수형 실전 연습

함수형을 처음 접하는 학생들에게 종종 제공되는 간단한 예제를 살펴보자. {1, 4, 9}처럼 List<Integer>가 주어졌을 때 이것의 모든 서브집합의 멤버로 구성된 List<List<Integer>>를 만드는 프로그램을 만든다고 가정하자. 예를 들어 {1, 4, 9}의 서브 집합은 {1, 4, 9}, {1, 4}, {1, 9}, {4, 9}, {1}, {4}, {9}, { }다.

빈 집합 { }를 포함해서 총 8개의 서브집합이 존재한다. 각 서브집합은 List<Integer> 형식으로 이루어져 있으므로 최종 정답의 형식은 List<List<Integer>>다.

public static <T> List<List<T>> subsets(List<T> l) {
    if (l.isEmpty()) {
      List<List<T>> ans = new ArrayList<>();
      ans.add(Collections.emptyList());
      return ans;
    }
    T first = l.get(0);
    List<T> rest = l.subList(1, l.size());
    
    List<List<T>> subans = subsets(rest);
    List<List<T>> subans2 = insertAll(first, subans);
    return concat(subans, subans2);
}

List<T> insertAll(T first, List<List<T>> lists) {
	List<List<T>> result = new ArrayList<>();
    for (List<T> l : lists) {
      List<T> copyList = new ArrayList<>();
      copyList.add(first);
      copyList.addAll(l);
      result.add(copyList);
    }
    return result;
}
// first가 불변이기 때문에 first는 복사하지 않았다.

static <T> List<List<T>> concat(List<List<T>> a, List<List<T>> b) {
    List<List<T>> r = new ArrayList<>(a);
    // 참조하는 변수를 변경할일이 없도록 복사를 해서 사용한다.
    r.addAll(b);
    return r;
}

인수에 의해 출력이 결정되는 함수형 메서드의 관점에서 프로그램 문제를 생각하자(즉, 무엇을 해야 하는가에 중점을 둔다).

 

3. 재귀와 반복

순수 함수형 프로그래밍 언어에서는 while, for 같은 반복문을 퐇마하지 않는다. 왜 그럴까? 이러한 반복문 때문에 변화가 자연스럽게 코드에 스며들 수 있기 때문이다. 예를 들어 while 루프의 조건문을 갱신해야 할 때가 있다. 그렇지 않으면 루프가 아예 실행되지 않거나 무한으로 반복될 수 있다. 이 외의 일반적인 상황에서는 루프를 안전하게 사용할 수 있다.

Iterator<Apple> it = apples.iterator();
while (it.hasNext()) {
	Apple apple = it.next();
    // ...
}
// 위 코드에서 호출자는 변화를 확인할 수 없으므로 아무 문제가 없다. 
// (즉, next로 Iterator의 상태를 변환했고, while 바디 내부에서 apple 변수에 할당하는 동작을 할 수 있다.)

public void searchForGold(List<String> l, Stats stats) {
	for(String s: l) {
    	if("gold".equals(s)) {
        	stats.incrementFor("gold");
        }
    }
}
// 루프의 바디에서 함수형과 상충하는 부작용이 발생한다. 
// 즉 루프 내부에서 프로그램의 다른 부분과 공유되는 stats 개체의 상태를 변화시킨다.

 

이론적으로 반복을 이용하는 모든 프로그램은 재귀로도 구현할 수 있는데 재귀를 이용하면 변화가 일어나지 않는다. 재귀를 이용하면 루프 단계마다 갱신되는 반복 변수를 제거할 수 있다.

일반적으로 반복 코드보다 재귀 코드가 더 비싸다. 왜 그럴까? 함수를 호출할 때마다 호출 스택에 각 호출시 생성되는 정보를 저장할 새로운 스택 프레임이 만들어진다. 즉, 재귀 팩토리얼의 입력값에 비례해서 메모리 사용량이 증가한다. 따라서 큰 입력값을 사용하면 StackOverflowError가 발생한다. 그렇다면 재귀란 쓸모 없는 것일까? 물론 그렇지 않다. 함수형에서는 꼬리 호출 최적화라는 해결책을 제공한다.

static factorialRecursive(long n) {
	return n == 1 ? 1 : n * factorialRecursive(n-1);
}
// 일반적인 재귀

static long factorialTailRecursive(long n) {
	return factorialHelper(1, n);
}

static long factorialHelper(long acc, long n) {
	return n == 1 ? acc : factorialHelper(acc * n, n-1);
}
// 꼬리 호출 최적화

factorialHelper의 재귀 호출이 가장 마지막에서 이루어지므로 꼬리 재귀다. 반면 이전의 factorialRecursive에서 마지막으로 수행한 연산은 n과 재귀 호출의 결과값의 곱셈이다. 중간 결과를 각각의 스택 프레임으로 저장해야 하는 일반 재귀와 달리 꼬리 재귀에서는 컴파일러가 하나의 스택 프레임을 재활용할 가능성이 생긴다. 사실 factorialHelper의 정의에서는 중간결과(팩토리얼의 부분결과)를 함수의 인수로 직접 전달한다.

안타깝게도 자바는 이와 같은 최적화를 제공하지 않는다. 그럼에도 여전히 고전적인 재귀보다는 여러 컴파일러 최적화 여지를 남겨둘 수 있는 꼬리 재귀를 적용하는 것이 좋다.
결론적으로 자바 8에서는 반복을 스트림으로 대체해서 변화를 피할 수 있다. 또한 반복을 재귀로 바꾸면 더 간결하고, 부작용이 없는 알고리즘을 만들 수 있따. 실제로 재귀를 이용하면 좀 더 쉽게 읽고, 쓰고, 이해할 수 있는 예제를 만들 수 있다.

 

마치며

  • 공유된 가변 자료구조를 줄이는 것은 자익적으로 프로그램을 유지보수하고 디버깅하는 데 도움이 된다.
  • 함수형 프로그래밍은 부작용이 없는 메서드와 선언형 프로그래밍 방식을 지향한다.
  • 함수형 메서드는 입력 인수와 출력 결과만을 갖는다.
  • 같은 인수값으로 함수를 호출했을 때 항상 같은 값을 반환하면 참조 투명성을 갖는 함수다. while 루프 같은 반복문은 재귀로 대체할 수 있다.
  • 자바에서는 고전 방식의 재귀보다는 꼬리 방식의 재귀를 사용해야 추가적인 컴파일러 최적화를 기대할 수 있다.