JAVA

자바 스트림 중개 연산 — 정렬하고 중복 없애고 잘라 내기(SORTED·DISTINCT·LIMIT)

PANCO_IT
#자바#JAVA#스트림#flatMap#distinct#sorted

map은 봉지째 담고 flatMap은 봉지를 뜯어 알맹이만 담는다는 비교 그림

둘 다 변환이지만, 하나는 개수를 유지하고 하나는 개수를 늘립니다.

3편에서 스트림은 거르고, 바꾸고, 담는 세 동작이라고 했습니다. 그런데 실제로 코드를 짜다 보면 그 사이에 하고 싶은 일이 더 생깁니다. 순서대로 줄 세우고 싶고, 중복은 지우고 싶고, 위에서 몇 개만 뽑고 싶죠.

오늘은 filtermap 옆에 나란히 붙일 수 있는 나머지 도구들을 봅니다. 다섯 개인데, 각각은 어렵지 않고 붙이는 순서에서 사고가 납니다.

중간 연산은 계속 이어 붙일 수 있습니다

먼저 위치를 잡고 가겠습니다. 3편에서 본 세 토막 중 가운데가 오늘의 무대입니다.

nums.stream()                    // 열기
    .filter(...)                 // 중간 연산
    .map(...)                    // 중간 연산
    .sorted()                    // 중간 연산 — 오늘 배울 것
    .limit(3)                    // 중간 연산 — 오늘 배울 것
    .collect(Collectors.toList()); // 닫기

중간 연산은 몇 개든, 어떤 순서로든 붙일 수 있습니다. 자유롭다는 뜻이기도 하지만, 잘못 붙여도 컴파일러가 아무 말을 안 해 준다는 뜻이기도 합니다. 오늘의 함정이 전부 여기서 나옵니다.

flatMap — 봉지를 뜯어서 담습니다

map과 이름이 비슷해 가장 많이 헷갈리는 것부터 보겠습니다.

문장 세 개에서 모든 단어를 뽑아내고 싶다고 해 보죠. 문장을 쪼개는 것은 split으로 됩니다. 그래서 map을 씁니다.

List<String> sentences = List.of("Hello World", "Java is fun");

sentences.stream()
    .map(s -> s.split(" "))   // 결과: List<String[]>

원하는 게 나왔을까요. 아닙니다. split배열 하나를 돌려주니, 문장 두 개를 넣으면 배열 두 개가 나옵니다. 단어가 풀려 나온 게 아니라 봉지 두 개가 그대로 담긴 것이죠.

쉽게 — 장을 봐 왔다고 해 봅시다. 사과 봉지와 귤 봉지를 바구니에 그냥 넣으면 바구니에는 봉지 두 개가 들어갑니다. 봉지를 뜯어 과일만 쏟아 넣어야 과일 일곱 개가 되죠. map이 봉지째 담는 것이고, flatMap이 뜯어서 담는 것입니다.

sentences.stream()
    .flatMap(s -> Arrays.stream(s.split(" ")))   // 결과: List<String>
    .collect(Collectors.toList());
// [Hello, World, Java, is, fun]

flatMap에 넘기는 람다는 값 하나가 아니라 스트림 하나를 돌려줘야 합니다. 그러면 자바가 그 작은 스트림들을 이어 붙여 하나로 만들어 줍니다. 그래서 map은 개수가 그대로지만 flatMap개수가 늘어날 수 있습니다.

중첩 리스트를 펴는 데도 같은 원리가 쓰입니다.

List<List<Integer>> nested = List.of(List.of(1, 2), List.of(3, 4, 5));

nested.stream()
    .flatMap(List::stream)     // [1, 2, 3, 4, 5]

distinct — 중복을 지웁니다. 다만 조건이 있습니다

distinct()는 이름 그대로 중복을 제거합니다. 괄호 안에 아무것도 넣지 않고, 앞쪽 순서를 유지한 채 뒤에 나온 중복만 버립니다.

List.of(1, 2, 2, 3, 3, 3).stream()
    .distinct()                 // [1, 2, 3]

숫자와 문자열은 이렇게 잘 됩니다. 문제는 직접 만든 클래스입니다. 여기서 초보자 대부분이 한 번은 막힙니다.

class City {
    String name;
    City(String name) { this.name = name; }
}

List.of(new City("서울"), new City("서울")).stream()
    .distinct()                 // 중복이 안 지워집니다

