본문 바로가기

나만의 작은 공간/Books

[Java 8 Labmda] 스트림


자바 8 에서는 streams 라는 기능이 추가되어서 collection-processing 코드를 더욱 높은 추상화가 가능하도록 할 수 있다. 

Stream 인터페이스는 다양한 function을 포함하고 있는데, 아래에 그 기능을 자세히 살펴보자. 

  1. 외부의 Iteration에서 내부적 Iteration으로

자바 개발할 때, 가장 많이 사용하는 것이 아마 Collectin을 이용한 루프일 것이다. 예를 들면 아래와 같다. 

int count = 0; 
for (Artist artist : allArtists) {
   if (artist.isFrom("London")) {
      count++;
   }
}

이러한 코드는 이러한 컬랙션의 값을 꺼내려 할 때에 이와 같은 패턴을 반복적으로 사용해야 한다는 단점이 있다. 또한 이러한 for loop 에 대하여 Parallel 하게 코딩하기 어렵다. parallel하게 하려면 아마 모든 for loop를 각각 따로 반복하여 작성해야 할 것이다. 

마지막으로, 이러한 코드는 프로그래머의 의도와 적절하게 부합되지 않는다. 이러한 for loop 구조는 이해하기 어렵다. 구조를 파악하기 위해서 모든 for loop의 몸통을 살펴야 알 수 있는 것이다. 내용이 간단한 for 문은 큰 문제가 되지 않지만, 복잡한 로직을 포함하고 있는 for 문은 그야말로 개발자들에게 큰 짐이 아닐 수 없다. 사실 for loop은 내부적으로 iteration 과정을 포함하고 있고, 외부적으로 이를 감싸고(wrap up) 있는 것이다. 아래의 예제를 보자. 아래의 예제를 사실상 외부적 순환 (External Iteration) 이라고 한다. 

int count = 0;
Iterator iterator = allArtists.iterator();
while(iterator.hasNext()) {
Artist artist = iterator.next();
   if (artist.isFrom("London")) {
      count++;
   }
}

외부적 순환은 아래와 같은 부정적인 이슈들이 존재한다.

1. 이러한 순환법은 우리가 뒤에서 볼 다양한 행위적 오퍼레이션에 대해서 추상화가 어렵다. 

2. 이러한 For loop은 우리가 하려고 하는 것과 하려고 하는 방식을 결합하게 되는 것이다. 

이에 대비하여 존재하는 내부적 순환(Internal Iteration)은 아래와 같다. 

long count = allArtists.stream()
                      .filter(artist -> artist.isFrom("London"))
                      .count();

위의 코드를 그림으로 나타내면 아래와 같다. 

stream()은 빌드툴로서 함수적인 방법으로 컬랙션에 대한 동작을 수행하려고 할 때 사용된다. 위의 그림에서 Collection code에서 내부적으로 iteration 을 수행하며, 입력으로 받는 람다식을 수행하여 결과를 반환한다. 

위의 코드에서 stream()의 사용은 전통적인 외부적순환 방법을 이용할 때에 iterator()를 호출하는 것과 동일한 작용을 한다. 전의 코드에서는Iteratior<E> 인터페이스를 리턴했지만, 아래의 코드에서는 Stream()인터페이스를 리턴하여 Stream 인터페이스 내부에 정의된 다른 메서드들도 연속적으로 호출이 가능 하도록 하였다. 이 Stream을 Internal Iterator World 이다. 위의 코드는 두 개의 단계로 분리할 수 있다. 

  • 런던에서 온 모든 아티스트를 찾는다.

  • 런던에서 온 아티스트를 담은 리스트의 갯수를 카운팅한다. 

여기서 Filtering의 의미는 "오직 조건에 맞는 오브젝트들만 테스트에 통과시킨다"는 의미이다. 

 2. 도대체 어떤 일이 벌어지는가?  

위의 예제에서 코드가 실행하는 제반 과정을 두 개의 단계로 분리하였다. 이는 혹시 각각의 단계에서 루프를 한번 씩 , 모두 두번의 루프를 돌려야 할 것이라 생각하는 사람이 있을 지도 모르겠다. 하지만, Stream API 는 내부적으로 오직 한번의 루프만 돌리도록 설계 되었다. 

자바에서는 전통적으로 한 메서드를 호출하면 그에 대응하는 동작을 수행하게끔 되어 있다. 마치 Systme.out.println() 메서드는 터미널에 문자열을 출력하는 행동을 하는 것과 같은 것처럼 말이다. 하지만, Stream 내부의 일부 메서드들은 조금 다르게 동작한다. 이 메서드들은 일반적인, 노멀한 메서드들이지만, 전통적인 메서드들과 다르게 동작한다. 이 메서드들은 노멀한 메서드들이지만, Stream object는 새로운 컬랙션을 리턴하는 것이 아니다. 아래의 코드를 분석해보자. 

