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())은 데이터를 여러 스레드에서 동시 처리한다

  • 따라서 아래와 같은 문제가 발생!

  1. 동시성 문제

  • 여러 스레드가 동시에 같은 자원을 변경하면서 충돌 발생하는 문제

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());
  1. 실행 순서 불확실성

  • 병렬 스트림은 성능 향상을 위해 스레드들이 나눠서 동시에 처리함

  • 결과 출력 순서가 보장되지 않음


💨 향후 확장 포인트

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