본문 바로가기
프로그래밍/JAVA 내용정리

[ModernJavaInAction] 4장 스트림(Stream)

by 노잼인간이라불립니다 2022. 8. 22.

이 글은 모던 자바 인 액션이라는 책을 읽고 스스로 내용을 정리하여 작성 한 글입니다.

 

모든 실습 코드는 아래 주소에 있습니다.

https://github.com/jojojojocho/mordernjavainaction

 

GitHub - jojojojocho/mordernjavainaction: 모던자바인액션 연습코드

모던자바인액션 연습코드. Contribute to jojojojocho/mordernjavainaction development by creating an account on GitHub.

github.com

 

안녕하세요. 오늘은 스트림에 대해서 알아보고자 합니다.

 

스트림은 JAVA 8 부터 API에 새로 추가된 기능입니다.

JAVA 8  컬렉션에는 스트림을 반환하는 Stream() 메서드가 있습니다.

 

스트림 이란?

데이터 처리 연산을 하기 위해 데이터 소스(컬렉션, 배열, I/O자원 등)에서 추출된 '연속된 요소들(Sequence Of Elements)' 이라고 볼 수 있겠습니다.

 

스트림을 사용하면 SQL 질의를 하듯이 선언형으로 컬렉션 데이터를 처리할 수 있습니다.

 

/** 
* 요구사항(문제) : 이름이 홍으로 시작하는 사람을 찾아라
*/

/**
* SQL 질의
*/
SELECT M.* FROM MEMBER M WHERE M.NAME LIKE '홍%';


/**
* JAVA FOR문
*/
List<String> memberList = new ArrayList();
for(int i = 0; i<memberList.size(); i++){
    if(memberList.get(i).getName().startWith("홍")){ 
    		memberList.add(member.get(i).getName());
    }
}


/**
* Stream을 이용
*/
List<String> memberNameList = memberList.stream()
            .filter(member -> member.getName().startWith("홍"))
            .map(member -> member.getName())
            .collect(Collectors.toList());

 

for문을 작성했을 때보다 훨씬 직관적이고 읽기 편한 코드가 됩니다.

 

또한 parallelStream을 통해 데이터  병렬 처리가 가능합니다.

 

위의 filter 뿐만 아니라 sorted, map, collect와 같은 연산들을 자유롭게 이용하여

데이터를 원하는 데로 가공, 처리 할 수 있습니다.

 

 

스트림을 사용해서 얻을 수 있는 이점은 아래와 같습니다.

1. 선언형이다.

=>가독성

 

2. 여러가지를 조합(filter, sorted, map, collect 등등)하여 사용할 수 있다. 

=> 유연성 

 

3.  parallelStream을 이용하면 병렬처리가 가능해지기 때문에 단일 스레드 환경의 데이터 처리보다 빠른 데이터 처리가 가능합니다.

=> 성능향상

 

컬렉션과 비교


컬렉션
== 자료구조 -> 시간, 공간 복잡성과 관련된 '요소저장''접근연산'이 주를 이룸.

BUT, 스트림'표현 계산식'이 주를 이룬다.

 

컬렉션의 관심사는 '데이터' 이고, 스트림의 관심사는 '계산' 이다.

 

+ 정렬된 컬렉션인 경우 '정렬이 유지'됨.

 

+ 스트림은 함수형 프로그래밍 언어에서 '일반적으로 지원하는 연산''데이터 베이스와 비슷한 연산'을 지원.

그리고  '순차적'   OR   '병렬'  실행이 가능.

 

 

 

스트림과 컬렉션의 차이

핵심 : 데이터를 계산하는 시점이 다르다!!

1. 컬렉션 - (저장 전 처리)

현재 컬렉션에 데이터를 저장한다는 의미 => 저장하기 전에 모두 계산 해서 저장한다는 의미입니다.

+ 추가, 삭제가 발생할 경우에도 새롭게 저장 해야함.

 

2. 스트림 (최종연산에 몰아서 처리)

사용자가 요청할 때만 요소를 계산 ==> 요청 값만 스트림에서 추출. AND 최종연산에서 모두 몰아서 처리

 

 

 

 

외부 반복과 내부 반복의 비교

스트림에서는 반복을 추상화 한 내부반복이 존재합니다.

 

외부반복 : 개발자가 직접 for문을 작성하여 반복.

내부반복 : for문을 작성하지 않아도 stream에서 내부적으로 반복적으로 실행해줌.

 

 

외부 반복 (직접 FOR문 작성)

개발자가 직접 요소를 반복 => 컬렉션 For-each 사용

 

- 장점

Stream을 사용한 내부반복보다 속도가 빠르다.

 

- 단점

병렬처리를 하려면 개발자가 직접 코드 작성을 해야함.

 

