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. 최대한 연산을 지연한다.
최대한 연산을 지연한다
는 말은 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-loop
와 Stream
의 성능이 차이가 나지 않는 경우도 있으니 성능 시간을 테스트 해보고 Stream
을 사용해도 되는지 여부를 결정하면 될 것 같다.
관련 예제 : github/Kyu0
이번 게시물에는 Stream API에 대해서 알아보았다. 처음으로 어떤 개념에 대해서 정리하는 글을 써봤는데 읽는 사람을 배려하면서 쓰기란 참 어려운 것 같다.
하지만 내 블로그에 작성된 글이 누군가에게 도움이 되길 바라며 앞으로도 열심히 작성해야겠다.
궁금한 점, 잘못된 부분 댓글로 지적해주시면 확인 후 수정하도록 하겠습니다. 긴 글 읽어주셔서 감사합니다.