[내일배움캠프 Spring_3기] 달리기반 3회차 - 스트림 API

jiiim_ni·2026년 1월 19일

오늘은 스트림에 대해서 배웠다
스트림에 대해 정리하기 전 제네릭에 대해 다시 한 번 더 정리를 해보았다.


제네릭

타입을 변수처럼!(Type Safety) 제네릭은 상자를 정의할 때 타입을 확정 짓지 않고, 상자를 실제 사용할 때(생성할 때) 알려주는 방식

  • < T > 는 Type의 약자로, 나중에 정해줄게! 라는 뜻의 기호

제네릭을 사용하면
1. 타입 안정성: 엉뚱한 타입이 들어오는 걸 컴파일 단계에서 차단함(실행 중 터질 일이 없음)
2. 코드 결벽증 해소: 지저분한 형변환(Casting)코드가 사라져서 코드가 아주 깔끔해짐

제네릭 메서드

public < T> 반환타입 메서드명(T 파라미터) 핵심은 반환 타입 바로 앞에 < T> 를 써주는 것.
이 메서드 안에서 나 T라는 타입을 마법처럼 쓸거야 라고 선언하는 것

제네릭 클래스랑 따로 쓰는 이유
1. 유연함 : 클래스가 제네릭이 아니어도 메서드만 제네릭으로 쓸 수 있음
2. 정적(static) 메서드: static 메서드는 클래스의 < T>를 쓸 수 없음(객체 생성 전이기 떄문) 그래서 static 메서드에서 제네릭을 쓰려면 반드시 메서드 제네릭 형식을 써야함

제한된 타입 파라미터

< T >라고 하니까 너무 아무나 다 들어옴 상자에 String이나 Integer 같은 상관없는 데이터가 들어오는 걱정을 해결해주는 것이 바로 extends 키워드를 이용한 제한된 타입 파라미터(Bounded Type Parameter)
-> 특정 클래스의 자식들만 들어오도록 입구 컷을 시전하는 것

왜 제한이 필요한가?
1. 최소한의 안전장치: 적어도 관련 객체여야함
2. 부모의 능력 사용: 타입에 제한을 걸면, 메서드 내부에서 부모 클래스가 가진 메서드를 마음껏 쓸 수 있음

제네릭 핵심 요약

  1. 클래스 제네릭: 클래스 옆에 < T>를 붙여 상자 자체의 타입을 결정함(GenericBox< T >)
  2. 메서드 제네릭: 메서드 반환 타입 앞에 < T>를 붙여 호출 시점에 타입을 결정함(< T > T method())
  3. 제한된 타입(extends): <T extends 부모> 를 통해 특정 혈통만 입장을 허용하는 보안 장치를 건다.
  4. 컴파일 타임 체크: 제네릭은 실행 시점이 아니라 코드를 짤 때 에러를 잡아주는 최고의 안전 장치

Step1: 스트림의 등장

List<Idol> idols = List.of(
            new Idol("장원영", "IVE", 100),
            new Idol("안유진", "IVE", 95),
            new Idol("카즈하", "LE SSERAFIM", 90),
            new Idol("김채원", "LE SSERAFIM", 92),
            new Idol("닝닝", "AESPA", 88)
        );

우리의 미션은 아이브 멤버만 뽑아서 점수순으로 정렬한 뒤, 이름만 가져오기!

// 1. 임시 저장소 만들기 (귀찮음)
List<Idol> iveMembers = new ArrayList<>();

// 2. 반복문 + 조건문 지옥 (어떻게 할 것인가?)
for (Idol idol : idols) {
    if ("IVE".equals(idol.getTeam())) {
        iveMembers.add(idol);
    }
}

// 3. 정렬하기 (코드가 너무 길어져서 생략...)
// Collections.sort(iveMembers, ...);

// 4. 또 반복문 돌려서 이름 뽑기
List<String> resultNames = new ArrayList<>();
for (Idol idol : iveMembers) {
    resultNames.add(idol.getName());
}

-> 과거에는 이런 식으로 짰음
우리가 진짜 원하는 건 아이브 멤버들의 이름인데 중간에 iveMembers, resultNames 같은 임시 변수를 계속 만들어야함.

스트림의 구원

