모던 자바 인 액션: 함수형 관점으로 생각하기

2024. 7. 15. 12:35JAVA/Language

다음 글은 모던 자바 인 액션 도서의 18장 함수형 관점으로 생각하기 장의 내용을 정리한 글입니다. 틀린 내용이 있을 수 있습니다.

 

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

1.1 공유된 가변 데이터

어떤 변수에 저장된 데이터 값이 예상치 못하게 변경되는 경우가 있습니다. 변경되는 이유는 다양하지만 대표적으로 여러 메서드에서 해당 변수에 접근하여 값을 변경하는 사례가 존재합니다. 이는 해당 변수가 가변적인 데이터 구조를 가지기 때문입니다.

 

예를 들어 다음 그림과 같이 어떤 하나의 리스트가 있고 클래스 A,B,C에서 읽고 쓰고하여 리스트를 동시적으로 갱신할 수 있습니다.

 

 

위와 같이 동시적으로 리스트의 값을 갱신하면 예상치 못한 값이 추가되거나 제거될 수 있습니다. 이러한 문제를 해결하기 위해서 대표적으로 2가지 방법이 존재합니다.

  • 순수 메서드 사용
    • 자신을 포함하는 클래스의 상태와 다른 객체의 상태를 변경하지 않고 return 문을 통해서만 자신의 결과를 반환하는 메서드를 의미합니다.
  • 불변 객체 사용
    • 불변 객체는 객체를 생성한 다음에 객체의 상태를 변경할 수 없는 객체를 의미합니다.
    • 불변 객체를 사용하면 예상하지 못한 상태로 변경할 수 없기 때문에 스레드 세이프합니다.

 

1.2 선언형 프로그래밍

선언형 프로그래밍은 문제를 어떻게 푸는 것이 아닌 문제를 해결하기 위해서 무엇을 해야 하는지에 대해서 집중하는 프로그래밍 방식입니다. 반대로 문제를 어떻게 푸는 것에 집중하는 방식은 명령형 프로그래밍 방식이라고 합니다.

다음은 명령형 프로그래밍의 예시입니다. 코드를 보면 1~3의 합계를 구하기 위해서 반복문을 사용하고 각각의 요소들을 참조하여 더한 다음에 누적하는 것을 볼 수 있습니다. 이와 같은 설명처럼 1~3의 합계를 구한다는 문제를 해결하기 위해서 어떻게 해결하는지 코드로 구현하고 있는 것을 볼수 있습니다. 이와 같은 방식을 명령형 프로그래밍 방식이라고 합니다.

int sum = 0;
List<Integer> list = List.of(1,2,3);
for(int i = 0; i < list.size(); i++){
	sum += list.get(i);
}

다음은 1~3의 합계 문제를 해결하기 위해서 선언형 프로그래밍 방식으로 구현한 것입니다. 스트림 API를 이용하여 무엇을 할것인지 선언하는 것을 볼 수 있습니다. 물론 상세한 구현 방식은 라이브러리 내부에 숨겨져 있습니다.

List<Integer> list = List.of(1,2,3);
int sum = list.stream()
  .mapToInt(Integer::valueOf)
  .sum();

 

정리하면 선언형 프로그래밍 방식은 문제를 해결하기 위해서 무엇을 할것인지 명시하는 방식입니다.

 

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

함수형 프로그래밍은 선언형 프로그래밍을 따르는 대표적인 방식입니다. 그리고 함수형 프로그래밍을 사용하게 되면 메서드에 부작용 없는 계산을 할 수 있도록 합니다. 부작용 없는 메서드를 구현하기 위한 대표적으로 방법으로 스트림 API를 사용하거나 람다 표현식을 사용하는 방법이 존재합니다.

 

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

함수형 프로그래밍은 부작용 없는 메서드를 구현하는 것을 의미합니다. 부작용 없는 메서드를 구현함으로써 여러 클래스에서 동시에 하나의 데이터에 접근하여도 이 데이터가 예상치 못한 값으로 변경되는 것을 막을 수 있도록 합니다.

 

함수형 프로그래밍에서 함수란 함수를 호출했을 때 외부의 객체나 인수로 전달하는 객체의 상태 값이 변경되지 않고 return문을 통해서만 연산된 값을 반환하는 기능을 의미합니다. 예를 들어 int와 double을 인수로 받아서 double을 반환하는 메서드가 있습니다. 하지만 메서드를 호출하면 객체의 필드를 변경하는 부작용이 있습니다. 이와 같은 메서드는 함수라고 할 수 없습니다. 왜냐하면 해당 메서드에 부작용이 있기 때문입니다.

 

 

 

함수의 특징

  • 0개 이상의 인수를 가집니다
  • 함수는 1개 이상의 결과를 반환하지만 부작용이 없어야 합니다.

정리

  • 함수형 프로그래밍이란 부작용이 없는 메서드를 구현하는 것을 의미하고 만약 구현한 메서드가 메서드의 외부나 인수로 전달한 객체의 필드 상태를 변경한다면 메서드는 부작용을 포함하기 때문에 함수라고 부를 수 없습니다.

 

2.1 함수형 자바

자바 언어로 완벽한 순수 함수형 프로그래밍을 하기는 어렵습니다. 하지만 시스템의 컴포넌트가 순수 함수형인 것처럼 동작하도록 코드를 구현할 수 있습니다.

 

함수형의 특징

  • 함수나 메서드는 지역 변수만을 변경할 수 있어야 합니다.
  • 함수나 메서드에서 참조하는 객체가 있으면 그 객체는 불변 객체여야합니다.
    • 객체의 모든 필드가 final이 적용되어야 합니다.
    • 예외적으로 메서드 내에서 생성한 객체의 필드는 변경할 수 있습니다.
      • 새로 생성한 객체의 필드 갱신이 외부에 노출되지 않아야 합니다.
      • 다음에 호출하는 메서드에 결과에 영향을 미치지 않아야 합니다.
  • 함수나 메서드가 어떤 예외도 일으키지 않아야 합니다.
    • 이 문제를 해결하기 위해서 반환 타입을 Optional로 정의하여 예외 발생시 Optional 타입의 객체를 반환하도록 하여 문제를 해결합니다.
  • 함수형에서는 비함수형 동작을 감출 수 있는 상황에서만 부작용을 포함하는 라이브러리 함수를 사용해야 합니다.
    • 예를 들어 insertAll이라는 메서드 내에서 List.add를 호출하기 전에 미리 리스트를 복사해서 라이브러리 함수에서 일으키는 부작용을 감추는 예시가 있습니다.

 

 

2.2 참조 투명성

참조 투명성이란 똑같은 인수로 함수를 호출할 때 항상 같은 결과를 반환하는 특징입니다.

예를 들어 String 타입의 a 문자열(raoul)이 replace 메서드를 호출하여 소문자 “r”을 전부 대문자 “R”로 변경하여 result 변수에 반환합니다. 이때 a 변수는 replace 메서드를 호출했음에도 “raoul”을 유지함을 볼수 있습니다. 그 이유는 replace 메서드를 호출할때 결과를 반환할 새로운 문자열 객체를 생성하기 때문입니다. 그래서 a 문자열을 대상으로 다음과 같이 똑같이 여러번 호출해도 동일한 결과를 반환하는 것입니다. 이와 같은 String 클래스의 replace 메서드는 참조 투명성을 갖는다고 말할 수 있습니다.

String a = "raoul"
String result = a.replace("r", "R"); # 항상 Raoul이 반환됨
System.out.println(a); // output: raoul
System.out.println(result); // output: Raoul

 

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

특징 / 프로그래밍 방식 함수형 프로그래밍 객체지향 프로그래밍
참조 투명성 O X
객체 필드값 변화 X O

 

2.4 함수형 실전 연습

함수형 프로그래밍을 이해하기 위해서 집합의 부분 집합을 구하는 예제를 구현해봅니다. 예를 들어 {1, 4, 9}라는 집합이 존재하고 해당 집합의 부분집합은 공집합을 포함한 {1, 4, 9}, {1, 4}, {1, 9}, {4, 9}, {1}, {4}, {9}, { } 입니다.

부분 집합을 구하기 위한 subsets 메서드는 다음과 같이 구현할 수 있습니다.

/**
 * {1, 4, 9} 집합이 주어질때 해당 집합의 부분집합을 구하는 예제
 * - 함수형 프로그래밍 방식으로 구현
 * - 공집합(빈 리스트)도 부분집합으로 취급한다
 */
public class Main {
	public static void main(String[] args) {
		List<List<Integer>> result = subsets(List.of(1, 4, 9));
		System.out.println(result);
	}

	public static List<List<Integer>> subsets(List<Integer> list){
		if (list.isEmpty()){
			List<List<Integer>> ans = new ArrayList<>();
			ans.add(Collections.emptyList());
			return ans;
		}
		Integer first = list.get(0);
		List<Integer> rest = list.subList(1, list.size());

		List<List<Integer>> subans = subsets(rest);
		List<List<Integer>> subans2 = insertAll(first, subans);
		return concat(subans, subans2);
	}

