Java - Stream

Kyu0·2022년 4월 19일
0

Stream API란?

Java 8 부터 Collection interface에 추가된 기능으로, 데이터를 선언적으로 처리하고 멀티코어 아키텍처를 활용할 수 있도록 설계되었다.

여기서 선언적으로 처리한다는 말은 가독성과 연관이 있다. 간단하게 예제를 보여주도록 하겠다.


예제

// for-loop
public int example1(int[] numbers) {
	int max = 0;
    
	for (int i = 0 ; i < numbers.length) {
    	if (max >= numbers[i]) {
        	max = numbers[i];
        }
    }
    
    return max;
}

// stream
public int example2(int[] numbers) {
	return Arrays.stream(numbers)
    		.max()
            .getAsInt();
}
	

example1에서는 값을 비교하면서 max의 값을 갱신하는 부분이 드러나있기 때문에 동작 과정을 상세히 알 수 있지만 가독성이 떨어진다.
또한, 실제 example1 메소드를 실행해보면 최대값이 아닌 최소값을 반환하는데, if (max >= numbers[i]) 부분에서 잘못된 비교를 하고 있기 때문이다. 이 부분은 if (max < numbers[i])로 고쳐져야 한다.

반대로 example2에서는 어떤 동작 과정을 거치는지는 나와 있지 않지만 어떤 동작을 하는지에 대해서는 명확히 나와있다. max() 메소드를 통해서 최대값을 추출한다는 것을 알 수 있다.

특징

Stream API는 크게 아래와 같은 특징을 가지고 있다.

  1. 최대한 연산을 지연한다.
  2. 람다식과 메소드 참조를 활용한다.
  3. 일회성이다.
  4. 데이터 소스를 변경하지 않는다.

1. 최대한 연산을 지연한다.
최대한 연산을 지연한다는 말은 Stream 내의 요소들이 실제로 사용될 때까지의 연산을 하지 않는다는 말이다. (=lazy evaluation)


예제

public void example3(List<String> names) {
	names.stream()
    	.filter(e -> e.equals("Park")) // 연산 X
        .forEach(System.out::println); // 실제 연산 시작
}

example3 에서 forEach(System.out::println)부분을 주석 처리하게 되면 실행 속도가 A에서 B로 급감하게 된다.
이는 filter() 메소드 이후에 걸러진 요소들이 사용되지 않았기 때문에 연산을 아예 하지 않기 때문이다.


예제

public void example4(List<Product> productList) { // List size == 100000
  productList.stream()
    .filter(product -> {
      System.out.println(product.getName());
      return (product.getPrice() % 2 == 0);
    })
    .limit(5)
    .forEach(product -> product.setName("a")); // 의미 없는 종단 연산
}

출력 결과

// 각 라인은 Product의 name 필드, 총 8개
yorvpjcyqepbxhaepntj
rechjraadmxyzsljynla
golfefamxtxcpritpkfa
veanooelpbznqjkagmvs
ihwetcmlmgyxciwlbcix
nddbznzhcasgxldnvuhh
jraurhufcnfbugmetvto
fbpzxcwlrsrcmhoaygcb 

example4 는 좀 더 명확하게 lazy evaluation 의 장점을 보여준다.

filter(product -> {
	System.out.println(product.getName());
    return (product.getPrice() % 2 == 0)
}

위 함수의 동작을 직관적으로 생각해보면 100,000 개의 Product name을 출력할 것 같지만, 실제로는 8개의 name만을 출력하는 결과를 볼 수 있다.

limit(5)

이는 위의 limit(5)라는 함수를 통해 return할 객체의 개수를 최대로 '5개'로 제한을 해줬기 때문에 filter에서 조건에 맞는 객체 5개를 찾으면 다음 과정으로 넘어가도록 동작하기 때문이다.
위의 결과에서는 객체를 8개 탐색했을 때 price % 2 == 0인 객체가 5개였기 때문에 출력 결과에는 8개의 Product name이 나오게 된 것이다.


2. 람다식과 메소드 참조를 활용한다.
1) 람다식
람다식을 작성하는 방식에는 여러 가지가 있다. 또한 깊게 들어가면 함수형 인터페이스(functional interface)도 소개를 해야하므로 본 게시글에는 간단하게 설명하고 차후 새로운 글에서 자세히 소개하도록 하겠다.

// e -> equals("Park")
function isPark(String e) {
    return e.equals("Park");
}