java
List<String> result = idols.stream()
    .filter(idol -> "IVE".equals(idol.getTeam()))       // 1. IVE만 남겨라 (Filtering)
    .sorted(Comparator.comparing(Idol::getScore))       // 2. 점수대로 줄 세워라 (Sorting)
    .map(Idol::getName)                                 // 3. 이름표만 떼라 (Mapping)
    .collect(Collectors.toList());                      // 4. 바구니에 담아라 (Collecting)

스트림은 마치 공장의 조립 라인(Pipeline)과 같음
데이터가 흘러가면서 알아서 필터링되고, 가공됨
-> 임시 변수가 하나도 없음. for, if 키워드도 사라짐
컴퓨터에게 어떻게(How) 하라고 시키지 않았음
단지 뭘 원하는지 무엇(What)만 선언했음

이것이 바로 선언형 프로그래밍(Declarative Programming)

스트림의 3단계 흐름

  1. 생성하기: idols.stream() - 데이터의 흐름을 만듬
  2. 가공하기(중간연산): filter, sorted, map - 데이터를 깎고 다듬음
  3. 결과 만들기(최종연산): collect - 최종 결과물을 포장함

Step2: 중간 연산

필터링과 슬라이싱

1. filter: 입구 컷!

  • 조건에 맞지 않는 데이터는 가차 없이 탈락시킴. if문 대신 씀
// 점수가 90점 이상인 아이돌만 통과!
idols.stream()
    .filter(idol -> idol.getScore() >= 90)
    ...

2. distinct: 중복은 안 받아요

  • 똑같은 데이터가 여러 개 들어오면 하나만 남김
    주의 - 이게 작동하려면 Idol 클래스에 equals()와 hashCode()가 제대로 구현되어 있어야 함(주민등록증 확인 같은 거)
idols.stream()
    .distinct() // 쌍둥이(중복 객체) 하나 제거
    ...

3. limit: 선착순

  • 데이터가 아무리 많아도 딱 정해진 개수만큼만 통과시킴
idols.stream()
    .limit(2) // "앞에 2명만 들어오세요~"
    ...

변환과 정렬

4. map: 변신

  • 데이터의 형태를 바꿈. 사과를 주면 사과 주스로, 아이돌 객체를 주면 이름(String)으로. 가장 많이 쓰이는 연산 중 하나
// Idol 객체 -> String(이름 + "님")으로 변환
idols.stream()
    .map(idol -> idol.getName() + "님")
    ...

5. sorted: 줄 서세요

  • 데이터를 정해진 기준에 따라 정렬함
// 람다를 쓰면 정렬도 한 줄 컷!
idols.stream()
    .sorted(Comparator.comparingInt(Idol::getScore)) // 점수 낮은 순
    ...

Chaining
미션: 점수 90점 이상인 우등생들 중, 중복을 제거하고, 이름을 가나다순으로 정렬해서, 상위 2명만 뽑아라.

List<String> result = idols.stream()
    .filter(idol -> idol.getScore() >= 90)       // 1. 우등생 필터링
    .distinct()                                  // 2. 중복 제거
    .sorted(Comparator.comparing(Idol::getName)) // 3. 이름순 정렬
    .map(Idol::getName)                          // 4. 이름만 추출
    .limit(2)                                    // 5. 2명만 제한
    .collect(Collectors.toList());               // 6. 포장 (최종 연산)

로직의 흐름이 한눈에 보임


Step3: 최종 연산

공장의 출하 단계
리스트로 만들 수도 있고(collect), 하나로 뭉칠 수도 있고(reduce), 맵으로 묶을 수도 있음(groupingBy)

Reduce: 하나로 합체

  • 모든 데이터를 단 하나의 값으로 만들어냄
int totalScore = idols.stream()
    .map(Idol::getScore)         // 1. 점수만 추출
    .reduce(0, (a, b) -> a + b); // 2. 0부터 시작해서 계속 더하기

처음엔 0이었다가, 하나씩 더해지면서 거대한 결과값이 됨

GroupingBy: 헤쳐 모여

SQL의 GROUP BY와 같은 기능

// Map<String, List<Idol>> 형태로 반환
Map<String, List<Idol>> byTeam = idols.stream()
    .collect(Collectors.groupingBy(Idol::getTeam));