이름이 똑같은데 왜 안 지워질까요. distinct는 두 값이 같은지를 equals()로 판단하는데, 우리가 만든 클래스는 equals를 정의하지 않았으니 자바의 기본 동작이 쓰입니다. 그 기본 동작은 "메모리상 같은 객체인가" 를 보는 것입니다. new를 두 번 했으니 둘은 다른 객체이고, 그래서 이름이 같아도 다르다고 판단합니다.

해결은 equalshashCode를 함께 정의하는 것입니다. 둘을 함께 만들어야 하는 이유는 distinct가 내부적으로 해시를 먼저 보고 걸러 내기 때문입니다. 하나만 만들면 의도대로 동작하지 않습니다.

sorted — 줄을 세웁니다

sorted()는 괄호를 비워 두면 기본 순서로 정렬합니다. 숫자는 작은 것부터, 문자열은 사전 순입니다.

List.of(5, 2, 8, 1).stream().sorted()   // [1, 2, 5, 8]

기준을 바꾸고 싶으면 괄호 안에 비교 방법을 넣습니다. 1편에서 list.sort((a, b) -> b - a)로 내림차순을 만들었던 그 방식이 그대로 쓰입니다.

.sorted((a, b) -> b - a)              // 내림차순
.sorted(Comparator.reverseOrder())    // 같은 뜻, 더 읽기 쉬움

객체를 특정 필드로 정렬할 때는 Comparator.comparing이 편합니다. 3편에서 배운 메서드 참조가 여기서 쓰입니다.

products.stream()
    .sorted(Comparator.comparing(Product::getPrice))   // 가격 낮은 순

한 가지 주의할 점이 있습니다. sorted모든 원소를 다 모아야 순서를 정할 수 있습니다. 그래서 앞에서 filter로 미리 줄여 놓고 정렬하는 편이 낫습니다. 정렬한 뒤에 거르면 버릴 것까지 줄 세운 셈이 되니까요.

limit과 skip — 앞에서 자르고 앞을 건너뜁니다

둘은 짝입니다. limit(n)은 앞에서 n개만 통과시키고, skip(n)은 앞에서 n개를 버립니다.

List.of(1, 2, 3, 4, 5).stream().limit(3)   // [1, 2, 3]
List.of(1, 2, 3, 4, 5).stream().skip(2)    // [3, 4, 5]

둘을 함께 쓰면 페이지 나누기가 됩니다. 게시판에서 한 페이지에 3개씩 보여 준다면 이렇게 됩니다.

페이지코드결과
1페이지.skip(0).limit(3)1, 2, 3
2페이지.skip(3).limit(3)4, 5, 6
3페이지.skip(6).limit(3)7, 8, 9

건너뛸 개수가 (페이지 번호 - 1) × 페이지 크기라는 규칙이 보이실 겁니다. 실무에서 목록을 나눠 보여 줄 때 그대로 쓰는 계산입니다.

단골 함정 — 순서를 바꾸면 답이 바뀝니다

오늘 가장 조심할 부분입니다. 중간 연산은 위에서 아래로 차례대로 실행됩니다. 그래서 같은 연산이라도 앞뒤가 바뀌면 결과가 달라집니다.

sorted를 먼저 붙일 때와 limit을 먼저 붙일 때 결과가 달라지는 비교

그림 1. 상위 3개를 원했다면 정렬이 먼저 와야 합니다.

점수 목록에서 상위 3명을 뽑는다고 해 보겠습니다.

scores.stream()
    .sorted(Comparator.reverseOrder())   // 먼저 줄 세우고
    .limit(3)                            // 위에서 3개

이게 맞습니다. 그런데 순서를 바꾸면 이렇게 됩니다.

scores.stream()
    .limit(3)                            // 앞에서 3개 자르고
    .sorted(Comparator.reverseOrder())   // 그 셋만 줄 세움

이건 상위 3명이 아니라 원래 목록의 앞 세 명을 정렬한 것입니다. 오류도 나지 않고 결과도 그럴듯하게 세 개가 나오니, 데이터를 눈으로 확인하기 전까지는 틀린 줄도 모릅니다. 컴파일러가 잡아 주지 않는 종류의 실수라 특히 조심해야 합니다.