	private static List<List<Integer>> insertAll(Integer first, List<List<Integer>> lists) {
		List<List<Integer>> result = new ArrayList<>();

		for (List<Integer> list : lists){
			List<Integer> copyList = new ArrayList<>();
			copyList.add(first);
			copyList.addAll(list);
			result.add(copyList);
		}
		return result;
	}

	private static List<List<Integer>> concat(List<List<Integer>> a, List<List<Integer>> b) {
		List<List<Integer>> r = new ArrayList<>(a);
		r.addAll(b);
		return r;
	}
}

insertAll 메서드와 concat 메서드를 보면 입력받은 리스트를 기반으로 새로운 리스트를 생성하여 반환하는 것을 볼수 있습니다. 이와 같은 방식으로 인하여 입력으로 전달한 리스트에 영향을 미치지 않도록 합니다.

 

3. 재귀와 반복

순수한 함수형 프로그래밍 언어에서는 while, for와 같은 반복문을 포함하지 않습니다. 왜냐하면 반복문으로 인한여 값의 변화가 자연스럽게 코드에 반영되어 함수형이 되지 않기 때문입니다. 이러한 반복문 문제를 해결하기 위해서 재귀를 이용합니다.

다음 코드는 반복문을 사용하여 구현한 팩토리얼 예제입니다. 다음 팩토리얼 코드를 보면 변수 r과 변수 i의 값이 계속 변하는 것을 볼수 있습니다. 물론 해당 변수들은 변수를 지역적으로 사용하기 때문에 외부에 영향을 미치지는 않지만 다른 사례에서 인수로 전달받은 객체를 이용하여 반복문마다 객체에 무언가를 요청하게 되면 객체의 상태가 변하게 되어 외부에 영향을 미칠 수 있습니다.

 

static int factorialIterative(int n){
	int r = 1;
	for(int i = 1; i <= n; i++){
			r *= i;
	}
	return r;
}

다음 코드는 위에서 작성한 반복문 형식의 팩토리얼 코드를 재귀적인 방식으로 변경한 코드입니다. 다음 코드 내용을 보면 인수로 전달한 n의 값이 변하지 않는 것을 볼 수 있습니다.

 

static long factorialRecursive(long n){
	return n == 1 ? 1 : n * factorialRecursive(n - 1);
}

자바8의 스트림을 사용하여 좀더 단순하게 팩토리얼을 정의할 수 있습니다.

 

static long factorialStreams(long n){
	return LongStream.rangeClosed(1, n)
    	.reduce(1, (long a, long b) -> a * b);
}

 

재귀 방식의 주의점

효율성 측면에서 무조건 반복문 방식보다는 재귀가 좋은 것은 아닙니다. 왜냐하면 재귀 방식이 반복 코드보다 코스트가 높기 때문입니다. 예를 들어 재귀적인 방식으로 메서드를 호출하게 되면 콜스택에 새로운 스택 프레임이 생성되기 때문에 메모리 사용량이 증가합니다.

위 주의점의 문제에서 메모리 사용량 증가 문제를 해결하기 위해서 함수형 언어에서는 꼬리 호출 최적화라는 해결책을 제공합니다.

 

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)에서는 컴파일러가 하나의 스택 프레임을 재활용합니다.
  • 고전적인 재귀보다 여러 컴파일러 최적화 여지를 남겨둘수 있는 꼬리 재귀를 적용하는 것이 좋습니다.
    • 자바 언어는 최적화를 제공하지 않지만 스칼라, 그루비같은 최신 JVM 언어는 이와 같은 재귀를 반복으로 변환하는 최적화를 제공합니다.
  • 자바 8에서는 반복을 스트림 API로 대체해서 변화를 피할 수 있습니다.

 

다음 그림은 단일 스택 프레임을 재사용하는 팩토리얼의 꼬리 재귀 정의하는 그림입니다. 그림을 보면 계산 중간 결과를 계속해서 인수로 전달하여 스택 프레임을 재사용하는 것을 볼수 있습니다. 이렇게 계속 실행하다가 베이스 케이스에 도달하여 인수로 전달된 최종 결과 24를 반환합니다.

 

4. 마치며

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

 

References

모던 자바 인 액션
https://pnurep.github.io/functional%20programming/Exploring_Kotlin_Functional_Programming_Chapter_1/#recursion-vs-iteration