자바의 Stream은 컬렉션을 다루는 또 다른 문법이라기보다, 데이터를 처리하는 흐름을 표현하는 방식에 가깝다. 리스트 안의 값을 하나씩 꺼내서 조건을 걸고, 변환하고, 다시 모으는 과정을 반복문 대신 파이프라인처럼 적을 수 있게 해준다.

중요한 점은 스트림이 데이터를 직접 들고 있는 저장소가 아니라는 것이다. 데이터는 여전히 List, Set, 배열 같은 곳에 있고, 스트림은 그 데이터에 어떤 처리를 할지 정의한다. 그래서 스트림 연산을 한다고 해서 원본 컬렉션이 자동으로 바뀌지는 않는다.

파이프라인으로 읽기

List<Integer> numbers = Arrays.asList(1, 2, 3, 4);
 
List<Integer> result = numbers.stream()
    .filter(n -> n % 2 == 0)
    .map(n -> n * 2)
    .collect(Collectors.toList());

위 코드는 짝수만 남긴 뒤 두 배로 바꾸고, 마지막에 다시 리스트로 모은다. 반복문으로도 같은 일을 할 수 있지만, 스트림을 쓰면 “무엇을 할지”가 순서대로 드러난다. filter는 거르고, map은 변환하고, collect는 결과를 모은다.

스트림 파이프라인은 보통 세 부분으로 나뉜다. 먼저 컬렉션에서 stream()을 호출해 스트림을 만들고, 그다음 filter, map 같은 중간 연산을 붙인다. 마지막으로 collect, forEach, reduce 같은 터미널 연산을 호출해야 실제 계산이 실행된다.

지연 평가와 터미널 연산

이 지점이 처음에는 조금 헷갈린다. 스트림의 중간 연산은 지연 평가된다. 아래 코드는 filter 안에 출력문이 있어도 아무것도 출력하지 않는다. 터미널 연산이 없기 때문이다.

Stream.of("Cathy", "Alba", "Beth")
    .filter(name -> {
        System.out.println("filter " + name);
        return true;
    });

실제로 실행하려면 끝에 collectforEach 같은 연산이 필요하다.

Stream.of("Cathy", "Alba", "Beth")
    .filter(name -> {
        System.out.println("filter " + name);
        return true;
    })
    .collect(Collectors.toList());

외부 상태를 바꾸지 않기

스트림을 쓸 때 조심해야 할 부분은 외부 상태를 직접 바꾸는 코드다. 특히 병렬 스트림에서는 여러 요소가 동시에 처리될 수 있기 때문에, 공유 리스트에 직접 add하는 방식은 안전하지 않다.

List<String> names = new ArrayList<>();
 
people.stream()
    .filter(person -> person.getGender() == Gender.FEMALE)
    .map(Person::getName)
    .forEach(name -> names.add(name));

이런 코드는 동작은 해도 스트림의 장점을 잘 살리지 못한다. 결과를 모아야 한다면 외부 리스트를 수정하기보다 collect를 사용하는 편이 자연스럽다.

List<String> names = people.stream()
    .filter(person -> person.getGender() == Gender.FEMALE)
    .map(Person::getName)
    .map(String::toUpperCase)
    .collect(Collectors.toList());

성능 관점에서는 연산 순서도 영향을 준다. 예를 들어 전체 요소를 먼저 변환한 뒤 거르는 것보다, 가능한 한 먼저 filter로 대상을 줄이고 나서 map을 적용하는 편이 불필요한 계산을 줄일 수 있다. 물론 모든 경우에 극적으로 차이가 나는 것은 아니지만, 스트림을 읽을 때는 데이터가 어떤 순서로 흘러가는지 생각하는 습관이 필요하다.

정리하면 Stream은 반복문을 없애기 위한 문법이 아니라, 데이터 처리 과정을 파이프라인으로 표현하기 위한 도구다. 중간 연산은 실행 계획을 쌓고, 터미널 연산이 호출되는 순간 실제로 흘러간다. 그래서 스트림 코드를 볼 때는 “어디서 시작해서, 어떤 변환을 거쳐, 어디에서 끝나는가”를 보면 된다.