Java8 Stream에 적용된 함수형 프로그래밍

hjkim·2022년 1월 4일
1

Java8에서 처음 등장한 stream은 '빠르다'는 이유로 자주 사용되곤 한다. 하지만 stream의 등장 배경은 단순히 '빠르게' 돌아가는 코드를 완성하기 위한 것이 아니다. stream은 Java에서의 함수형 프로그래밍이 적용된 대표적인 예시라 할 수 있다. 이번 포스팅에서는 함수형 프로그래밍의 특징들이 stream에 어떻게 녹아들어가 있는지 자세히 적어보고자 한다.

stream 개념 관련 이전 포스팅 : Java8 Stream
함수형 프로그래밍 관련 이전 포스팅 : 함수형 프로그래밍 개념

1. 불변성

Integer[] arr = {1,2,3,4};
List<Integer> lst = Arrays.asList(arr);
lst.stream().map(i -> i*3).collect(Collectors.toList());

위의 코드에서 stream은 lst 각각의 원소들에 3을 곱하고 이를 모아 새로운 배열로 만들어주고 있다. 맨 마지막 코드를 실행하면 [3, 6, 9, 12]라는 값을 얻을 수 있을 것이라 예측이 가능하다. 하지만 실행 결과가 [3, 6, 9, 12]라 하여 input 값이었던 원본 배열 lst가 변하는가? 답은 "아니오" 이다.

함수형 프로그래밍은 내부에서 연산을 하고 그 함수를 빠져나오면 해당 input값을 변화시키지 않는 '불변성'이란 특징이 존재한다. stream에서도 마찬가지로 이 '불변성'이란 특징이 존재한다.
중개 연산인 map()과 collect() 혹은 그 외의 많은 메소드들을 거치더라도 맨 처음 stream()에 input으로 주어진 lst 배열은 불변한다.

2. 참조 투명성

함수형 프로그래밍에는 참조 투명성이라는 특징이 존재한다. 외부의 값을 참조하는 것이 아니라 input parameter 값에 따라서만 결과 값을 얻는 것을 '참조가 투명하다'고 한다. stream에서도 이러한 특징을 살펴볼 수 있다. 아래의 코드는 참조가 투명한 stream이다.

List<String> strs = Arrays.asList("1", "2", "3", "4");
strs.stream().filter(s -> Integer.toInt(s) % 2 == 0)
	.collect(Collectors.toList());

위의 코드를 보면 strs를 stream의 input parameter로 넘겨주고 있고, 중개 연산인 filter, 종단 연산인 collect()를 진행하고 있다. 이때, filter 연산도, collect 연산도 전부 다른 외부 변수를 사용하고 있지 않다. filter 연산과 collect 연산이 변화시키는 것은 오로지 input parameter로 주어진 strs 배열이다. 따라서 위의 코드는 참조가 투명하다.

List<String> strs = Arrays.asList("1", "2", "3", "4", "5");
List<String> strs1= new ArrayList<>();
strs.stream().forEach(s -> {
	if (new Integer(s) % 2 == 0) {
		strs1.add(s);
	}
});

반면 위의 코드는 참조가 투명하지 않은 예시이다. strs 배열과 strs1 배열을 생성하고 strs 배열을 stream의 input parameter로 던져주고 있다. 하지만 forEach 연산을 진행하며 원소가 2의 배수일 때 strs1에 값을 add하고 있다. input parameter로 주어진 배열은 strs인데, 외부의 strs1의 값을 변경하고 있는 것이다. 따라서 해당 stream은 참조가 투명하지 않다.

3. 일급 함수

함수형 프로그래밍은 일급 함수들을 파라미터로 넘기거나 자료구조에 담고, 반환 값으로 돌려주는 등의 작업이 가능해야 한다. 그리고 이 일급 함수들이 서로 엮여(Function composition) 프로그램이 구축된다. 앞서 살펴본 예제를 다시 한 번 살펴본다.

