46. 스트림에서는 부작용 없는 함수를 사용하라
📌 1. 발표 전 알아야 할 개념
스트림(Stream)
47장의 Stream과 의미의 차이를 이해해보자.
자바 8에서 도입된 기능으로, 리스트, 배열과 같은 데이터 컬렉션을 함수형 스타일로 처리하는 방식
반복문 대신 더 간결하게 데이터를 처리할 수 있게 됨
public class StreamTest {
public static void main(String[] args) {
// 짝수를 찾고 제곱하기
List<Integer> numbers = List.of(1, 2, 3, 4, 5);
List<Integer> result = new ArrayList<>();
// 전통적인 방식
for(Integer n : numbers){
if(n % 2==0) {
result.add(n*n);
}
}
System.out.println(result);
// stream 방식
List<Integer> result2 = numbers.stream()
.filter(n -> n%2==0)
.map(n->n*n)
.collect(Collectors.toList());
System.out.println(result2);
}
}
stream()
: 데이터 흐름을 시작filter()
: 조건에 맞는 데이터만 고르기map()
: 데이터를 가공해서 변환collect()
: 결과 수집
📕 2. 부작용
부작용(Side Effect)
함수가 자신의 범위를 벗어나 외부 상태를 변경하는 것
List<Integer> result = new ArrayList<>();
void process(List<Integer> numbers) {
for(Integer n : numbers) {
if(n % 2 == 0) {
result.add(n); // 부작용 : 외부 변수 수정
}
}
}
☝🏼 3. 스트림에서는 부작용 없는 함수를 사용하라라
3-1. 순수함수(Pure Function)
같은 입력에는 항상 같은 출력을 반환
함수 외부의 상태를 변경하지 않는다 -> 부작용이 없다
// 순수함수
int square(int n) {
return n * n;
}
// 비순수 함수
int cnt = 0;
int add(int x) {
cnt ++; // 외부 상태 변경
return x + cnt;
}
3-2. for-each VS Stream
for-each 반복
명령형 프로그래밍 스타일로, 어떻게(How) 처리할지 집중함
외부 변수를 쉽게 수정할 수 있음
코드의 의도를 파악하기 위해 전체 루프를 확인하고 이해해야 함
Stream
선언형 프로그래밍 스타일로, 무엇을(What) 처리할지 집중함
파이프라인으로 데이터가 흐르며 처리됨
각 단계, 줄마다 명확한 목적을 드러냄
3-3. 스트림에서는 부작용 없는 함수를 사용하라
병렬 처리
: 부작용이 없는 코드는 안전하게 병렬로 실행할 수 있다코드의 의미가 명확해지고(가독성) 부작용이 없어 디버깅이 쉬워진다(유지보수)
❌ 잘못된 예시 - 스트림 안에서 외부 리스트 변경
외부 리스트 변경
병렬 스트림에서 예측 불가능한 결과 발생 가능능
List<String> words = List.of("apple", "banana", "avocado");
List<String> result = new ArrayList<>();
// a로 시작하는 단어를 찾고 result에 결과 넣기
words.stream()
.filter(word -> word.startsWith("a"))
.forEach(word -> result.add(word)); // 스트림에서 외부 상태 변경 -> 부작용
🙆🏼♂️ 올바른 예 - Collect(수집기)를 이용하여 내부에서 결과 수집
collect()
는 내부에서 안전하게 데이터를 모은다외부 상태에 영향을 주지 않음 -> 병렬 처리도 안전
List<String> words = List.of("apple", "banana", "avocado");
List<String> result = new ArrayList<>();
List<String> result = words.stream()
.filter(word -> word.startsWith("a"))
.collect(Collectors.toList()); // 부작용이 없는 안전한 방식
🤨 4. 병렬 스트림에서 부작용이 왜 위험할까?
병렬 스트림(parallelStream())은 데이터를 여러 스레드에서 동시 처리한다
따라서 아래와 같은 문제가 발생!
동시성 문제
여러 스레드가 동시에 같은 자원을 변경하면서 충돌 발생하는 문제
List<Integer> numbers = IntStream.rangeClosed(1, 1000).boxed().collect(Collectors.toList());
// 1부터 1000까지 숫자 생성, 리스트로 수집 | numbrs = [1, 2, 3, 4, ..., 1000]
List<Integer> result = new ArrayList<>(); // 여러 스레드가 동시에 접근할 수 있음음
numbers.parallelStream() // 병렬 처리(여러 스레드 동시에 작업업)
.forEach(n -> {
if (n % 100 == 0) result.add(n); // for-each 안에서 외부 리스트 수정정
});
System.out.println(result.size()); // 기대: 10... 이 정상이나 매번 다름
ArrayList는 스레드 안전하지 않고, 여러 스레드가 동시에 호출하면 내부 구조가 꼬임 > 동시성 문제!
🙆🏼♂️ 순수 함수와 collect를 사용하라
List<Integer> result2 = numbers.parallelStream()
.filter(n -> n % 100 == 0)
.collect(Collectors.toList());
실행 순서 불확실성
병렬 스트림은 성능 향상을 위해 스레드들이 나눠서 동시에 처리함
결과 출력 순서가 보장되지 않음
💨 향후 확장 포인트
Spring에서 Stream 사용 시 주의점
Stream을 사용하여 JPA 엔티티를 DTO로 깔끔하게 변환할 수 있다
// JPA 엔티티를 DTO로 변환하는 예
List<UserDTO> userDtos = userRepository.findAll().stream()
.map(user -> new UserDTO(user.getId(), user.getName(), user.getEmail()))
.collect(Collectors.toList());
Stream 처리 중 DB 트랜잭션이 끝나지 않도록 주의하자 (LazyLoding을 사용하는 경우 스트림 처리 중 세션이 닫힐 수도 있음)
Stream 처리 결과를 캐싱할 때는 불변성을 유지하는 것이 중요하다
🤖 최종 결론
스트림 내에서 부작용이 있는 함수를 사용하면 코드가 불분명해지고 병렬 처리 시 문제가 발생할 수 있으니 주의하자. for-each로 외부 상태를 수정하지 말고, collect, reduce와 같은 연산으로 결과를 모으자.
😶🌫️ 느낀점
어려웠지만 익숙해져야 하는 stream
Last updated