distinctfilter 사이에서도 같은 일이 생깁니다. 중복을 지우고 세는 것과, 걸러 낸 뒤 중복을 지우는 것은 상황에 따라 결과가 다릅니다. "무엇을 먼저 해야 내가 원하는 답이 나오나" 를 한 번 소리 내어 말해 보면 대개 순서가 잡힙니다.

직접 실행해 보기

지금까지 본 것을 한 번에 확인합니다. 특히 마지막 두 블록에서 순서만 바꿨을 때 답이 어떻게 달라지는지 직접 보시기 바랍니다.

중간 연산 다섯 가지와 순서 함정
import java.util.Arrays;
import java.util.Comparator;
import java.util.List;
import java.util.Objects;
import java.util.stream.Collectors;

class City {
  String name;
  City(String name) { this.name = name; }

  @Override
  public boolean equals(Object o) {
      if (this == o) return true;
      if (!(o instanceof City)) return false;
      return Objects.equals(name, ((City) o).name);
  }
  @Override
  public int hashCode() { return Objects.hash(name); }
  @Override
  public String toString() { return name; }
}

public class Main {
  public static void main(String[] args) {
      // 1) flatMap — 문장에서 단어 뽑기
      List<String> sentences = List.of("Hello World", "Java is fun");
      System.out.println("map     : " + sentences.stream()
              .map(s -> s.split(" ").length + "개짜리 배열")
              .collect(Collectors.toList()));
      System.out.println("flatMap : " + sentences.stream()
              .flatMap(s -> Arrays.stream(s.split(" ")))
              .collect(Collectors.toList()));

      // 2) distinct — equals/hashCode 를 만들어 뒀으므로 동작합니다
      List<City> cities = List.of(new City("서울"), new City("대구"), new City("서울"));
      System.out.println("distinct: " + cities.stream()
              .distinct().collect(Collectors.toList()));

      // 3) skip + limit — 페이지 나누기
      List<Integer> all = List.of(1, 2, 3, 4, 5, 6, 7, 8, 9, 10);
      for (int page = 1; page <= 3; page++) {
          System.out.println(page + "페이지: " + all.stream()
                  .skip((long) (page - 1) * 3)
                  .limit(3)
                  .collect(Collectors.toList()));
      }

      // 4) 순서 함정 — 상위 3개를 원한다면?
      List<Integer> scores = List.of(72, 95, 60, 88, 100, 51);
      System.out.println("원본        : " + scores);
      System.out.println("sorted→limit: " + scores.stream()
              .sorted(Comparator.reverseOrder()).limit(3)
              .collect(Collectors.toList()));
      System.out.println("limit→sorted: " + scores.stream()
              .limit(3).sorted(Comparator.reverseOrder())
              .collect(Collectors.toList()));
  }
}

📱 코드 실행(실습)은 태블릿·PC에서 이용할 수 있어요. 모바일에서는 위 코드를 눈으로 따라가며 확인하세요.

sorted→limit은 100, 95, 88이 나오고 limit→sorted는 95, 72, 60이 나옵니다. 둘 다 세 개짜리 목록이라 언뜻 비슷해 보이지만, 뒤엣것은 최고점 100이 아예 빠져 있습니다.

한 장 정리

연산하는 일기억할 점
flatMap각 원소를 여러 개로 풀어 이어 붙임람다가 스트림을 돌려줘야 함. 개수가 늘어남
distinct중복 제거내 클래스는 equals·hashCode함께 정의
sorted정렬비워 두면 기본 순서, 괄호 안에 기준을 넣을 수 있음
limit(n)앞에서 n개만 통과정렬이 필요하면 sorted먼저
skip(n)앞에서 n개 버림skip + limit = 페이지 나누기

이것만 기억하면 됩니다. map은 봉지째, flatMap은 봉지를 뜯어 담습니다. distinct내가 만든 클래스에서는 equals를 만들어 줘야 비로소 동작합니다. 그리고 중간 연산은 적은 순서대로 실행되니, 상위 N개를 원한다면 sortedlimit보다 위에 있어야 합니다.

미니 퀴즈

순서 함정과 distinct 조건에서 많이 틀립니다. 정답은 눌러야 나옵니다.

미니 퀴즈

OX 3문제 + 4지선다 3문제


포켓코딩(JAVA4) 4편 · 예제 출처: 강사 개인 GitHub 저장소. 다음 편: 스트림 최종 연산 — 합치고, 세고, 찾기.

PANCO_IT

IT 지식을 정리하는 개발자

관련 글