@DisplayName("For-each를 사용하는 외부 반복")
@Test
public void useForeach() {
    List<String> dishNames = new ArrayList<>(); // 요리의 이름들을 담을 리스트
    for (Dish dish : menu) {
        dishNames.add(dish.getName());      // 요리의 이름들을 dishNames 리스트에 담음.
    }
    dishNames.stream().forEach(System.out::println);    //dishNames 순회하면서 이름을 출력
}

@DisplayName("Iterator 객체를 사용하는 외부 반복")
@Test
public void useIterator() {
    List<String> dishNames = new ArrayList<>(); //For-each를 사용하는 외부 반복과 동일
    Iterator<Dish> iterator = menu.iterator();  //menu로 부터 Iterator를 가져옴.

    while (iterator.hasNext()) {
        Dish dish = iterator.next();
        dishNames.add(dish.getName());  //iterator를 이용해 dish의 이름을 dishNames 리스트에 담음.
    }
    dishNames.stream().forEach(System.out::println);  //dish의 이름을 출력
}

 

 

내부 반복 ( 스트림 내부에서 반복처리 )

- 장점

스트림에서 반복을 알아서 처리해준다.

  작업을 병렬로 처리가능.

  다양한 순서로 작업 순서를 최적화 가능.

  스트림 라이브러리의 내부 반복은 데이터 표현과 하드웨어를 활용한 병렬 구현을 자동으로 선택.

 

- 단점

 스트림 생성 비용이 비싸다. 외부 반복의 for문 보다 속도가 느리다.

+ 추가내용 : java primitive 타입일 경우 일반 FOR문의 속도가 스트림 보다 훨씬 빠르지만,

Wrapper타입이나 구성되어 있는 로직 자체가 복잡한 경우 Stream이 더 빠른 경우가 생김.

 

    @DisplayName("스트림을 이용한 내부 반복")
    @Test
    public void useStream() {
        //stream을 이용한 내부반복 -> 선언형이라 가독성이 높다.
        List<String> dishNames = menu.stream()      // 스트림 생성.
                .map(Dish::getName)         //dish -> dish.getName 으로 매핑.
                .collect(toList());         //리스트로 collect
        dishNames.stream()
                .forEach(System.out::println);// dishNames를 순회하면서 각 요소의 이름을 출력

//        AtomicInteger index = new AtomicInteger();
	/*
	람다식 안에서는 final 형태의 변수만 선언이 가능하므로
        AtomicInteger 또는 배열을 사용하여 값을 변경.
        */
        final int[] index = {0};
        dishNames.parallelStream().forEach(dishname -> {
                    Assertions.assertThat(menu.get(index[0]++).getName()).isEqualTo(dishname);   //순회하면서 검증.
                });
    }

 

 

외부 반복 코드를 내부 반복 코드로 변환

@DisplayName("외부반복을 내부반복으로 변환(외부반복코드)")
@Test
public void externalIterator(){
    List<String> highCaloriDishes = new ArrayList<>(); //조건에 해당하는 String을 담을 리스트변수
    Iterator<Dish> iterator = menu.iterator();  //1. menu로 부터 iterator를 가져온다
    while (iterator.hasNext()){
        Dish dish = iterator.next();
        if(dish.getCalories() > 300){
            highCaloriDishes.add(dish.getName()); //2. 칼로리가 300보다 큰 요리들을 highCaloriDishes에 추가
        }
    }
    highCaloriDishes.stream().forEach(System.out::println); //3. 요리 출력
}

@DisplayName("외부반복을 내부반복으로 변환(내부반복코드)")
@Test
public void  internalIterator(){
    List<String> highCaloriDishes = menu.stream()   //1.  스트림을 가져온다
            .filter(dish -> dish.getCalories() > 300)   //2.트림에서 요리의 칼로리가 300이 넘는 요리들만 필터링
            .map(dish -> dish.getName())        //3.필터링 된 요리객체를 요리 이름으로 매핑
            .collect(toList());         //4. collect

    highCaloriDishes.stream().forEach(System.out::println);  //5. hCaloriDishes 를 출력
}
1

스트림 연산

중간연산 (Filter, map, limit, sorted, distinct)

- 중간연산은 다른 스트림을 반환 => 따라서 중간연산을 이용해 질의를 작성가능.

- 중간연산은 파이프라인에 단말연산이 실행되기 전까지 아무연산도 수행하지 않는다.(Lazy)

   => 중간연산을 합친다음에 최종연산으로 한꺼번에 처리

 

**파이프라인 : 여러 개의 스트림이 연결된 구조.

 

최종연산(forEach, count, collect)

- 스트림 파이프라인에서 결과를 도출 하는 연산 => 결과 값으로 다른 자료구조를 리턴한다. ex) List, Integer, void 등

 

예제코드

