나의 의문점을 말하기전에, 스트림이 무엇인지 간단하게 살펴보자.
1. 스트림(Stream) 이란?
- Java 8부터 도입된 데이터 처리 기능
- 데이터 소스에서 데이터를 추상화하여 처리할 수 있도록 도와줌
- 컬렉션, 배열, 파일 등의 데이터 소스에서 데이터를 읽고, 중간 처리 및 최종 처리하여 원하는 결과를 만듦
- 스트림은 기존 데이터 소스의 요소를 가져와 중간 처리 연산을 수행하고 새로운 Stream으로 반환함
- 중간 처리 연산은 lazy evaluation 방식으로 동작하기에 최종 처리 연산이 호출될 때만 수행됨
- 즉, 중간 처리 연산을 호출해도 즉각적인 연산이 되지 않음
- 최종 연산이 수행되어야 스트림의 요소들이 중간 처리 연산을 거쳐 최종 연산에서 소모됨
- 함수형 프로그래밍 개념을 적용하여 작성했기에 가독성과 유지보수성이 높음
- 원본 데이터 소스를 변경하지 않고, 새로운 데이터 소스를 생성하여 데이터를 처리함
2. 최종연산 시 원본 데이터 소스가 변경되는 경우
스트림 특징 중 "원본 데이터 소스를 변경하지 않는다"를 읽었는데, 이 의문점은 뭐란 말인가?
일단 해당 특징을 간단하게 살펴보자.
List<String> names = new ArrayList<>();
names.add("myomyo");
names.add("aaa");
names.add("bbb");
Stream<String> strStream = names.stream().map(String::toUpperCase);
names.forEach(System.out::println);
/*
myomyo
aaa
bbb
*/
위 코드에서 names를 대문자로 변환하는 중간 처리 연산을 수행한 Stream을 만들고, names를 출력했지만 소문자로 출력된다. 이것이 원본 데이터 소스가 변경되지 않는다이다.
그렇다면 "원본 데이터 소스가 변경된거 아닌가?"라는 의문점은 어디에서 온것인가?
// given
List<String> names1 = new ArrayList<>();
names1.add("myomyo");
names1.add("aaa");
List<String> names2 = new ArrayList<>();
names2.add("bbb");
List<List<String>> namesList = new ArrayList<>();
namesList.add(names1);
namesList.add(names2);
// when
namesList.stream()
.filter(names -> names.size() == 2)
.forEach(names -> names.add("new"));
// then
namesList.stream()
.flatMap(Collection::stream)
.forEach(System.out::println);
/*
myomyo
aaa
new
bbb
*/
- given에서 테스트할 데이터를 생성
- when에서 namesList에 들어있는 List의 사이즈가 2인 것만 필터링해서 그 List에 "new" 추가
- then에서 namesList가 가지고 있는 List의 모든 요소를 출력
- 결과는 원본 데이터인 namesList에 "new"가 추가되어 있음
이것은 스트림의 최종연산인 forEach로 반복하는 것이 참조 객체인 List이기 때문이다.
따라서 참조를 통해 객체를 변경하는 것이므로 당연히 "new"가 추가되는 것이다.
이에 따라 아래 예시코드처럼 클래스의 인스턴스를 가진 리스트도 당연히 원본 데이터 수정이 가능하다.
class A {
int age;
public A(int age) {
this.age = age;
}
}
A a1 = new A(1);
A a2 = new A(1);
List<A> AList = new ArrayList<>();
AList.add(a1);
AList.add(a2);
AList.stream().forEach(a -> a.age = 2);
AList.forEach(a -> System.out.println(a.age));
/*
2
2
*/
참조된 객체를 변경하는 것이므로 막을 방법은 없다.
이 작업을 수행할 수 있다하더라도 이것은 사용해도 좋은걸까 의문점이 든다.
스트림은 원래의 데이터 소스를 변경하지 않는 것이 side-effects(부작용)를 만들지 않는다고 배웠기 때문이다.
따라서 A클래스를 가진 리스트가 있을 때, age가 20 넘는 것들에 대해서만 age를 +1 해주는 예시를 만들어본다.
List<A> AList = new ArrayList<>();
// 스트림 예시
AList.stream()
.filter(a -> a.age > 20)
.forEach(a -> a.age = a.age + 1);
// 컬렉션 for loop 예시
AList.forEach(a -> {
if(a.age > 20) {
a.age = a.age + 1;
}
}
// 일반 for loop 예시
for(A a : AList) {
if(a.age > 20) {
a.age = a.age + 1;
}
}
age에 관련된 메서드를 만들면 더욱 간결하게 만들 수 있지만 일단 그것은 논외로 친다.
위 코드에서 무엇이 가장 간결해 보이는가?
나는 스트림 방식이 가장 보기 편하고 눈에 잘들어온다.
그렇기에 스트림의 forEach문으로 참조 객체의 상태를 변경하는 것이 아닐까 싶다.
3. 최종연산 forEach로 참조 객체의 상태를 변경하는 것이 좋을까?
우선 스트림에서 반복할 수 있는 연산은 peek()라는 중간연산과 forEach() 최종연산이 있다.
peek()는 공식문서에 따르면 주로 디버깅을 위해 존재하는 것이라 명시했기에 잘 사용하지 않는다.
그러므로 반복은 forEach() 최종연산을 통해 할 수 밖에 없다.
따라서 해당 의문점을 찾아보기 위해 stackoverflow의 질문과 답변을 확인했다.
위 내용을 참고해서 적어본다.
3-1. 반복하는 동안 Java8의 Stream 내 객체를 변경해도 될까?
아래와 같은 코드가 있을 때 이렇게 사용해도 될까?
users.stream().forEach(u -> u.setProperty("value"))
가능하다.
하지만 스트림 내부의 객체 상태를 수정할 수 있지만 대부분의 경우 스트림 소스의 상태를 수정하면 안된다.
스트림 공식문서의 non-interference에 따르면 다음과 같다.
스트림을 사용하면 ArrayList와 같은 안전하지 않은 스레드 컬렉션을 포함하여 다양한 데이터 소스에서 병렬 집계 연산을 실행할 수 있다. 이는 스트림 파이프라인을 실행하는 동안 데이터 소스와의 간섭을 방지할 수 있는 경우에만 가능하다. escape-hatch 연산인 iterator()와 spliterator()를 제외하고는 터미널 연산이 호출될 때 실행이 시작되고 터미널 연산이 완료되면 실행이 종료된다.
대부분의 데이터 소스에서 간섭을 방지한다는 것은 스트림 파이프라인을 실행하는 동안 데이터 소스가 전혀 수정되지 않도록 하는 것을 의미한다. 여기에는 동시 수정을 처리하도록 특별히 설계된 concurrent collections를 소스로 하는 스트림은 예외이다. 동시 스트림 소스는 Spliterator가 CONCURRENT 특성을 보고하는 스트림 소스이다.
- 병렬 집계 연산을 하려면 데이터 소스와의 간섭을 방지할 수 있는 경우에만 가능
- 데이터 소스 간섭 방지는 보통 스트림 파이프라인을 실행하는 동안 데이터 소스가 전혀 수정되지 않도록 하는 것
따라서 내가 병렬 집계 연산을 사용하지 않는다면 사용해도 큰 부작용은 없을 듯하다.
다음과 같은 예시 코드를 스택오버플로우에서는 제공하고 있다.
List<User> users = getUsers();
// 사용해도 괜찮은 예시 #1
users.stream().forEach(u -> u.setProperty(value));
// ^ ^^^^^^^^^^^^^
// \__/
// 사용하면 위험한 예시 #1
users.stream().forEach(u -> users.remove(u));
//^^^^^ ^^^^^^^^^^^^
// \_____________________/
// 사용하면 위험한 예시 #2
List<Integer> list = IntStream.range(0, 10).boxed().collect(Collectors.toList());
list.stream()
.filter(i -> i > 5)
.forEach(i -> list.remove(i)); //throws NullPointerException
하지만 아까 non-interference에서 말했듯이 데이터 소스가 전혀 수정되지 않도록 하는 것을 의미한다고 했다.
그런데 사용해도 괜찮은 예시 #1에서는 데이터 소스를 수정하고 있다.
그에 따른 또 다른 대답은 여기서 볼 수 있었다. (링크)
List<User> users = getUsers();
// #1. user의 나이가 20이상인 것만 변경할 때
List<User> newUsers = users.stream()
.filter(u -> u.age >= 20)
.collect(toList());
newUsers.forEach(u -> u.setProperty(value));
// #2. 위 코드를 하나로 합쳐 부작용이 생길 수 있는 코드
users.stream()
.filter(u -> u.age >= 20)
.forEach(u -> u.setProperty(value));
여기서는 나이 20이상인 user를 모은 새로운 리스트를 만들고 그 리스트를 컬렉션의 forEach로 반복해서 변경한다.
만약 기존 리스트인 users를 재사용한다면 '부작용이 생길 수 있는 코드'처럼 작성하게 된다.
이 답변을 한 사람은 "코드의 길이는 안전 및 유지 관리성과 관련이 없다" 라고 말을 했다.
#1 코드보다는 #2 코드가 깔끔해보여서 이렇게 작성하고 싶은 욕심이 마구마구 생기지만 stackoverflow와 공식문서의 side-effects 부분을 보면 #1이 안전하다는 것을 알 수 있다.
side-effects(부작용)에 대한 공식문서 내용은 아래와 같다.
// 불필요한 부작용 사용하는 코드
ArrayList<String> results = new ArrayList<>();
stream.filter(s -> pattern.matcher(s).matches())
.forEach(s -> results.add(s)); // Unnecessary use of side-effects!
// 부작용이 없는 코드
List<String>results = stream.filter(s -> pattern.matcher(s).matches())
.collect(Collectors.toList()); // No side-effects!
위 코드 내용은 문자열 스트림에서 지정된 정규식과 일치하는 문자열을 검색하고 일치 항목을 목록에 넣는 코드이다.
불필요한 코드에 대한 공식문서 내용은 아래와 같다.
이 코드는 불필요하게 부작용을 사용한다. 병렬로 실행하면 ArrayList의 non-thread-safety로 인해 잘못된 결과가 발생할 수 있고, 필요한 동기화(synchronization)를 추가하면 경합이 발생하여 병렬 처리의 이점을 훼손할 수 있다. 또한 여기서 부작용을 사용하는 것은 전혀 불필요하며, forEach()를 더 안전하고 효율적이며 병렬화에 적합한 축소 연산으로 간단히 대체할 수 있다.
따라서 forEach에 로직을 추가하면 동시성 보장이 어렵고 가독성이 떨어지며, 스트림의 의도를 벗어나게 된다.
forEach는 종료를 하기 위한 연산이다.
로직을 수행하는 역할은 중간 연산이 해야하는 일이다.
즉, 최종 연산인 forEach가 중간 연산의 책임을 하게 된다는 의미이다.
4. 너무 길어서 요약하기
List<A> aList = new ArrayList<>();
// #1
aList.stream()
.filter(a -> a.age >= 20)
.forEach(a -> a.age += 1); // side-effects 발생 가능
// #2
List<A> updateAList = aList.stream()
.filter(a -> a.age >= 20)
.collect(toList());
updateAList.forEach(a -> a.age += 1); // side-effects 발생 X
- 디버깅을 위한 peek() 연산에서 객체의 상태를 변경하는 것은 좋지 않음
- Stream의 forEach()는 최종 연산이다
- Stream의 forEach()에 로직을 구현하는 것은 중간 연산의 책임을 하는 것
- 이펙티브 자바 46에 따르면, forEach 연산은 최종 연산 중 기능이 가장 적고 '덜' 스트림하기에 계산 결과를 보고할 때(print 기능)만 사용하고 계산하는 데는 쓰지 말자 라고 함
- forEach를 통해 원본 데이터 소스를 변경하면 side-effects가 발생할 수 있음
- 스트림을 통한 병렬(paraller) 연산은 사용할만한 상황이 매우 적음 (잘못하면 큰일)
- 병렬 연산이 매우 적더라도 side-effects가 있는 로직을 구현한다면 나중에 먼 미래에 큰일날 수도?
5. 나의 생각
적을수록 의문점이 들어 글 한번 쓰기 어려웠다. 대부분 스트림의 병렬 연산을 사용하지 않으므로 큰 에러없이 Stream의 forEach()를 통해 원본 데이터 소스를 변경하거나 여러 로직을 구현했을 것이다. 그게 가장 쉬운 방법이면서 보기도 편하기 때문이다.
만약 전체 유저를 가져오고 그 중 일부만 상태값을 변경하고 전체 유저를 반환하는 코드를 작성해야 한다고 생각해보자.
List<User> users = getUsers();
users.stream()
.filter(u -> u.getAge() >= 20)
.forEach(u -> u.setAge(u.getAge()+1));
return users;
이 코드는 ArrayList일 때 users의 내부 상태를 변경했기에 ConcurrentModificationException이 발생할 수도 있다는 걸 생각하면서 안전하게 데이터 소스를 수정할 수 있는지 고려해야 한다.
이걸 side-effects 없이 만드려면 어떻게 해야할까?
List<User> users = getUsers();
// 1번째 방법
List<User> updateUsers = users.stream()
.map(u -> {
if(u.getAge() >= 20)
return new User(u.getAge()+1);
return u;
})
.collect(toList());
return updateUsers;
// 2번째 방법
List<User> updateUsers = users.stream()
.filter(u -> u.getAge() >= 20)
.collect(toList());
updateUsers.forEach(u -> u.setAge(u.getAge() + 1));
List<User> nonUpdateUsers = users.stream()
.filter(u -> u.getAge() < 20)
.collect(toList());
List<User> newUsers = new ArrayList<>();
newUsers.addAll(updateUsers);
newUsers.addAll(nonUpdateUsers);
return newUsers;
어떤 방법을 사용하든지 상대적으로 가독성이나 코드가 길어지고 불편해지는 점이 있다.
아마 보기 싫을 수도 있다. 나도 1번째나 2번째 코드보다는 side-effects가 있는 코드가 더 보기 좋아보인다.
따라서 다음과 같이 결론을 내릴 수 있다.
- 스트림의 forEach()를 사용할 때 안전한 데이터 소스 수정을 고려해야 함
- side-effects 없이 데이터 소스를 수정하려면 코드가 길어지고 가독성이 떨어짐
- 병렬 연산을 대부분 하지 않으니 side-effects 있는 방식을 사용해도 괜찮아보임
- 혹시 모르니 Stream API를 더 깊이 이해하고 안전한 코드 작성을 위해 노력하는 것이 중요
'프로그래밍 > Java' 카테고리의 다른 글
자바에서 String을 조심해야하는 이유 (0) | 2022.10.22 |
---|---|
JVM(자바가상머신)이란? - Part5, Garbage Collection (1) | 2022.10.15 |
JVM(자바가상머신)이란? - Part4, Runtime Data Area (0) | 2022.09.29 |
[Java] sort와 parallelSort 비교 (0) | 2022.09.18 |
Java - 값이 null,공백 등 Blank인지 확인하기 (0) | 2022.04.07 |