이미지 출처 : https://steady-coding.tistory.com/309
List<String> names = new ArrayList<>();
names.add("aaa");
names.add("bbb");
names.add("ccc");
names.add("ddd");
names.add("eee");
List<String> upperNames = names.stream().map(String::toUpperCase).collect(Collectors.toList());
// Stream은 처리하는 원본 데이터를 변경하지 않고 별도의 data 흐름을 생성한다.
names.forEach(System.out::println); // 원본 데이터는 변하지 않음
upperNames.forEach(System.out::println);
// 기존의 방식은 변경된 데이터를 기존 데이터에 갱신하는 것이 복잡하며 원본 데이터를 건드리게 됨
for(int i = 0; i < names.size(); i++) {
names.set(i, names.get(i).toUpperCase());
}
Stream의 가장 큰 특징 중 하나는 원본 데이터를 Source로 하지만
원본 데이터를 변경(수정/삭제)하지 않고 별도의 데이터 흐름(Stream)을 만든다는 것 이다.
위의 코드에서도 names라는 list의 String들을 대문자로 바꾸었지만
이를 upperNames라는 새로운 list에 담아 사용하지 기존의 names는 변경하지 않는다.
기존 순회(for) 방식은 구체적인 동작은 확인 할 수 있으나
무엇을 하려고 그 동작을 하는지는 Code를 살펴봐야 파악 가능하다.
(물론 구체적인 동작이 서술되어 있으므로 Debugger 사용은 더 용이 할 수 있음)
Stream은 크게 2가지 중계형 오퍼레이터와 종료형 오퍼레이터로 나뉜다.
중계형 오퍼레이터(filter, map, flatMap...)
1. return type이 Stream이다.(사용 할 수 있는 Data를 return하지 않음)
2. Lazy하다.(종료형 오퍼레이터로 마무리하지 않으면, 작성한 중계형 오퍼레이터는 작동하지 않음)
종료형 오퍼레이터(collect, forEach, anyMatch...)
1. return type이 Stream이 아니다.(사용 할 수 있는 Data(List, Map, boolean...)를 return 한다.)
List<String> upperNames2 = names.parallelStream().map((s) -> {
System.out.println(s + " : " + Thread.currentThread().getName());
return s.toLowerCase();
}).collect(Collectors.toList());
Stream의 가장 큰 장점 중에 하나는 위와 같이 간단하게(parallelStream())
병렬처리(복수의 thread 이용)가 가능하다는 점이다.
위의 출력 결과에서 확인 할 수 있다시피 하나의 thread(main)이 아닌
여러 thread들을 동시에 실행하다보니 출력 순서가 실행 할 때마다 달라진다.
병렬처리(parallelStream())의 경우 적은 양의 데이터에서는 thread 생성 및 분배에 대한 문제로
오히려 불필요한 자원을 낭비하는 것일 수 있으니 데이터가 대용량일 경우와 내부 처리 함수에 따라
테스트를 통해 일반 stream()과 성능을 비교하여 사용하는 것이 필요하다.
public class Classes {
private Integer id;
private String title;
private boolean closed;
public Classes(Integer id, String title, boolean closed) {
this.id = id;
this.title = title;
this.closed = closed;
}
get/setter...
}
public class ClassesApp {
public static void main(String[] args) {
List<Classes> springClasses = new ArrayList<>();
springClasses.add(new Classes(1, "spring boot", true));
springClasses.add(new Classes(2, "spring jpa", true));
springClasses.add(new Classes(3, "spring mvc", false));
springClasses.add(new Classes(4, "spring core", false));
springClasses.add(new Classes(5, "rest api", false));
List<Classes> javaClasses = new ArrayList<>();
javaClasses.add(new Classes(6, "java test", true));
javaClasses.add(new Classes(7, "java modern", true));
javaClasses.add(new Classes(8, "java 8 to 11", true));
List<List<Classes>> classList = new ArrayList<>();
classList.add(springClasses);
classList.add(javaClasses);
}
}
예제를 위해 다음과 같은 Classes를 만들고 이를 사용할 ClassesApp을 작성했다.
System.out.println("<Spring 수업중 수업명이 spring으로 시작하는 수업>");
springClasses.stream().filter(c -> c.getTitle().startsWith("spring"))
.forEach(c -> System.out.println("수업번호 : " + c.getId()));
springClasses.stream().filter(c -> !c.isClosed()) // close되지 않은 수업이므로 !이 맞음
.forEach(c -> System.out.println(c.getTitle()));
filter()는 Stream에서 조건에 해당하는 요소들 만을 걸러 또 다른 Stream을 return한다.
System.out.println("<Spring 수업중 수업이름만 추출하여 스트림 만들기>");
springClasses.stream().map(c -> c.getTitle()) // Classes::getTitle
.forEach(s -> System.out.println(s)); // System.out::println
map()은 요소를 원하는 형태로 변환(ex : Classes에서 title만 추출)한 Stream을 return한다.
System.out.println("<Spring 수업 중에 spring이 들어있는 수업의 제목만 모아서 list로 만들기>");
List<String> fmClasses = springClasses.stream().filter(c -> c.getTitle().contains("spring"))
.map(Classes::getTitle)
.collect(Collectors.toList());
filter()와 map()은 위와 같이 함께 쓰면 복잡한 작업을 간결한 Code로 작성 할 수 있다.
List<List<Classes>> classList = new ArrayList<>();
classList.add(springClasses);
classList.add(javaClasses);
System.out.println("<전체 수업중 open되어 있는 수업명>");
classList.stream().flatMap(Collection::stream) // list -> list.stream()
.filter(c -> !c.isClosed())
.forEach(c -> System.out.println(c.getTitle()));
복수의 List가 하나의 List에 담긴 Data의 경우 flatMap()을 활용하면
하나의 Stream에 각 List의 요소들을 마치 한 List에 담겨 있는 것처럼 옮겨 담을 수 있다.
System.out.println("<Java 수업 중에 test가 들어있는 수업이 있는지 확인>");
// anyMatch()는 종결형 오퍼레이션으로 boolean을 return
boolean flag = javaClasses.stream().anyMatch(c -> c.getTitle().contains("test"));
anyMatch()는 조건에 해당하는 경우 true 값을 return하여 조건문에 활용하기 좋을 듯 하다.
System.out.println("<10부터 1씩 증가하는 무제한 스트림 중에서 앞에 10개 빼고 최대 10개만 출력>");
Stream.iterate(10, i -> i + 1)
.skip(10).limit(10)
.forEach(System.out::println);
기본 데이터 없이 Stream 클래스 만으로 값을 생성하고 변형/조작 하는 방법들도 있다.
Stream은 가독성 측면에서는 깔끔하고 직관적이지만
성능 측면에서 보자면 10000개 이상 요소를 가진 Collection의 병렬 실행이 아니라면
for-loop에 비해 15배 느린 경우도 있을 수 있다는 것이 위 글의 내용이다.
위 글에서도 아래의 3가지 이유를 들며 Stream 사용을 신중히 하라는 의견을 내고있다.
- 성능 저하 가능성
- 가독성이라는 것의 주관성
- 디버깅의 어려움
개발자의 편의(가독성)와 사용자의 편의(성능)라고 거칠게 나눈다면
나는 아직까지는 개발자가 좀 더 수고스럽더라도 사용자의 편의(성능)를 추구해야 한다고 생각한다.
요즘은 Computing Power가 좋아져 약간의 비효율은 용납되어 가독성을 추구해야 한다지만
사용자가 크게 늘어난다면 그 비효율은 약간이 아니라 눈덩이처럼 불어날 수 있다고 생각하기 때문이다.
물론 내가 함수형 프로그래밍을 잘 안다면 위의 비효율을 감수하더라도
더 큰 이득을 만들어내는 프로그래밍을 할 수 있지 않을까라는 아쉬움이 들며
기술을 쓸때 상황과 내 능력에 맞는 적절한 선택을 할 줄 알아야겠다는 생각을 가져본다.