결과
IVE: [장원영, 안유진, 레이]
Aespa: [카리나, 지젤]
LE SSERAFIM: [김채원, 카즈하, 은채]

PartitioningBy: 흑백 논리

데이터를 딱 두 그룹(True/False)으로 나누고 싶을 때 씀

// Map<Boolean, List<Idol>> 형태로 반환
Map<Boolean, List<Idol>> isGenius = idols.stream()
    .collect(Collectors.partitioningBy(idol -> idol.getScore() >= 90));

결론(스트림 API)

  1. 생성(Source): idols.stream()
  2. 가공(Intermediate): filter, map, sorted...
  3. 결과(Terminal): collect, reduce...
  • 복잡한 반복문, 조건문, 임시 변수 없이도 데이터 처리 과정을 하나의 문장처럼 써낼 수 있음

Step4: 한번 더 스트림

1. 필터링과 슬라이싱(Filtering & Slicing)

"원하는 것만 골라내고, 나머지는 과감히 버린다!"

// 미션: "300칼로리보다 높은 요리 중, 처음 2개는 건너뛰고 3개만 보여줘!"
menuList.stream()
    .filter(m -> m.getCalories() > 300) // 1. 300칼로리 초과 필터
    .skip(2)                            // 2. 앞의 2개는 패스
    .limit(3)                           // 3. 딱 3개만 선정
    .forEach(System.out::println);
  • filter: 조건(Predicate)에 맞는 데이터만 통과
  • limit: 앞에서부터 N개만 딱 끊기
  • skipL 앞에 N개는 건너뛰기!
  • distinct: 중복 제거(주민등록증 검사!)

2. 매핑(Mapping)

"데이터의 모양을 마음대로 바꾼다!"

// 미션: "500칼로리 이상 메뉴들의 이름만 뽑아서 리스트로 만들기"
List<String> highCalorieNames = menuList.stream()
    .filter(m -> m.getCalories() >= 500)
    .map(Dish::getName) // 요리 객체 -> 이름(String)으로 변환!
    .collect(toList());

// 미션: "요리의 총 칼로리 합계 구하기" (숫자 전용 스트림 사용)
int totalCalories = menuList.stream()
    .mapToInt(Dish::getCalories) // IntStream으로 변환하여 최적화
    .sum();

숫자를 계산할 때는 일반 map보다 mapToInt, mapToDouble을 사용하는 게 좋음. sum(), average() 같은 편리한 기능 있음

3. 검색과 매칭(Finding & Matching)

"우리 메뉴에 이런 게 있을까?"
데이터 전체를 가공하지 않고, 조건에 맞는지 확인만 하고 싶을 때 사용

// 채식주의자가 먹을 수 있는 요리가 하나라도 있나요?
boolean hasVeggie = menuList.stream()
    .anyMatch(Dish::isVegetarian); // 결과: true

// 모든 요리가 1000칼로리 미만인가요?
boolean isHealthy = menuList.stream()
    .allMatch(m -> m.getCalories() < 1000); // 결과: true
  • anyMatch: 하나라도 조건에 맞으면 OK
  • allMatch: 모든 데이터가 조건에 맞아야 OK
  • findFirst: 조건에 맞는 녀석 중 가장 첫 번째 발견된 데이터

4. 정렬(Sorting)

"보기 좋은 떡이 먹기도 좋다"
데이터를 특정 기준으로 줄 세우는 방법

// 미션: "육류 요리 중 칼로리가 낮은 순으로 정렬하기"
menuList.stream()
    .filter(m -> m.getType() == DishType.MEAT)
    .sorted(Comparator.comparing(Dish::getCalories)) // 오름차순
    .forEach(System.out::println);

// 미션: "이름을 역순(Z-A)으로 정렬하기"
menuList.stream()
    .sorted(Comparator.comparing(Dish::getName).reversed()) // 내림차순
    .forEach(System.out::println);

스트림은 결국
어떤 데이터를(Source)
어떻게 걸러서(Intermediate)
어떤 모양으로 담을까(Terminal)의 반복


제네릭과 스트림에 대해서 헷갈리는 부분이 많았는데
오늘 강의를 들으면서 개념 정리가 확실히 잘 된 거 같다.
하지만 여전히 헷갈리고 어려운 부분이 좀 있어서 복습을 잘 해야할 거 같다.

0개의 댓글