티스토리 뷰

 

 

스트림의 핵심

스트림은 함수형 프로그래밍에 기초한 패러다임이다. 스트림이 제공하는 표현력, 성능, 병렬성을 이해하기 위해서는 스트림 API 뿐만 아니라 함수형 프로그래밍이라는 패러다임까지 함께 받아들여야 한다.

 

 

함수형 프로그래밍

객체지향 프로그래밍 패러다임은 객체를 중심으로 사고하고 프로그램을 작성하는 것이다.

반면 함수형 프로그래밍은 데이터를 함수로 연결하는 것을 중심으로 사고하고 프로그램을 작성하는 것이다.

 

그렇다면 함수형 프로그래밍이 객체지향 프로그래밍 혹은 절차지향 프로그래밍과 반대되는 개념이 아닐까? 라는 생각을 가질 수 있다.

하지만 셋은 상호 배타적이지 않으며 대부분의 시스템은 이 세 가지 모두를 사용하는 경향이 있다.

그러므로 상반되는 패러다임이라고 생각하기 보다는, 애플리케이션을 구성하기 위한 프로그래밍 패러다임들의 적절한 조화로 생각하는게 좋을 것 같다.

 

함수형 프로그래밍의 특징

 

함수형 프로그래밍은 자료 처리를 수학적 함수의 계산으로 취급하고 상태와 가변 데이터를 멀리한다.

 

하나의 입력에 대해서 항상 결과 값이 같아아 한다.

예를 들어 f(x)라는 함수에 x1을 대입해 r1이라는 값이 나왔고 x2를 대입해 r2라는 값이 나왔으면, 일주일 뒤 또는 십년 뒤에 연산을 실행해도 항상 같은 값이 나와야 한다.

 

그렇다고 x3를 대입해서 r2가 되지 않는 것은 아니다. 다른 입력 값이 같은 결과를 가질 수도 있다.

 

삼각함수를 생각해보면 sin(x) 값은 입력되는 x에 따라 항상 동일하다. x가 0일 때도 0, 180일때도 0이다.

어떤 x가 들어오더라도 sin(x) 값은 변하지 않는다.

 

즉, 하나의 입력에 대해서 동일한 결과만 나오면 된다는 것이다. 동일성은 보장하되 유일성을 보장하지 않는다고도 할 수 있다.

 

함수형 프로그래밍은 부수 효과가 없는 순수 함수를 1급 객체로 간주하여 파라미터나 반환값으로 사용할 수 있으며, 참조 투명성을 지킬 수 있다.

 

'부수 효과' '순수 함수' '1급 객체' '참조 투명성' 과 같은 단어가 자주 등장하는데 용어를 간단히 정리해보자.

 

부수 효과 (Side Effect)

부수 효과란 다음과 같은 변화 또는 변화가 발생하는 작업을 말한다.

- 변수의 값 변경

- 객체의 필드 값 설정

- 예외나 오류가 발생하며 애플케이션 실행 중단

- 콘솔 또는 파일 I/O 발생

 

순수 함수(Pure Function)

부수 효과를 제거한 함수를 순수 함수라고 부르며, 함수형 프로그래밍에서 사용하는 함수를 말한다.

- 즉, 함수의 실행이 외부에 영향을 끼치지 않는 함수라고 할 수 있다.

- 함수 자체가 독립적이며 Side Effect가 없기 때문에 Thread에 안정성을 보장 받는다.

- Thread Safe하기 때문에 병렬 처리를 동기화 없이 진행할 수 있다는 장점도 있다.

 

일급 객체(First-Class Object)

함수형 프로그래밍에서 함수는 일급 객체로 취급된다. 따라서 일급 객체 함수란 일급 객체로 취급되는 함수를 뜻한다.

- 변수나 데이터 구조 안에 담을 수 있는 것

- 파라미터로 전달 가능한 것

- 반환 값으로 사용 가능한 것

 

참조 투명성

- 함수를 실행하여도 어떠한 상태의 변화 없이 항상 동일한 결과를 반환한다는 의미

- 부작용을 제거하여 프로그램의 동작을 이해하고 예측을 용이하게 하는 것

- 병렬 처리 환경에서 Race Condition에 대한 비용을 줄여준다

    - Why? 부수효과가 없어 항상 동일한 실행에 대한 동일한 결과를 반환하기 때문.


스트림 패러다임을 이해하지 못한 예시

List<String> results = new ArrayList<>();

stream.filter(s -> pattern.matcher(s).matches())
      .forEach(s -> results.add(s));

모든 연산이 최종연산 forEach에서 발생하는데, 이 때 외부 상태를 수정하는 results.add(s)를 실행한다.

항상 동일한 입력에 대한 동일한 값이 나와야 함을 보장할 수 없다. 왜냐하면 results가 선언된 이후 스트림 연산을 하는 사이에 상태가 변하지 않음을 보장할 수 없기 때문이다.

 

forEach는 출력하는 용도로만 사용하기로 했었다. 또한 병렬 처리를 하는 상황에 forEach에서 연산을 수행하는 경우 동시성을 보장받을 수 없다.