allArtists.stream()
     .filter(artist -> {
     System.out.println(artist.getName());
     return artist.isFrom("London");
});

count()함수를 호출하기전에 먼저 모든 아티스트들을 출력하고, 그 다음에 런던에서 온 아티스트를 리턴할 수 있다. 

long count = allArtists.stream()
                .filter(artist -> {
                System.out.println(artist.getName());
                return artist.isFrom("London");
        })
       .count();

위의 Operation은 빌더패턴과 비슷하다. 그런데 왜서 이러한 방식으로 구현하는 지를 물어보는 사람이 있을지도 모른다. 이는 효과적이기 때문이다.  예를 들어, 첫 숫자가 >10 인 리스트를 찾는다고 한다면 , 리스트의 모든 엘리먼트를 다 볼 필요가 없이 매칭이 되는 리스트만 반환 받을 수 있기 때문이다. 

 3. Common Stream Operations 

여기서 많이 사용하고 있는 코먼 Stream Operation들을 보기로 하자. (아주 기본적이지만 중요한 것들)

1. collect 

collect(toList())

스트림에서 새로운 리스트를 만들어내는 오퍼레이터이다.  아래의 예제를 보자. 

List collected = Stream.of("a", "b", "c")
                      .collect(Collectors.toList());
                      assertEquals(Arrays.asList("a", "b", "c"), collected);

위의 예제는 collect(toList())가 스트림에서 원하는 결과를 받아오는 지를 설명하는 코드이다. Stream 인터페이스의 많은 메서드들은 Lazy하기 때문에, chained method call  마지막에  collect와 같은 Eager operation을 사용하는 것이 매우 중요하다. 

2. map

한 타입의 값을 다른 것으로 변경하려고 할 때에 map을 사용한다. map은 Stream이나, 밸류에 수행하여 새로운 스트림이나 밸류를 만들어 낸다. 만일 한 리스트의 스트링이 있다고 가정할 때에, 그 리스트의 스트링을 모두 대문자로 바꾸려고 한다면, 아마 루프를 돌려서 스트링의 toUpperCase() 메서드를 호출하였을 것이다. 그리고 새롭게 만들어진 스트링 값들을 새로운 리스트에 넣어주었을 것이다. 

아래의 코드는 이와 같은 스타일을 반영한다. 

List collected = new ArrayList<>();
          for (String string : asList("a", "b", "hello")) {
                  String uppercaseString = string.toUpperCase();
                  collected.add(uppercaseString);
         }
         assertEquals(asList("A", "B", "HELLO"), collected);

아래는 위의 코드를 map을 이용한 것으로 수정한 것이다. 

List collected = Stream.of("a", "b", "hello")
              .map(string -> string.toUpperCase())
              .collect(toList());
              assertEquals(asList("A", "B", "HELLO"), collected);

두번째 줄의 람다식에서 보면, 스트링 아규먼트를 받아서 스트링을 반환한다. 아규먼트와 리턴 타입이 꼭 같은 타입이어야 할 필요는 없다. 하지만, 사용되는 람다식은 반드시 Function 인터페이스의 인스턴스여야 한다.  Function 인터페이스는 오직 하나의 메서드만 가지는 제네릭 인터페이스이다. 

3. filter

루프를 도릴 때마다, 새로운 filter 메서드를 사용할 수 있다. 

스트링의 리스트가 있다고 가정하고, 특정한 digit으로 시작하는 모든 스트링을 찾고 싶다고 한다면, (예를 들어, "1abc" 는 되고, "abc"는 안되고)아마도 기존의 우리라면 이렇게 코드를 작성했을 것이다. 

List beginningWithNumbers = new ArrayList<>();
      for(String value : asList("a", "1abc", "abc1")) {
         if (isDigit(value.charAt(0))) {
             beginningWithNumbers.add(value);
           }
       }
      assertEquals(asList("1abc"), beginningWithNumbers);

이런 코딩 방식을 필터 패턴이라고 한다. 스트림의 앨리먼트들 중 필요한 것들만 남기고 나머지는 버리는 것이다. 아래는 위의 코드를 함수형 프로그래밍방식으로 코딩한 것이다 .

List beginningWithNumbers
          = Stream.of("a", "1abc", "abc1")
             .filter(value -> isDigit(value.charAt(0)))
             .collect(toList());

map과 비슷하게 filter 는 하나의 아규먼트를 가지는 싱글 Function이다. 이 함수는 앞에서 if  문에서 사용했던 것과 동일한 효과를 내고 있다. 이functional interface는 우리가 앞에서 봤었던 Predicate이다. 결과적으로 true or false Boolean 을 판단하는 동작을 하는 Functional Interface 이다. 

4. flatMap

flatMap lets you replace a value with a Stream and concatenates all the streams together. 

