Java 8 이후 가장 관심을 많이 받은 건 아마도 Stream API가 아닐까 싶을 정도로 굉장히 이제는 중요하고 모르면 절대 안되는 이 녀석에 대해 공부해보자.
Stream
- 데이터를 담고 있는 저장소(컬렉션)가 아니다.
- 스트림이 처리하는 데이터 소스를 변경하는 게 아니다.
- 스트림으로 처리하는 데이터는 오직 한번만 가능하다.
- 중개 오퍼레이션은 근본적으로 Lazy하다.
- 손쉽게 병렬 처리를 할 수 있다.
이게 다 무슨말일까? 하나씩 차근 차근 알아가보자.
데이터를 담고 있는 저장소가 아니다.
이건 말 그대로 스트림으로 처리하는 데이터는 컬렉션이 아니라는 말이다.
import java.util.ArrayList;
import java.util.List;
import java.util.stream.Stream;
public class Main {
public static void main(String[] args) {
List<String> friends = new ArrayList<>();
friends.add("John");
friends.add("Jane");
friends.add("Bob");
friends.add("Mary");
Stream<String> stream = friends.stream();
}
}
- 위 코드를 보면, `friends`라는 List와 friends.stream()의 반환값은 명확히 Stream으로 다르다.
- 쉽게 말해 스트림은 저장소 역할을 하는게 아니라, 특정 자료구조에 대해 어떤 처리를 할 수 있는 일회성 공간이라고 생각해야 한다.
스트림이 처리하는 데이터 소스를 변경하는 게 아니다.
import java.util.ArrayList;
import java.util.List;
import java.util.stream.Stream;
public class Main {
public static void main(String[] args) {
List<String> friends = new ArrayList<>();
friends.add("John");
friends.add("Jane");
friends.add("Bob");
friends.add("Mary");
List<String> upper = friends.stream()
.map(String::toUpperCase)
.toList();
System.out.println(friends);
System.out.println(upper);
}
}
- 위 코드를 보면, `friends`를 스트림으로 각 요소별로 대문자로 변경하여 새로운 리스트를 만들어냈다.
- 그럼 이건 기존의 `friends`도 변경하는 걸까? 아니다! 기존의 데이터 소스를 변경하는 게 아니다.
- 다음 실행 결과를 보면, 기존의 데이터 소스는 그대로이고 스트림으로 처리한 데이터를 새로운 리스트를 반환하는 것이다.
실행 결과
스트림으로 처리한 데이터는 오직 한번만 가능하다.
import java.util.ArrayList;
import java.util.List;
import java.util.stream.Stream;
public class Main {
public static void main(String[] args) {
List<String> friends = new ArrayList<>();
friends.add("John");
friends.add("Jane");
friends.add("Bob");
friends.add("Mary");
Stream<String> friendsStream = friends.stream();
List<String> upperFriends = friendsStream.map(String::toUpperCase).toList();
System.out.println(upperFriends);
List<String> lowerFriends = friendsStream.map(String::toLowerCase).toList();
System.out.println(lowerFriends);
}
}
- 위 코드를 보면, `friends`라는 리스트를 스트림으로 변환한 `friendsStream`이 있다.
- 이 녀석으로 각 요소에 대해 toUpperCase()를 처리한 새로운 리스트를 만들었다.
- 그 이후에 다시 이 `friendsStream`을 이용할 수 있을까? 그렇지 않다. 위 코드처럼 다시 `friendsStream`을 통해 각 요소에 대해 toLowerCase()를 처리한 새로운 리스트를 만드려고 한다면 다음과 같은 에러를 마주하게 된다.
중개 오퍼레이션은 근본적으로 Lazy하다.
이게 조금 중요한 말인데, 보통은 스트림을 사용할 때 다음과 같이 사용하는 사례를 많이 마주할 것이다.
customFieldManager.getCustomFieldObjects().stream()
.filter(customField -> customField.getCustomFieldType().getName().equals("Date Picker"))
.map(SimpleCustomFieldDTO::new)
.collect(Collectors.toList());
- 위 코드는 예시 코드이다.
- 스트림으로 변환한 후, filter()를 거치고, map()을 거쳐서, 리스트로 최종적으로 반환한다.
- 이렇듯, 스트림은 중개 오퍼레이션과 종료 오퍼레이션이 나뉘어져 있다.
- 여기서 중개 오퍼레이션은 filter(), map()이고, 종료 오퍼레이션은 collect()이다.
- 쉽게 생각하면, 중개 오퍼레이션은 반환값이 그대로 Stream이다. 반대로 종료 오퍼레이션은 반환값을 Stream으로 주지 않는다.
import java.util.ArrayList;
import java.util.List;
import java.util.stream.Stream;
public class Main {
public static void main(String[] args) {
List<String> friends = new ArrayList<>();
friends.add("John");
friends.add("Jane");
friends.add("Bob");
friends.add("Mary");
Stream<String> stringStream = friends.stream().filter(friend -> friend.equals("John"));
Stream<String> stringStream1 = friends.stream().map(String::toUpperCase);
}
}
- 보면 filter(), map() 까지만 실행한 반환값은 모두 Stream이다. 즉, 이 두개는 중개 오퍼레이션이라는 말이다.
import java.util.ArrayList;
import java.util.List;
import java.util.stream.Stream;
public class Main {
public static void main(String[] args) {
List<String> friends = new ArrayList<>();
friends.add("John");
friends.add("Jane");
friends.add("Bob");
friends.add("Mary");
List<String> friendsList = friends.stream()
.filter(friend -> friend.equals("John"))
.toList();
}
}
- 반면, toList()를 호출한 반환값은 Stream이 아니라 List이다. 즉, toList()는 종료 오퍼레이션이라는 말이다.
- 참고로, forEach()도 종료 오퍼레이션이다. 얘는 반환값이 없기 때문에 헷갈릴 수 있어서 이렇게 꼭 집어서 말해봤다.
이제 중개 오퍼레이션과 종료 오퍼레이션의 차이를 알았다. 그럼 여기서 이제 "중개 오퍼레이션은 근본적으로 Lazy하다"라는 말은 무엇이냐면, 중개 오퍼레이션은 종료 오퍼레이션을 만나기 전까지 실제적으로 실행되지가 않는다! 다음 코드를 보자!
import java.util.ArrayList;
import java.util.List;
public class Main {
public static void main(String[] args) {
List<String> friends = new ArrayList<>();
friends.add("John");
friends.add("Jane");
friends.add("Bob");
friends.add("Mary");
friends.stream()
.filter(friend -> {
System.out.println("friend: " + friend);
return friend.equals("John");
});
}
}
- 자, filter()는 중개 오퍼레이션이다. 이 filter() 이후로 종료 오퍼레이션은 없다. 즉, 여전히 이 녀석은 타입이 Stream인 상태이다. 그런데 내가 filter() 안에 각 요소들을 찍어보고 싶어서 `System.out.println("friend: " + friend)`를 작성한다고 이게 출력될까?
실행 결과
위 실행 결과처럼 아무것도 출력하지 않는다. 디버깅해서 브레이크 포인트 걸어봐도 안 걸린다! 그래서! 정말 중요한 내용이다! 중개 오퍼레이션은 근본적으로 Lazy하다는 말은, 종료 오퍼레이션을 만나기 전까지 중개 오퍼레이션의 작업은 진행되지가 않는다는 말이다! 그럼 내가 저 코드에 종료 오퍼레이션을 넣고 실행하면 결과는 어떻게 될까?
import java.util.ArrayList;
import java.util.List;
public class Main {
public static void main(String[] args) {
List<String> friends = new ArrayList<>();
friends.add("John");
friends.add("Jane");
friends.add("Bob");
friends.add("Mary");
friends.stream()
.filter(friend -> {
System.out.println("friend: " + friend);
return friend.equals("John");
}).toList();
}
}
- 딱 한 부분, toList()를 붙이는 것 말고 한 게 없다.
실행 결과
정리
스트림 파이프라인은 0 또는 다수의 중개 오퍼레이션과 한개의 종료 오퍼레이션으로 구성되고, 스트림의 데이터 소스는 오직 종료 오퍼레이션을 실행할 때만 처리한다.
손쉽게 병렬 처리를 할 수 있다.
이건 무슨말이냐면 이런 의문이 생길 것이다. "아니 뭐 굳이 스트림을 써? 그냥 for 루프 사용하면 되는거 아니야?" 맞다. 상관없다. 단순한 코드는 어떤걸 사용하더라도 뭐 크게 가시성이 달라지지도 않고 둘 다 읽기 편할 수 있다. 아래 코드를 보자.
import java.util.ArrayList;
import java.util.List;
public class Main {
public static void main(String[] args) {
List<String> friends = new ArrayList<>();
friends.add("John");
friends.add("Jane");
friends.add("Bob");
friends.add("Mary");
friends.stream()
.filter(friend -> friend.equals("John"))
.forEach(System.out::println);
for (String friend : friends) {
if (friend.equals("John")) {
System.out.println(friend);
}
}
}
}
- 하나는 스트림을 사용했고, 하나는 for 루프를 사용했다. 뭐 둘 다 읽기 편하고 아무런 문제도 없다.
- 실행 결과도 동일할 것이다.
그런데, 이런 경우가 있다. for 루프는 병렬 처리를 하기가 쉽지 않다. 위에 저 for 루프 안에서 요소 하나하나씩 순회하며 처리하지 저걸 병렬로 처리하고 있지 않는단 말이다. 그런데 스트림은 이걸 병렬로 처리하는게 굉장히 수월하다. 스트림은 요소 하나하나를 직렬로 어떤 행위를 처리할수도 있고 병렬로 어떤 행위를 처리할수도 있다.
import java.util.ArrayList;
import java.util.List;
public class Main {
public static void main(String[] args) {
List<String> friends = new ArrayList<>();
friends.add("John");
friends.add("Jane");
friends.add("Bob");
friends.add("Mary");
friends.parallelStream()
.filter(friend -> friend.equals("John"))
.forEach(System.out::println);
}
}
- 이렇게 stream() 대신에 parallelStream()을 사용하면 끝난다.
- 이럼 요소를 병렬로 처리하게 된다.
근데 또 눈으로 봐야만 믿는 사람들을 위해(나 포함) 아래와 같이 코드를 조금만 더 수정해보자.
import java.util.ArrayList;
import java.util.List;
public class Main {
public static void main(String[] args) {
List<String> friends = new ArrayList<>();
friends.add("John");
friends.add("Jane");
friends.add("Bob");
friends.add("Mary");
friends.parallelStream()
.filter(friend -> {
System.out.println("friend: " + friend + "||" + Thread.currentThread().getName());
return friend.equals("John");
})
.forEach(System.out::println);
}
}
- 실행하고 있는 쓰레드의 이름을 한번 찍어보자.
실행 결과
이 실행결과를 보면 알겠지만, 쓰레드의 이름이 다르다. 즉, 병렬처리가 제대로 되고 있다는 뜻이다. 이게 이제 손쉽게 병렬 처리를 할 수 있다는 말이다.
근데 손쉽게 병렬 처리를 한다고 마냥 좋을까?
지금 저 코드에서 병렬 처리를 하면 더 빠를까? 내가 볼땐 아니다. 이것도 눈으로 한번 확인해보자.
import java.util.ArrayList;
import java.util.List;
public class Main {
public static void main(String[] args) {
List<String> friends = new ArrayList<>();
friends.add("John");
friends.add("Jane");
friends.add("Bob");
friends.add("Mary");
withParallelStream(friends);
withForLoop(friends);
}
private static void withParallelStream(List<String> friends) {
long startTime = System.currentTimeMillis();
friends.parallelStream()
.filter(friend -> friend.equals("John"))
.forEach(System.out::println);
long endTime = System.currentTimeMillis();
System.out.println("parallelStream elapsed time: " + (endTime - startTime) + "ms");
}
private static void withForLoop(List<String> friends) {
long startTime = System.currentTimeMillis();
for (String friend : friends) {
if (friend.equals("John")) {
System.out.println(friend);
}
}
long endTime = System.currentTimeMillis();
System.out.println("for loop elapsed time: " + (endTime - startTime) + "ms");
}
}
- 두개를 실행해보자, 하나는 스트림으로 병렬처리를, 하나는 그냥 for 루프를 사용해서 동일한 작업을 하는데 시간을 측정해봤다.
실행 결과
엥?! 오히려 하나의 쓰레드로만 실행한게 더 빠르다. 왜 그럴까?
→ 쓰레드를 여러개 사용하는데는 컨텍스트 스위칭 비용이 들기 때문이다. 또한, 쓰레드를 만들어내는 것도 리소스를 사용하는 것이기에 그렇다. 단순하게 생각해서 지금 저 `friends`라는 리스트는 그래봐야 개수가 4개밖에 없는 정말 작은 리스트이다. 그리고 요즘 컴퓨터는 1초에 연산을 몇번이나 할까? 수십억번을 한다. 수십억번. 4개 돌리는거? 일도 아니다. 그러니까 실제로 0ms가 걸린것이고. 그런데 4개 돌리는 그 와중에도 쓰레드를 3개를 더 만들어 총 4개를 사용하고, 그 4개를 돌려가며 사용하는 컨텍스트 스위칭 비용이 오히려 성능에 악화를 시키는 것이다. 그러니까 병렬 처리를 스트림으로 단순하게 할 수 있다고 해서 반드시 좋다고 생각하면 큰 오산이다. 이 경우가 그럼 언제 효율적일까? 데이터가 정말 많을때나 그 데이터로 처리하는 로직이 굉장히 복잡해서 하나의 쓰레드가 다 하는것보다 여러개의 쓰레드가 나눠 하는게 누가봐도 더 효율적일때 이 병렬 처리를 고려하면 좋다!
여러가지 Stream API
결론부터 말하면, 이거 외우는거 아니다. 쓰다보면 외워져서 바로 이거 쓰면 되겠구나! 싶은게 생길것이고, 모르면 이런거 있나? 찾아보면 된다. 진짜 여기서는 딱 두개만 해봐야겠다.
filter
말 그대로 뭔가 걸러내는 중개 오퍼레이션이다. 일단 코드로 바로 보자.
public class Shop {
private String name;
private boolean isOpen;
public Shop() {
}
public Shop(String name, boolean isOpen) {
this.name = name;
this.isOpen = isOpen;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public boolean isOpen() {
return isOpen;
}
public void setOpen(boolean open) {
isOpen = open;
}
@Override
public String toString() {
return "Shop{" +
"name='" + name + '\'' +
", isOpen=" + isOpen +
'}';
}
}
- 우선 아주 간단한 클래스를 하나 만들었다. 가게에 대한 클래스고 가게 이름과 현재 열었는지 안 열었는지에 대한 필드들이 있다.
import java.util.ArrayList;
import java.util.List;
public class ShopMain {
public static void main(String[] args) {
Shop shopA = new Shop("A", true);
Shop shopB = new Shop("B", true);
Shop shopC = new Shop("C", false);
Shop shopD = new Shop("D", true);
List<Shop> shops = new ArrayList<>();
shops.add(shopA);
shops.add(shopB);
shops.add(shopC);
shops.add(shopD);
shops.stream()
.filter(Shop::isOpen)
.forEach(System.out::println);
}
}
- 그리고 이렇게, 현재 오픈한 가게가 있는지를 찾아보는 간단한 스트림 API
- 메서드 레퍼런스를 사용해서 매우매우 깔끔하게 작성했다.
실행 결과
근데, 이러고 싶을때가 있다. 안 열은 가게를 알고 싶은 경우가 있다. 이때는 역(Not)을 사용해야 하는데 그럼 이게 뭐가 살짝 불편하냐면 메서드 레퍼런스를 못쓴다. 다음과 같이 컴파일 오류가 난다.
이럴때, 조금 더 이쁘게 작성할 수가 있는데, 이전 포스팅에서 배운 Predicate<T>을 사용하는 것이다! 왜냐하면 filter가 받는 타입 자체가 Predicate<T>이기 때문에 더할 나위없이 완벽하다. 그래서 이렇게 작성할 수 있다.
shops.stream()
.filter(Predicate.not(Shop::isOpen))
.forEach(System.out::println);
앞으로, 역(Not)과 메서드 레퍼런스를 같이 사용하고 싶을때 이 Predicate<T>을 적극 활용하자!
flatMap
이 flatMap은 map의 추가 기능이라고 보면 된다. map이 뭐냐? 인풋을 받아 아웃풋으로 돌려준다. 그래서 인자의 타입도 우리가 배운 Function<T, R>이다. flatMap도 어떤 값(A)을 받아 어떤 값(B)로 만들어준다. 그런데 앞에 flat이 붙었다. 이건 뭐냐면, 리스트를 받아서 그 리스트의 요소를 다 풀어버리는 것이다. 코드로 보자.
import java.util.ArrayList;
import java.util.List;
import java.util.function.Predicate;
public class ShopMain {
public static void main(String[] args) {
Shop shopA = new Shop("A", true);
Shop shopB = new Shop("B", true);
Shop shopC = new Shop("C", true);
Shop shopD = new Shop("D", true);
List<Shop> openedShops = new ArrayList<>();
openedShops.add(shopA);
openedShops.add(shopB);
openedShops.add(shopC);
openedShops.add(shopD);
Shop shopE = new Shop("E", false);
Shop shopF = new Shop("F", false);
Shop shopG = new Shop("G", false);
Shop shopH = new Shop("H", false);
List<Shop> closedShops = new ArrayList<>();
closedShops.add(shopE);
closedShops.add(shopF);
closedShops.add(shopG);
closedShops.add(shopH);
List<List<Shop>> allShops = new ArrayList<>();
allShops.add(openedShops);
allShops.add(closedShops);
allShops.stream()
.flatMap(shops -> shops.stream())
.forEach(shop -> System.out.println(shop.getName()));
}
}
- openedShops, closedShops 두 개의 리스트가 있다.
- 그리고 이 각각 리스트를 리스트로 담은 allShops가 있다.
- 그럼 allShops는 요소가 각각이 리스트이다.
- 이 allShops를 가지고 스트림을 돌리면 각 요소 하나하나가 리스트인데 flatMap을 사용해서 이 리스트를 풀어 헤치는 것이다. 그래서 그 하나의 요소인 리스트 전체를 다 돌고, 그 다음 하나의 요소인 리스트 전체를 다 돌 수 있게 말이다.
실행 결과
요 녀석을 더 깔끔하게 이렇게 바꿀 수도 있다.
//allShops.stream()
// .flatMap(shops -> shops.stream())
// .forEach(shop -> System.out.println(shop.getName()));
allShops.stream()
.flatMap(Collection::stream)
.forEach(shop -> System.out.println(shop.getName()));
이 정도만 알아보고 나머지 여러 기능들은 공식 문서를 통해 필요할 때 찾아서 사용하면 된다. 다시 말하지만 이걸 외우는거 아니다! 안 외워도 자주 사용하는건 저절로 외워지기도 하고 필요한게 생기면 찾아서 사용하면 된다! 다음 공식 문서에서.
'JAVA의 가장 기본이 되는 내용' 카테고리의 다른 글
[Java 8] CompletableFuture (0) | 2024.11.30 |
---|---|
[Java 8] Optional (0) | 2024.11.27 |
[Java 8] 함수형 인터페이스와 람다 표현식 (0) | 2024.11.25 |
애노테이션 (6) | 2024.10.21 |
리플렉션 (6) | 2024.10.21 |