1. 메서드 distinct: 중복 제거
실무에서는 데이터를 필터링하고 변환하는 것뿐 아니라, 컬렉션에서 유일한 요소만 선택하거나 결과의 크기를 제한하거나, 반대로 앞의 몇 개 요소를 건너뛰어야 할 때가 자주 있습니다. 예를 들어:
- 사용자의 고유한 이름 목록을 얻기.
- 페이지에 표시할 처음 10개의 레코드만 가져오기.
- 앞의 5개 요소를 건너뛰기(예: 페이지 네비게이션 구현에서 “2페이지부터 보기”).
이런 작업을 위해 Stream API에는 distinct, limit, skip 같은 전용 메서드가 있습니다.
distinct는 어떻게 동작할까요?
메서드 distinct()는 모든 중복 요소가 제거된 새 스트림을 반환합니다. 중복 여부는 해당 클래스의 equals와 hashCode 메서드로 결정됩니다.
당신이 수집가라고 가정해 봅시다. 주요 컬렉션에는 중복이 없어야 하고, 중복된 동전은 교환용으로 따로 빼두려 합니다. 바로 distinct()가 메인 컬렉션에서 중복을 걸러낼 수 있습니다.
예: 사용자 이름의 유일값
List<String> names = List.of(
"앨리스", "밥", "앨리스", "에바", "밥", "데니스", "글레브", "에바"
);
고유한 이름 목록을 얻어봅시다:
List<String> uniqueNames = names.stream()
.distinct()
.collect(Collectors.toList());
System.out.println(uniqueNames);
// 출력: [앨리스, 밥, 에바, 데니스, 글레브]
예: 사용자의 고유한 이메일
다음과 같은 User 클래스가 있다고 합시다:
public class User {
String name;
String email;
// 생성자, 게터, toString() — 편의를 위해
public User(String name, String email) {
this.name = name;
this.email = email;
}
@Override
public String toString() {
return name + " <" + email + ">";
}
}
이메일이 중복된 사용자 목록:
List<User> users = List.of(
new User("앨리스", "alice@mail.com"),
new User("밥", "bob@mail.com"),
new User("에바", "eva@mail.com"),
new User("앨리스2", "alice@mail.com"), // 이메일 중복!
new User("글레브", "gleb@mail.com"),
new User("에바2", "eva@mail.com") // 이메일 중복!
);
단순히 users.stream().distinct()를 호출하면 중복이 제거되지 않습니다. 기본적으로 User 객체는 equals와 hashCode가 재정의되어 있지 않기 때문이죠. 이 경우 distinct는 동일한 참조만 중복으로 간주합니다.
해결: 고유성을 email 기준으로 판단하도록 equals와 hashCode를 재정의합니다.
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
User user = (User) o;
return Objects.equals(email, user.email);
}
@Override
public int hashCode() {
return Objects.hash(email);
}
이제 이렇게 됩니다:
List<User> uniqueUsers = users.stream()
.distinct()
.collect(Collectors.toList());
uniqueUsers.forEach(System.out::println);
// 앨리스 <alice@mail.com>
// 밥 <bob@mail.com>
// 에바 <eva@mail.com>
// 글레브 <gleb@mail.com>
중요: 자신의 클래스에서 distinct가 제대로 동작하길 원한다면 equals와 hashCode를 항상 재정의하세요!
2. 메서드 limit: 요소 수 제한
메서드 limit(long maxSize)는 원본 스트림의 앞에서부터 최대 maxSize개 요소만 포함하는 새 스트림을 반환합니다.
비유: 당신은 케이크 가게에 가서 100개 중 딱 3개만 맛보고 싶습니다. limit(3) — 그러면 정확히 처음 세 개만 주고, 나머지는 “오늘은 여기까지”가 됩니다.
예: 처음 3개의 이름
List<String> firstThree = names.stream()
.limit(3)
.collect(Collectors.toList());
System.out.println(firstThree);
// 출력: [앨리스, 밥, 앨리스]
우리 애플리케이션 예: 신규 사용자 Top‑3
List<User> firstUsers = users.stream()
.limit(3)
.collect(Collectors.toList());
firstUsers.forEach(System.out::println);
// 앨리스 <alice@mail.com>
// 밥 <bob@mail.com>
// 에바 <eva@mail.com>
정렬과 함께 사용
정렬과 조합할 수 있습니다 — 예를 들어, 이메일 길이가 가장 짧은 사용자 Top‑2를 뽑아봅시다:
List<User> top2ShortEmail = users.stream()
.sorted(Comparator.comparingInt(u -> u.email.length()))
.limit(2)
.collect(Collectors.toList());
top2ShortEmail.forEach(System.out::println);
// 밥 <bob@mail.com>
// 에바 <eva@mail.com>
3. 메서드 skip: 앞 요소 건너뛰기
메서드 skip(long n)은 원본 스트림의 처음 n개 요소를 건너뛴 새 스트림을 반환합니다.
예: 처음 2개의 이름 건너뛰기
List<String> afterTwo = names.stream()
.skip(2)
.collect(Collectors.toList());
System.out.println(afterTwo);
// 출력: [앨리스, 에바, 밥, 데니스, 글레브, 에바]
예: 페이지네이션(pagination)
웹에서 흔히 “페이지당 사용자 3명”을 보여줍니다. 두 번째 페이지를 얻으려면 앞의 3개를 건너뛰고, 다음 3개를 가져오면 됩니다:
int pageSize = 3;
int pageNumber = 2; // 두 번째 페이지
List<User> page = users.stream()
.skip(pageSize * (pageNumber - 1))
.limit(pageSize)
.collect(Collectors.toList());
page.forEach(System.out::println);
// 앨리스2 <alice@mail.com>
// 글레브 <gleb@mail.com>
// 에바2 <eva@mail.com>
4. distinct, limit, skip 조합
이 메서드들은 과제에 따라 적절히 조합해 사용할 수 있습니다.
예: 세 번째부터 시작해 고유한 이름 2개 얻기
List<String> result = names.stream()
.distinct() // 중복 제거: [앨리스, 밥, 에바, 데니스, 글레브]
.skip(2) // 앨리스와 밥을 건너뜁니다: [에바, 데니스, 글레브]
.limit(2) // 두 개만 가져옵니다: [에바, 데니스]
.collect(Collectors.toList());
System.out.println(result);
// 출력: [에바, 데니스]
예: 필터링과 함께
예를 들어, 문자 ‘a’를 포함하는 고유한 이메일 2개를 가져온다고 합시다:
List<String> emails = users.stream()
.map(user -> user.email)
.filter(email -> email.contains("a"))
.distinct()
.limit(2)
.collect(Collectors.toList());
System.out.println(emails);
// 출력: [alice@mail.com, eva@mail.com]
5. 실습: 적용 과제
과제 1. 길이가 3자를 초과하는 사용자 이름의 고유 목록 구하기
List<String> longUniqueNames = names.stream()
.filter(name -> name.length() > 3)
.distinct()
.collect(Collectors.toList());
System.out.println(longUniqueNames);
// 예: [앨리스, 데니스]
과제 2. 사용자 목록에서 3번째와 4번째 고유 이메일 얻기
List<String> thirdAndFourthEmail = users.stream()
.map(user -> user.email)
.distinct()
.skip(2)
.limit(2)
.collect(Collectors.toList());
System.out.println(thirdAndFourthEmail);
// 예: [eva@mail.com, gleb@mail.com]
과제 3. 10보다 큰 고유한 숫자 최초 5개 얻기
List<Integer> numbers = List.of(5, 12, 17, 5, 23, 17, 42, 19, 12, 8);
List<Integer> result = numbers.stream()
.filter(n -> n > 10)
.distinct()
.limit(5)
.collect(Collectors.toList());
System.out.println(result);
// 출력: [12, 17, 23, 42, 19]
6. 시각화: 연산 적용 순서
graph TD
A[원본 목록] --> B[filter]
B --> C[distinct]
C --> D[skip]
D --> E[limit]
E --> F[collect]
설명:
일반적으로 먼저 filter를 적용하고, distinct로 중복을 제거한 뒤, skip과 limit을 사용하며, 마지막에 collect를 호출합니다. 하지만 문제에 따라 순서를 바꿀 수도 있습니다.
7. distinct, limit, skip 사용 시 흔한 실수
오류 №1: distinct가 “아무” 기준으로든 중복을 제거해 줄 것이라고 기대함.
실제로 distinct는 객체의 equals 메서드를 기준으로 동작합니다. 어떤 특정 필드(예: email만)를 기준으로 고유성을 원한다면 전체 객체 기준이 아닌 것이므로 equals/hashCode를 재정의하거나, Collectors.toMap() 또는 추가 필터링 같은 기법을 사용해야 합니다.
오류 №2: 연산 순서를 잘못 적용함.
먼저 limit를 적용하고 그 다음에 distinct를 적용하면, 처음 N개에 중복이 포함되어 있을 수 있으므로 중복이 남을 수 있습니다.
오류 №3: skip 개수가 요소 수보다 큼.
스트림에 들어 있는 요소 수보다 더 많이 건너뛰면 결과는 단순히 빈 리스트가 됩니다. 오류는 나지 않지만 결과가 당황스러울 수 있습니다.
오류 №4: 성능을 간과함.
distinct, limit, skip는 매우 큰 스트림에서 비효율적일 수 있습니다. 특히 스트림이 비정렬 상태이거나 요소가 복잡한 경우 그렇습니다. 일상적인 99%의 과제에서는 문제가 되지 않지만, 수백만 건을 다룬다면 한 번쯤 고려해야 합니다.
오류 №5: 사용자 정의 클래스에서 equals/hashCode 재정의를 잊음.
사용자 정의 객체(예: User)를 다룰 때 이 메서드들을 재정의하지 않으면, distinct는 논리적으로 동일하더라도 서로 다른 객체로 간주합니다.
GO TO FULL VERSION