strs.stream().filter(s -> Integer.toInt(s) % 2 == 0)
	.collect(Collectors.toList());

stream 에서도 filter의 인자로 아래와 같은 익명 함수를 받고 있다. 그리고 그 리턴된 값은 collect로 넘어간다.

s -> Integer.toInt(s) % 2 == 0

collect에서도 역시 Collectors.toList()와 같은 함수를 인자로 받고 있다. 그리고 filter와 collect가 서로 엮여 코드를 완성하고 있다. 일급 함수의 특징들이 stream에서도 나타나고 있는 것이다.

4. Lazy Evaluation

stream이 동작하는 부분 중 가장 유념해서 봐두어야 할 부분이 바로 이 Lazy Evaluation 특징 때문에 발생하고 있다.

아래와 같이 동작하는 for문이 존재한다.

for(int i=1; i<6; i++) {
	strs[i] *= 3;
}

for문은 Lazy Evaluation이 아닌 Eager Evaluation이 일어난다. for 문의 i 변수는 1로 초기화 된 후 strs[i] *= 3 연산을 수행하고 난 뒤 i<6인지 아닌지 바로바로 판단하고 바로바로 연산을 수행하는 특징이 있다. 반면 stream에서는 바로바로 연산이 수행되지 않는다.

List<String> strs = Arrays.asList("1", "2", "3", "4");
strs.stream().filter(s -> Integer.toInt(s) % 2 == 0)
	.collect(Collectors.toList());

참조 투명성에서 살펴봤던 예제를 다시 살펴본다. stream에서는 stream 생성 후 filter, collect 순서로 연산이 바로바로 진행되지 않는다. collect()가 호출되는 시점에 filter를 걸어 list를 생성한다.

다른 예제로는 이전 포스팅에서 언급했던 limit()가 있다.

list.parallelStream().limit(5).sum()

list 내부에 1부터 10까지의 정수가 들어있다고 가정한다. 이때 parallelStream은 limit(5)연산을 바로 수행하여 5개의 원소를 잘라오지 않는다. sum()이 호출되는 시점이 limit도 함께 걸리는 것이다. 이러한 Lazy Evaluation 특징 때문에 이전 포스팅에서도 언급하였듯, parallelStream을 사용해도 오히려 성능이 낮아지는 결과를 얻는 경우가 생기는 것이다.

parallel stream은 fork & join을 진행할 때 10개의 원소를 JVM에 판단하기에 연산을 진행하기 최적의 숫자대로 데이터를 나누어 둔다. JVM이 판단하기에 최적의 숫자가 2개씩이라 판단했을 때, 10개의 데이터는 2개/2개/2개/2개/2개로 나뉘어 종단 연산인 sum()이 호출되기까지 아무런 연산을 수행하지 않고 있는다.

그러다가 sum()이 호출되면 limit(5) 연산도 같이 수행되는데, JVM은 이미 fork 과정에서 데이터를 2개씩 쪼개두어 2개/2개/1개만 가져오기 애매한 상황이 발생한다. 이를 처리하는 과정에서 시간이 좀 더 소요되고, parallel stream을 통해 성능 향상을 얻을 수 없게 된다.

stream에서는 항상 종단 연산이 호출될 때 중개 연산이 같이 수행되며 이는 Lazy Evaluation이란 함수형 프로그래밍의 특징에서 유래된 것임을 잊지 말아야 한다.


함수형 프로그래밍이라 하면 대부분 Javascript를 떠올리곤 한다. 하지만 Java에도 코드에 함수형 프로그래밍을 녹여내려는 시도가 있었다는 사실을 알 수 있었다. 또한 빠르다는 이유로, 혹은 Java8에서 새로 도입된 것이라는 단순한 이유로 사용되어지는 stream이 함수형 프로그래밍의 개념을 담고 있었다는 사실도 알 수 있어 참 재미있었다.

profile
피드백은 언제나 환영입니다! :)

0개의 댓글