/*
중간연산은 다른 스트림을 반환 -> 따라서 중간연산을 이용해 질의를 작성 가능하다.
중간연산의 중요한 특징은 최종 단말연산을 스트림파이프라인에 실행하기 전까지는 아무연산도 수행하지 않는다. 
-> 즉 lazy Operation 이다.
*/
@DisplayName("스트림 연산")
@Test
public void streamOperation(){
    List<String> names = menu.stream()
            .filter(dish -> dish.getCalories() > 300)   
            .map(Dish::getName)             
            .limit(3)              
            .collect(toList());             
            )
            

    @DisplayName("스트림 중간연산 확인해보기")
    @Test
    public void intermediateOperation(){
        List<String> names = menu.stream()
                .filter(dish -> {
                    System.out.println("filtering : " + dish.getName()); // 출력 x
                    return dish.getCalories() > 300;
                })
                .map(dish -> {
                    System.out.println("mapping : " + dish.getName());  // 출력 x
                    return dish.getName();
                })
                .limit(3)
                .collect(toList()); //최종연산인 이 때 모든 연산이 처리 되면서 출력문도 같이 출력된다.
        System.out.println(names);
    }

    @DisplayName("스트림 중간연산과 최종연산 이해도 확인 (중간연산과 최종연산 구분해보기)")
    @Test
    public void intermediateAndTerminalOperation(){
        long overThreeHundredsCal = menu.stream()
                .filter(dish -> dish.getCalories() > 300)   
                .distinct()                                 
                .limit(3)                            
                .count();                                   
        System.out.println(overThreeHundredsCal);           
    }
}

 

스트림의 일반적인 flow

1. 질의를 수행할 데이터 소스(컬렉션, 배열, I/O 자원 등)를 통해 스트림 생성.

2. 스트림 파이프 라인을 구성할 중간연산 (filter, map, limit, sorted, distinct)

3. 스트림 파이프 라인을 실행하고 결과를 도출할 최종 연산(forEach, count, collect)

 

스트림의 특징

1. Lazyness

스트림은 파이프라인의 형태로 구성되어 있습니다.

그러나, 중간 연산은 각각 하면서 진행하는게 아닌 중간연산들을 모아 최종적으로 연산하게 됩니다.

즉, 최종연산이 이루어지기 전까지는 아무런 결과도 도출되지 않습니다.

 

2. Shortcircuit 의 특성을 가진다.

스트림은 파이프라인에서 요구하는 조건이 만족되면 더 이상 순회하지 않습니다.

 

+추가 ShortCircuit :  여러 개의 And 연산으로 이어진 하나의 boolean 표현식을 True,False로 평가, 판단한다고 했을 때, 앞에 하나라도 False가 존재한다면 뒤는 비교 해 보지 않아도 평가식은 False가 됨.

 

3. 내부반복

스트림은 사용자가 직접 반복문을 작성하지 않아도 모든 중간연산(filter, map, limit 등)과 최종연산(collect, count, foreach)에서 내부반복을 지원하므로, 사용자가 따로 반복문을 작성해 줄 필요가 없습니다.

 

4. 스트림은 소비된다.

스트림은 생성 후 순회 할 경우 소모가 되는 특성을 가지고 있습니다.

그러므로  같은 스트림을 2번 순회하는 경우 Exception이 발생합니다.

 

예제코드

@DisplayName("스트림은 단 한번만 소비 될 수 있다!!")
@Test
public void operatingOnlyOnceStream() {
    List<String> companys = Arrays.asList("Apple", "Samsung", "LG", "Asus"); //회사이름들
    Stream<String> stream = companys.stream();   // Sequence Of Elements (연속된 요소) 의 스트림
    stream.forEach(System.out::println);        //첫번째 순회 -> 제대로 출력
    //java.lang.IllegalStateException: stream has already been operated upon or closed
    org.junit.jupiter.api.Assertions.assertThrows(IllegalStateException.class,
            () -> {
                stream.forEach(System.out::println);  //두번째 순회 -> 이미 한번 돌았기 때문에 출력 x.
            }, "예외가 발생하지 않았습니다.. 테스트 fail!!!!!!!!!!!!!!!!!!!!!!!");
}

 

 

 

 

 

스트림 요약 정리

1. 스트림은 소스에서 추출된 'Sequence Of Element'로, 데이터 처리 연산을 지원한다.

2. 스트림은 내부반복을 지원한다. 내부반복은 filter, map, sorted 등의 연산으로 반복을 추상화 한다. (외부반복은 병렬성을 스스로 관리해야 함.)

3. 스트림에는 중간연산 (스트림을 리턴)과 최종연산(다른 타입을 리턴)이 있다.

4. 중간연산은 스트림을 반환하면서 다른연산과 연결되는 연산이므로 파이프라인 구성은 가능하지만, 단말연산(최종연산)없이는 어떠한 결과도 도출할 수 없다. 결과 도출은 단말연산이 필요하다.

5. 스트림 파이프라인을 처리해서 다른 타입(자료구조)으로 결과를 반환하는 연산을 단말연산(최종연산)이라고 한다.

6. 스트림의 요소는 요청할 때 lazy하게 계산된다. (중간연산을 모아서 나중에 최종연산에서 처리 한다.)

 

 

 

 

참고

  • 모던 자바 인 액션