1. List → Set 변환과 역변환
왜 컬렉션 변환이 필요한지부터 살펴봅시다. 실무에서는 종종 다음이 필요합니다:
- 리스트에서 고유 원소만 뽑기(예: 이메일 리스트 → 중복 없는 주소 집합).
- 매핑(Map) 만들기. 예를 들어, 이름 리스트에서 “이름 → 이름 길이” 맵을 만들기.
- 요소를 하나의 문자열로 합치기(예: 보기 좋게 출력).
예전에는 이를 위해 반복문, 조건문, 임시 컬렉션을 많이 써야 했습니다. 이제는 Stream API로 더 간단하고 — 더 우아합니다!
예제: 리스트에서 고유한 이름 Set 얻기
다음과 같은 이름 리스트가 있다고 가정해 봅시다(누군가가 실수로 자기 이름을 두 번 입력했을 수도 있죠!):
List<String> names = List.of("Anna", "Sergey", "Anna", "Mariya", "Ivan", "Sergey");
목표는 각 이름이 한 번만 등장하는 집합(Set)을 얻는 것입니다. Stream API를 사용하면 한 줄로 끝납니다:
Set<String> uniqueNames = names.stream()
.collect(Collectors.toSet());
System.out.println(uniqueNames);
출력:
[Mariya, Ivan, Anna, Sergey]
Set은 순서를 보장하지 않습니다 — 출력 순서는 달라질 수 있습니다.
그 반대로: Set → List?
가끔은 반대로 집합을 리스트로 바꿔야 합니다(예: 정렬하거나 인덱스로 접근하려고):
List<String> namesList = uniqueNames.stream()
.collect(Collectors.toList());
System.out.println(namesList);
2. Map으로 변환: Collectors.toMap()
예제: 이름 리스트에서 “이름 → 이름 길이” Map 만들기
가끔은 진짜 지도 제작자처럼 지도를 만들고 싶어집니다! 해 봅시다:
List<String> names = List.of("Anna", "Sergey", "Mariya", "Ivan");
Map<String, Integer> nameToLength = names.stream()
.collect(Collectors.toMap(
name -> name, // 키 — 이름 자체
name -> name.length() // 값 — 이름 길이
));
System.out.println(nameToLength);
출력:
{Mariya=6, Ivan=4, Anna=4, Sergey=6}
중요 포인트: 키 중복
원본 리스트에 동일한 이름이 있다면, 이를 Map으로 수집하려 할 때 IllegalStateException: Duplicate key 오류가 발생합니다. Java는 같은 키에 두 값을 넣는 것을 허용하지 않습니다.
중복은 어떻게 처리할까?
키가 겹칠 때의 동작을 지정할 수 있습니다 — 예를 들어, 첫 번째 값을 남기거나 마지막 값을 남기기:
List<String> names = List.of("Anna", "Sergey", "Anna", "Mariya", "Ivan", "Sergey");
Map<String, Integer> nameToLength = names.stream()
.collect(Collectors.toMap(
name -> name,
name -> name.length(),
(oldValue, newValue) -> oldValue // 첫 번째 값을 유지
));
System.out.println(nameToLength);
이제 예외가 발생하지 않고, Map에는 각 이름의 첫 번째 항목만 들어갑니다.
예제: 객체를 값으로 갖는 Map
조금 더 복잡하게 해 보죠: 사용자 리스트에서 “이름 → 사용자” Map을 만듭니다:
class User {
String name;
int age;
User(String name, int age) {
this.name = name;
this.age = age;
}
public String toString() {
return name + " (" + age + ")";
}
}
// 사용자 리스트 예시
List<User> users = List.of(
new User("Anna", 25),
new User("Sergey", 30),
new User("Mariya", 22)
);
Map<String, User> nameToUser = users.stream()
.collect(Collectors.toMap(
user -> user.name,
user -> user
));
System.out.println(nameToUser);
출력:
{Mariya=Mariya (22), Anna=Anna (25), Sergey=Sergey (30)}
3. 문자열로 조합하기: Collectors.joining()
컬렉션을 그대로 두지 말고, 사용자 표시나 로그를 위해 보기 좋은 문자열로 만들고 싶을 때가 있습니다. 예를 들어, 모든 이름을 쉼표로 이어 붙이기:
List<String> names = List.of("Anna", "Sergey", "Mariya", "Ivan");
String result = names.stream()
.collect(Collectors.joining(", "));
System.out.println(result);
출력:
Anna, Sergey, Mariya, Ivan
접두사와 접미사를 추가할 수도 있습니다
String result = names.stream()
.collect(Collectors.joining(", ", "목록: [", "]"));
System.out.println(result);
출력:
목록: [Anna, Sergey, Mariya, Ivan]
4. 터미널 연산: forEach, collect, count, anyMatch, allMatch, noneMatch
forEach 메서드
forEach는 잘 알려져 있듯이 스트림의 각 요소에 대해 동작을 수행합니다.
names.stream().forEach(name -> System.out.println("안녕, " + name + "!"));
collect 메서드
요소를 컬렉션, 문자열 또는 다른 구조로 수집합니다. 가장 흔한 사용은 Collectors.toList()와 Collectors.toSet()로 List나 Set을 만드는 것입니다.
count 메서드
스트림의 요소 개수를 셉니다.
long count = names.stream()
.filter(name -> name.length() > 4)
.count();
System.out.println("길이가 4보다 긴 이름 수: " + count);
anyMatch, allMatch, noneMatch 메서드
조건이 하나라도 충족되는지(anyMatch), 모두 충족되는지(allMatch), 전혀 충족되지 않는지(noneMatch)를 검사합니다.
boolean hasShortName = names.stream()
.anyMatch(name -> name.length() < 4);
System.out.println("짧은 이름이 있나요? " + hasShortName);
boolean allLong = names.stream()
.allMatch(name -> name.length() > 3);
System.out.println("모든 이름의 길이가 3보다 긴가요? " + allLong);
boolean noneIvan = names.stream()
.noneMatch(name -> name.equals("Ivan"));
System.out.println("Ivan이 없나요? " + noneIvan);
출력:
짧은 이름이 있나요? false
모든 이름의 길이가 3보다 긴가요? true
Ivan이 없나요? false
5. 터미널 연산과 중간 연산: 개념 정리
중간 연산(filter, map, distinct, sorted, limit, skip, peek) — 새로운 Stream을 반환하며, 체이닝이 가능합니다.
터미널 연산(forEach, collect, count, anyMatch, allMatch, noneMatch, reduce, findFirst, findAny) — 스트림을 종료하며, 그 이후에는 결과가 더 이상 나오지 않습니다!
체이닝 예시:
List<String> result = users.stream()
.filter(user -> user.age > 20)
.map(user -> user.name.toUpperCase())
.distinct()
.sorted()
.collect(Collectors.toList());
System.out.println(result);
출력:
[ANNA, IVAN, MARIYA, SERGEY]
6. Stream으로 컬렉션을 변환할 때 흔한 실수
오류 №1: toMap에서 키 중복을 처리하지 않음
원본 컬렉션에 중복 키가 있는데 Collectors.toMap()을 병합 함수 없이 사용하면 예외가 발생합니다. 이런 경우에는 반드시 병합 함수를 지정하세요:
// 마지막 값을 유지
.toMap(keyMapper, valueMapper, (oldVal, newVal) -> newVal)
오류 №2: forEach를 collect 대신 사용함
초보자는 가끔 forEach로 컬렉션을 “모으려” 합니다. 예:
List<String> list = new ArrayList<>();
names.stream().forEach(name -> list.add(name)); // 동작은 하지만 Stream-way가 아닙니다!
collect(Collectors.toList())를 사용하는 편이 더 안전하고 깔끔합니다.
오류 №3: 스트림을 재사용하려는 시도
스트림은 한 번만 사용할 수 있습니다. 터미널 연산(예: collect, forEach) 후에 같은 Stream으로 계속 작업하려 하면 IllegalStateException이 발생합니다.
오류 №4: ‘부작용 없음’ 원칙을 어김
중간 연산은 “순수”해야 합니다(외부 변수를 변경하지 않기). map이나 filter 내부에서 스트림 바깥의 상태를 변경하지 마세요.
오류 №5: Set과 Map의 순서를 고려하지 않음
요소의 순서가 중요하다면, 그에 맞는 컬렉션을 사용하세요 — 예: LinkedHashSet, TreeMap — 그리고 적절한 컬렉터를 지정하세요.
GO TO FULL VERSION