CodeGym /행동 /JAVA 25 SELF /Stream으로 컬렉션 변환

Stream으로 컬렉션 변환

JAVA 25 SELF
레벨 30, 레슨 4
사용 가능

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 — 그리고 적절한 컬렉터를 지정하세요.

1
과제
JAVA 25 SELF, 레벨 30, 레슨 4
잠금
컨퍼런스 고유 참가자 수 계산 🧑‍🤝‍🧑
컨퍼런스 고유 참가자 수 계산 🧑‍🤝‍🧑
1
과제
JAVA 25 SELF, 레벨 30, 레슨 4
잠금
첫 번째 기록 우선 사용자 프로필 목록 생성 👤
첫 번째 기록 우선 사용자 프로필 목록 생성 👤
1
설문조사/퀴즈
Stream API 기초, 레벨 30, 레슨 4
사용 불가능
Stream API 기초
Stream API 기초
코멘트
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION