1. 스트림 만들기
Stream API를 사용하려면 우선 컬렉션이나 배열에서 스트림을 얻어야 합니다.
스트림 생성 예시
// 리스트에서
List<String> names = List.of("Anna", "Boris", "Alex", "Alina");
Stream<String> stream = names.stream();
// 배열에서
int[] numbers = {1, 2, 3, 4, 5};
IntStream intStream = Arrays.stream(numbers);
// 개별 값에서
Stream<String> letters = Stream.of("A", "B", "C");
요약:
- list.stream() — 컬렉션용
- Arrays.stream(array) — 배열용
- Stream.of(...) — 개별 값에 대해
우리 애플리케이션 맥락에서의 예
사용자 목록이 있다고 가정해 봅시다:
List<String> users = List.of("Ivan", "Anna", "Petr", "Alexey");
Stream<String> userStream = users.stream();
중간 연산과 종단 연산
중요한 포인트: Stream API의 연산은 두 가지 유형으로 나뉩니다.
- 중간 연산 (예: filter, map, distinct) — 처리 단계를 기술합니다. 새 스트림을 반환하지만 그 자체로는 아무 것도 실행하지 않습니다.
- 종단 연산 (예: collect, forEach, count) — 파이프라인을 실행하고 결과를 산출합니다.
스트림은 “지연(lazy)” 방식으로 동작합니다: 종단 연산을 호출하기 전까지는 어떤 계산도 수행되지 않습니다. 그래서 우리는 종종 collect(...)로 체인을 끝내는데, 이 지점이 스트림이 다시 컬렉션 또는 다른 결과로 바뀌는 시점입니다.
2. filter 연산: 조건으로 요소 걸러내기
filter는 중간 연산으로, 주어진 조건을 만족하는 요소만 통과시킵니다.
시그니처
Stream<T> filter(Predicate<? super T> predicate);
Predicate는 요소를 받아 true(유지) 또는 false(제거)를 반환하는 함수형 인터페이스입니다.
예: 짝수만 남기기
List<Integer> numbers = List.of(1, 2, 3, 4, 5, 6);
List<Integer> evenNumbers = numbers.stream()
.filter(n -> n % 2 == 0)
.collect(Collectors.toList());
System.out.println(evenNumbers); // [2, 4, 6]
무슨 일이 일어나나요?
- n -> n % 2 == 0 — 람다 표현식으로, 숫자가 2로 나누어떨어지는지 확인합니다.
- filter는 짝수만 남깁니다.
예: "A"로 시작하는 이름만 필터링
List<String> names = List.of("Anna", "Boris", "Alex", "Alina", "Ivan");
List<String> aNames = names.stream()
.filter(name -> name.startsWith("A"))
.collect(Collectors.toList());
System.out.println(aNames); // [Anna, Alex, Alina]
중요 포인트: filter는 컬렉션을 변경하지 않습니다 — 필요한 요소만 포함한 새 스트림을 만듭니다.
3. map 연산: 요소를 다른 것으로 변환
map은 변환 연산입니다. 스트림의 각 요소에 함수를 적용하고 새 요소를 반환합니다.
시그니처
<R> Stream<R> map(Function<? super T, ? extends R> mapper)
Function은 요소를 받아 무언가(다른 타입일 수도 있음)를 반환하는 인터페이스입니다.
예: 문자열의 길이 구하기
List<String> names = List.of("Anna", "Boris", "Alex");
List<Integer> nameLengths = names.stream()
.map(name -> name.length())
.collect(Collectors.toList());
System.out.println(nameLengths); // [4, 5, 4]
무슨 일이 일어나나요?
- map이 문자열을 그 길이로 변환합니다 (name -> name.length()).
- 결과로 숫자 스트림이 됩니다.
예: 문자열을 대문자로 변환
List<String> names = List.of("Anna", "Boris", "Alex");
List<String> upperNames = names.stream()
.map(name -> name.toUpperCase())
.collect(Collectors.toList());
System.out.println(upperNames); // [ANNA, BORIS, ALEX]
4. collect 연산: 결과를 다시 컬렉션으로 수집
collect는 종단 연산으로, 스트림을 종료하고 결과를 컬렉션이나 다른 컨테이너로 수집합니다.
시그니처
<R, A> R collect(Collector<? super T, A, R> collector)
시그니처가 어려워 보여도 겁먹지 마세요! 99% 경우에는 Collectors 클래스의 준비된 컬렉터를 사용합니다.
Collectors는 여러 “수집기”를 제공하는 유틸리티 클래스입니다. 결과를 어떤 형태로 모을지: 리스트, 집합, 문자열 등.
예:
- Collectors.toList() — List로
- Collectors.toSet() — Set으로
- Collectors.joining(", ") — 쉼표로 구분된 문자열로
즉, Collectors는 다양한 모양의 상자 세트와 같아서, 스트림의 요소를 그 안에 포장한다고 생각하면 됩니다.
예: 결과를 List로 수집
List<String> filtered = names.stream()
.filter(name -> name.length() > 3)
.collect(Collectors.toList());
예: 결과를 Set으로 수집
Set<String> uniqueNames = names.stream()
.map(String::toLowerCase)
.collect(Collectors.toSet());
예: 문자열을 쉼표로 이어 붙이기
String result = names.stream()
.collect(Collectors.joining(", "));
System.out.println(result); // Anna, Boris, Alex
5. 연산 체이닝: 필터링 + 변환 + 결과 수집
Stream API의 가장 큰 강점은 연산을 연이어 체이닝할 수 있다는 점입니다.
예: "A"로 시작하는 이름들의 길이 구하기
List<String> names = List.of("Anna", "Boris", "Alex", "Alina", "Ivan");
List<Integer> aNameLengths = names.stream()
.filter(name -> name.startsWith("A"))
.map(String::length)
.collect(Collectors.toList());
System.out.println(aNameLengths); // [4, 4, 5]
단계별 설명:
- .stream() — 리스트에서 스트림을 생성합니다.
- .filter(name -> name.startsWith("A")) — "A"로 시작하는 이름만 남깁니다.
- .map(String::length) — 각 이름을 그 길이로 변환합니다.
- .collect(Collectors.toList()) — 결과를 리스트로 수집합니다.
동일한 명령형 코드
같은 작업을 “옛 방식”으로 작성하면 다음과 같습니다:
List<Integer> result = new ArrayList<>();
for (String name : names) {
if (name.startsWith("A")) {
result.add(name.length());
}
}
비교해 보세요: Stream API는 한 줄로, “무엇을 하는지”에 초점을 맞춰 읽히며 “어떻게 하는지”가 아닙니다.
6. 실습: 짧은 과제 몇 가지
연습해 봅시다! 모든 예제는 하나의 파일에서 실행할 수 있습니다 — 데이터만 바꾸면 됩니다.
과제 1: 홀수만 남기고 제곱하기
List<Integer> numbers = List.of(1, 2, 3, 4, 5, 6, 7);
List<Integer> oddSquares = numbers.stream()
.filter(n -> n % 2 != 0)
.map(n -> n * n)
.collect(Collectors.toList());
System.out.println(oddSquares); // [1, 9, 25, 49]
과제 2: 문자열 리스트에서 첫 글자들의 리스트 구하기
List<String> names = List.of("Anna", "Boris", "Alex");
List<Character> initials = names.stream()
.map(name -> name.charAt(0))
.collect(Collectors.toList());
System.out.println(initials); // [A, B, A]
과제 3: 길이가 3보다 큰 문자열만 필터링하여 Set으로 수집
List<String> words = List.of("cat", "dog", "elephant", "ant", "bear");
Set<String> longWords = words.stream()
.filter(word -> word.length() > 3)
.collect(Collectors.toSet());
System.out.println(longWords); // [bear, elephant]
7. filter, map, collect 사용 시 흔한 실수
실수 № 1: collect를 빼먹어서 결과가 없음!
Stream API는 창가의 고양이처럼 게으릅니다: 종단 연산(예: collect 또는 forEach)을 호출하기 전에는 아무 일도 일어나지 않습니다. users.stream().filter(...).map(...); 만 작성하면 아무 동작도 수행되지 않습니다.
실수 № 2: filter와 map의 순서를 바꿈
가끔 초보자는 먼저 map을 하고 그 다음 filter를 합니다. 예를 들어 names.stream().map(String::length).filter(len -> len > 3)는 문자열이 아니라 숫자를 얻게 됩니다. 길이가 3보다 큰 문자열이 필요하다면 먼저 필터링하고 그 다음 변환하세요.
실수 № 3: 불변성을 잊음
Stream API 연산은 원본 컬렉션을 변경하지 않습니다! List<String> upper = names.stream().map(String::toUpperCase).collect(Collectors.toList()); 이후에도 names 컬렉션은 그대로입니다.
실수 № 4: 외부의 변경 가능한 리스트를 사용하려 함
다음과 같이 작성하지 마세요:
List<String> result = new ArrayList<>();
names.stream().filter(...).forEach(name -> result.add(name));
collect를 사용하는 편이 더 안전하고 간결합니다.
실수 № 5: NullPointerException
컬렉션에 null 요소가 있을 수 있다면, null에 대해 name.startsWith("A")를 호출하면 예외가 발생합니다. 가능하다면 null 체크 필터를 추가하세요:
.filter(name -> name != null && name.startsWith("A"))
GO TO FULL VERSION