void noneFunctionalStream() {
      List<Integer> matched = new ArrayList<>();
      List<Integer> elements = new ArrayList<>();
      for (int i = 0; i < 100; i++) {
          elements.add(i);
      }
      elements.parallelStream()
              .forEach(e -> {
                  if (e >= 50) {
                      System.out.println(Thread.currentThread().getId() + " " + matched);
                      matched.add(e);
                  }
              });
      System.out.println(matched.size());
  }

위 코드는 1부터 100까지의 수 중 50보다 큰 수를 병렬 처리를 사용해 matched 변수에 담아서 크기를 보여주는 코드이다. forEach에서 해당 연산을 수행하게 되면 어떤 쓰레드가 어떤 숫자부터 어떤 숫자까지 탐색한다는 보장이 없다. 또한 실행할 때 마다 결과가 달라질 수 있다. 쓰레드에 따라 값이 제각각일 뿐 아니라 추가되는 순서도 다르다. 결국 ConcurrentModificationException이 발생한다.

 

실행결과

 

 

스트림 패러다임을 잘 이해한 예시

List<String>results = stream.filter(s -> pattern.matcher(s).matches())
					.collect(Collectors.toList());

스트림 패러다임을 이해하지 못한 예시와 다르게 외부 상태를 변경하지 않는다. 스트림 연산으로 반환된 결과를 새로운 객체에 할당하므로 항상 동일한 값을 반환함을 보장한다.

 

void functionalStream() {
    List<Integer> elements = new ArrayList<>();
    for (int i = 0; i < 100; i++) {
        elements.add(i);
    }
    List<Integer> matched = elements.parallelStream()
            .filter(e -> e >= 50)
            .collect(toList());
    System.out.println(matched.size() + "  " + matched);
}

동일한 병렬 처리를 하는 코드를 스트림 API의 부작용 없는 메서드를 사용하면 코드의 가독성 뿐만 아니라 Side Effect까지 예방 가능하다.

collect() 최종 연산을 이용해 스트림 연산을 객체 하나로 수집한다.

최종 연산 중 하나인 Collectors 클래스를 활용할 수 있다.

 

실행결과


수집기(Collectors)

 

최종 연산 중 하나로 스트림 데이터의 중간 연산 후 마지막에 원하는 형태의 컬렉션으로 반환한다. 원본 데이터를 손상시키지 않고 연산을 하지도 않기 때문에 자주 사용된다. Collectors 클래스에는 다양한 수집기가 정의되어 있다. 자주 사용하는 5가지를 알아보자.

 

- toList를 이용해 List 형태 반환

대표적으로 가장 흔히 사용하는 형태이다. 스트림 연산을 마친 결과값을 List 형태로 반환한다.

List<String> getMemberNames(List<Member> members) {
    return members.stream()
            .map(Member::getName)
            .collect(toList());
}

 

- toCollection를 이용해 원하는 컬렉션 반환

toCollection을 이용해 원하는 형태로 반환할 수 있다. distinct를 사용해서 List로 반환할 수 있지만 예제를 위해 사용했다.

List<Member> members = List.of(new Member("그레이", 12), new Member("콩하나", 14), 
				new Member("그레이", 14), new Member("콩하나", 20));

TreeSet<String> uniqueNames = members.stream()
		      .map(Member::getName)
		      .collect(Collectors.toCollection(TreeSet::new));

 

- groupingBy를 이용해 그룹핑

예를 들어 같은 이름을 가진 사람들의 나이 합을 구해라와 같은 요구사항이 생겼다면

그레이: 26, 콩하나: 34 와 같은 결과를 얻을 수 있다.

List<Member> members = List.of(new Member("그레이", 12), new Member("콩하나", 14), 
			new Member("그레이", 14), new Member("콩하나", 20));

Map<String, Integer> result = members.stream()
                .collect(groupingBy(Member::getName, summingInt(Member::getAge)));

 

- partitioningBy를 이용해 true/false 로 파티셔닝

true: 콩하나, 블랙캣

false: 그레이, 호이

List<Member> members = List.of(new Member("그레이", 12), new Member("콩하나", 15),
            new Member("호이", 10), new Member("블랙캣", 25));

Map<Boolean, List<Member>> result = members.stream()
	      .collect(partitioningBy(member -> member.getAge() > 14));

 

 

- joining을 통해 문자열로 변환

"그레이, 콩하나, 호이, 키아라, 져니, 블랙캣, 에코, 푸우" 라는 결과를 얻을 수 있다.

List<Member> members = List.of(new Member("그레이", 22), new Member("콩하나", 14), 
				new Member("호이", 15), new Member("키아라", 16),
                		new Member("져니", 18), new Member("블랙캣", 21), 
				new Member("에코", 19), new Member("푸우", 24));

String result = members.stream()
        .map(Member::getName)
        .collect(Collectors.joining(", "));

System.out.println(result);

정리

- 스트림 파이프라인 프로그래밍의 핵심은 부작용 없는 함수에 있다.

- 스트림뿐 아니라 스트림 관련 객체에 전달되는 모든 함수가 부작용이 없어야 한다.

- forEach는 출력을 하는 용도로만 사용하자.

- 스트림을 올바르게 사용하기 위해서는 수집기(Collectors)를 잘 알아두자.

댓글