예제 3에서 e -> equals("Park")를 일반 함수 형태로 변환하면 위와 같다. e는 파라미터, equals("Park")은 return하는 값으로 취급이 된다. 람다식을 통해 일일히 함수를 작성하지 않아도 되니 편리성이 증가되었다.

2) 메소드 참조
람다식에서 함수에 그저 파리미터를 그대로 전달해 줄 경우 간략하게 표현하기 위해서 도입된 기능이다.

// 파일을 삭제하는 메소드
public void example5(List<String> filePathList) {
	filePathList.stream()
    			.map(File::new)
                .forEach(file -> file.delete());
}

위의 예제 5에서 File::new 부분이 메소드 참조이다.  map 함수에서는 단순히 filePathList에 저장된 파일들의 경로들을 new File(String filePath)에 넘겨주는 역할만 하므로 해당 구문을 단축시켜 File::new로 간소화한 것이다.


3. 일회성이다

public void example6(List<String> filePathList) {
	Stream<String> filePathStream = filePathList.stream();
    
    // 파일 삭제
    filePathStream.map(File::new)
    			  .forEach(file -> file.delete());
              
    // 파일 생성
    filePathStream.forEach(File::new); // runtime 오류 발생
}

출력 결과

Exception in thread "main" java.lang.IllegalStateException: stream has already been operated upon or closed
...

예제 6과 같이 이미 사용한 stream을 재사용하면 런타임 시 오류가 나게 된다. (stream has already been operated upon or closed)
이 부분은 사용 시 주의를 해야한다. (사실 변수로 선언하기 귀찮기 때문에 재사용할 일이 없긴 하다.)


4. 데이터 소스를 변경하지 않는다.

public void example6(List<String> arr) {
	List<String> arr = new ArrayList<>();

    arr.add("d");
    arr.add("a");
    arr.add("c");
    arr.add("q");
    arr.add("v");

    arr.stream()
        .sorted()
        .collect(Collectors.toList()); //a, c, d, q, v
        
    for (String element : arr) {
        System.out.println(element);
    }
}

출력 결과

d
a
c
q
v // 정렬 되지 않음

public void example7(List<Product> productList) {
	// Product의 name을 "s"로 변경
	productList.stream()
    		   .forEach(product -> product.setName("s");
               
    for (Product product : productList) {
    	System.out.println(product.getName());
    }
}

출력 결과

s
s
s
... // name이 "s"로 변경됨

List<String>를 활용한 예제 6의 경우에는 정렬이 되지 않은 반면, List<Product>를 활용한 예제 7에서는 데이터 소스가 변경된 것을 볼 수 있다. Java에서는 함수 파라미터로 객체를 넘겨주면 객체의 주소값을 복사하기 때문에 실제 객체 인스턴스를 참조해 필드의 값이 변하게 되는 것이다.
객체를 다룰 때는 이 부분만 유의하면 될 것 같다.하지만 여태까지 사용해 본 바로는 이 점은 실보다는 득이 더 큰 것 같다.


성능

일반적으로 for-loop 보다 Stream의 처리 속도가 더 느리다.
이는 for-loop가 단순히 인덱스 기반으로 원소에 접근하기 때문에 오버헤드가 거의 없는 반면, Stream은 원소들을 처리하고 새로운 stream으로 만들어 반환하기 때문에 더 느린 것으로 파악된다.
또한 비교적 늦게 도입된 Stream의 경우, 컴파일러의 최적화가 덜 되어있다보니 성능이 덜 나온다는 언급도 있다.
여러 가지 환경, 로직에 따라 for-loopStream의 성능이 차이가 나지 않는 경우도 있으니 성능 시간을 테스트 해보고 Stream을 사용해도 되는지 여부를 결정하면 될 것 같다.

관련 예제 : github/Kyu0


마치며...

이번 게시물에는 Stream API에 대해서 알아보았다. 처음으로 어떤 개념에 대해서 정리하는 글을 써봤는데 읽는 사람을 배려하면서 쓰기란 참 어려운 것 같다.
하지만 내 블로그에 작성된 글이 누군가에게 도움이 되길 바라며 앞으로도 열심히 작성해야겠다.

궁금한 점, 잘못된 부분 댓글로 지적해주시면 확인 후 수정하도록 하겠습니다. 긴 글 읽어주셔서 감사합니다.

profile
개발자

0개의 댓글