여러 개의 map 에서 새로운 Stream object 를 만들어서 replace 하려고 하는 경우가 있다. 이럴 때 사용된다. 아래의 예제를 보자. 우리는 두개의 스트림 넘버들을 가지고 있다. 그리고 이 모든 넘버들이 순차적으로 배열된 리스트를 얻고 싶다. 이 문제를 아래와 같이 풀 수 있다. 

List together = Stream.of(asList(1, 2), asList(3, 4))
         .flatMap(numbers -> numbers.stream())
         .collect(toList());
         assertEquals(asList(1, 2, 3, 4), together);

위의 코드에서 보면, 두 리스트를 stream()메서도를 이용하여 Stream객체로 바꾸어 주는 것인데  이 작업을 flatMap이 해주는 것이다.  flatMap은 functional interface와 연관된다는 점에서는 map 과 비슷하나(-  -Function-) , flatMap의 리턴타입은 오직 Stream이어야지 되며 다른 값은 안된다. 

5. max 와 min

아래의 코드 예제를 보자. finding shortest track with stream 예제이다. 


public class Streams {
	public static void main(String[] args) {
		List tracks = Arrays.asList(new Track("Bakai", 524), 
                                new Track("Violets for your Furs", 328),
				new Track("Time Was", 451));

		Track shortestTrack = tracks.stream()
			.min(Comparator.comparing(track -> track.getLength()))
			.get();
		
		System.out.println(shortestTrack.getName());
	}
}

class Track {
	String name;
	int id;

	public Track(String name, int id) {
		this.name = name;
		this.id = id;
	}

	public String getName() {
		return name;
	}

	public void setName(String name) {
		this.name = name;
	}

	public int getId() {
		return id;
	}

	public void setId(int id) {
		this.id = id;
	}

	public int getLength() {
		return this.id;
	}

}

스트림의 앨리먼트 중에서 min 혹은 max 값을 구하려면 Comparator를 구현한 Comparator 오브젝트로 sorting을 해야 하는 데, 람다식을 이용하면 이런 추가적인 구현이 없이 바로 한 줄에 가능하다. 바로 java 8 부터 추가된 Comparator 클래스의 static  메서드인 comparing() 때문이다.  

# 위의 코드에서  track.getLength() 로 코딩하였으면, Track 클래스의 getter 메서드를 getLength로 해주는 것이 맞다. 

6. A Common Pattern Appears

위의 Min 에서 사용한 코드를 다시 써보자. 

List tracks = Arrays.asList(new Track("Bakai", 524), 
                                new Track("Violets for your Furs", 328),
				new Track("Time Was", 451));
Track shortestTrack = tracks.get(0);
for (Track track : tracks) {
    if (track.getLength() < shortestTrack.getLength()) {
               shortestTrack = track;
      }
   }
assertEquals(tracks.get(1), shortestTrack);

이와 같은 패턴의 코드를 수도 없이 많이 짜봤으리라, 오히려 이러한 패턴의 코드를 좀 더 General 한 패턴의 코드로 추상화 시킬 수 있다. 아래와 같이 ... 

Object accumulator = initialValue;
for(Object element : collection) {
accumulator = combine(accumulator, element);
}

이러한 General Form 이 어떻게 Stream API에서 정의 되어 있는 지 좀 더 깊이 살펴보자. 아래는 이런 위의 패턴을 사용한 것들임 ... ...

7. reduce

value들의 컬랙션을 가지고 하나의 Single Result를 만들려고 할 때에 사용된다. 우리가 앞에서 사용한 min, max, count 메서드들은 모두 reduction의형식을 취하고 있다. Reduce는 스트림의 요소들의 결합을 위하여 제공되는 함수이다. 

아래의 예제를 보자. 

int count = Stream.of(1, 2, 3)
.reduce(0, (acc, element) -> acc + element);
assertEquals(6, count);
Optional<Integer> sum = Stream.of(1, 2, 3).reduce((acc, element) -> acc + element);
assertEquals(6, sum.get().intValue());

위의 두 코드는 동일하게 작동한다. 다만 차이점은 위의 코드는 아규먼트를 두개 즉 0 이라는 identity 값과 람다식을 인자로 받았고,아래 코드는 람다식만 받았다는 것이다. 여기서 아규먼트로 쓰이는 identity는 accumuator의 초기값이다. 0 이 아니고 2로 설정하면결과는 6이 아닌 8이 될 것이다. 

위에서 보는 reduce 람다 식을 보면, 두 개의 아규먼트를 받아서 스트림의 앨리먼트들을 다 더하는 오퍼레이션을 행한다. acc는 accumulator로서 현재 상태의 tempSum을 가지고 있다. 람다식은 새로운 acc 값을 리턴한다. reducer의 타입은 BinaryOperator이다. 사실상, 위의 Reduction 과정은 아래처럼 확장할 수 있다.