개발/유니티

[유니티]c#문법5 :스트림 이해 및 예제

kimchangmin02 2025. 7. 15. 21:46

비유

스트림이 왜 좋은지

숙제 검사하기

선생님이 반 친구들 30명의 숙제를 검사해서, 참 잘했어요 도장을 찍어주고 싶어요.

숙제 내용: 수학 문제 푼 공책
도장 조건: 100점 맞은 공책에만 찍어주기


방법 1: 옛날 방식 (List, for문)

이건 조금 복잡하게 일하는 선생님이에요.

  1. 일단 다 걷기: 선생님은 30명 전체의 공책을 전부 걷어서 자기 책상 위에 커다랗게 쌓아둬요.
    • (이 공책 더미가 바로 메모리를 많이 차지하는 List예요.)
  2. 분류하기: 이제 **"100점짜리만 담을 새 상자"**를 옆에 가져와요.
    • (이 새 상자가 메모리를 또 차지하는 '중간 바구니'예요.)
  3. 공책 더미에서 공책을 한 권씩 꺼내서 점수를 봐요.
    • 100점이면? -> **"100점짜리 상자"**에 넣어요.
    • 100점이 아니면? -> 원래 더미에 그냥 둬요.
  4. 도장 찍기: 이제 일이 끝난 "100점짜리 상자"를 열어서, 안에 있는 공책들에 "참 잘했어요" 도장을 하나씩 꽝! 꽝! 찍어줘요.

문제점: 선생님 책상이 너무 좁아요! 공책 30권 더미도 있고, 100점짜리 공책을 담은 새 상자도 있고... 너무 복잡하고 자리를 많이 차지해요.


방법 2: 스트림(Stream) 방식 (똑똑한 선생님)

이건 아주 똑똑하고 빠르게 일하는 선생님이에요.

  1. 줄을 세우기: 선생님은 친구들을 한 줄로 쭉 세워요. 이걸 **"스트림"**이라고 불러요. (공책을 미리 걷지 않아요!)
  2. 움직이는 검사 라인: 이제 첫 번째 친구부터 선생님 앞으로 걸어오게 해요.
  3. 한 명씩, 그 자리에서 바로!
    • 철수가 공책을 들고 선생님 앞에 왔어요.
    • 선생님이 점수를 쓱 봐요. "어, 80점이네?" -> "도장 없어~ 다음 친구!" (철수는 그냥 자기 자리로 돌아가요)
    • 영희가 공책을 들고 왔어요.
    • 선생님이 점수를 쓱 봐요. "와, 100점이네!" -> 그 자리에서 바로 "참 잘했어요" 도장을 꽝! 찍어줘요. (영희는 기쁘게 자리로 돌아가요)

선생님은 이 일을 모든 친구가 지나갈 때까지 반복해요.


뭐가 다를까? (메모리 이야기)

  • 옛날 방식 선생님: 책상 위에 **"전체 공책 더미"**와 **"100점짜리 공책 상자"**라는 두 개의 큰 짐을 올려놔야 했어요. (메모리를 많이 씀)
  • 똑똑한 스트림 선생님: 선생님 책상 위는 항상 깨끗해요! 왜냐하면 공책을 쌓아두지 않고, 친구 한 명의 공책만 잠깐 보고 바로 처리하니까요. 필요한 건 오직 "도장" 하나뿐이었어요. (메모리를 거의 안 씀)

결론:
스트림은 불필요한 짐(중간 저장 상자)을 만들지 않고, 일이 흘러가는 길목에서 필요한 것만 즉시 처리하는 아주 똑똑한 방법이에요. 그래서 컴퓨터가 힘들어하지 않고(메모리를 아끼고), 일을 더 빨리 끝낼 수 있답니다

 

>이래서, 메모리관점을 언급한거구나

 

 

 

 

 

 

 

 

2

일을 나눠서 더 빨리 할 수 있어요 (병렬 처리)

.stream() 대신 .parallelStream()

 

 

 

 

 

3

예제들이해하려면

Stream API 함수형 메서드 <좀 알아야할지도

 

 

.peek() — 디버깅용: 중간 과정을 출력

 

 

.limit(), .skip() — 일부만 남기거나 건너뛰기

List.of(1, 2, 3, 4, 5)
    .stream()
    .skip(2)        // 앞 2개 건너뜀
    .limit(2)       // 그 다음 2개만 사용
    .forEach(System.out::println); // 3, 4

 

 

 

 

groupingBy()// 같은 기준값을 가진 요소들끼리 List로 묶음

 

List<String> names = List.of("Alice", "Bob", "Anna", "Brian");

Map<Character, List<String>> grouped = names.stream()
    .collect(Collectors.groupingBy(name -> name.charAt(0)));

System.out.println(grouped);
// 출력: {A=[Alice, Anna], B=[Bob, Brian]}

//List.of(...)는 불변 리스트 생성

 

 

 

 

 

★ 초급: 스트림의 시작과 끝 (이것만 알아도 반은 성공!)

이 메서드들은 스트림의 가장 기본적인 구조를 만듭니다.

메서드 종류 비유 설명 예시 코드
.stream() 생성 재료를 컨베이어 벨트에 올리기 리스트, 배열 같은 데이터 묶음을 스트림(흐름)으로 만들어주는 출발 스위치입니다. names.stream()
.forEach() 최종 벨트 끝에서 완성품 처리하기 스트림을 따라 흘러온 최종 결과물들을 가지고, 각각에 대해 어떤 행동을 합니다. (예: 화면 출력) .forEach(System.out::println)
.collect() 최종 벨트 끝에서 완성품을 상자에 담기 스트림의 최종 결과물들을 모아서 새로운 리스트(List), 맵(Map), 셋(Set) 등으로 만들어 줍니다. .collect(Collectors.toList())

★★ 중급: 스트림 중간에서 데이터 가공하기 (가장 많이 사용!)

이 메서드들은 흘러가는 데이터를 원하는 형태로 필터링하거나 변환하는 핵심적인 역할을 합니다. "중간 연산"이라고 부릅니다.

메서드 종류 비유 설명 예시 코드
.filter() 중간 선별 기계 (솎아내기) 주어진 조건(람다식)에 true인 요소만 통과시키고, 나머지는 버립니다. .filter(n -> n > 10) (10보다 큰 수만 통과)
.map() 중간 가공 기계 (모양 바꾸기) 각 요소를 받아서, 주어진 함수(람다식)를 적용해 새로운 형태의 요소로 변환합니다. (1:1 변환) .map(s -> s.toUpperCase()) (소문자를 대문자로)
.distinct() 중간 중복 제거기 스트림에서 중복된 요소들을 제거합니다. (이전 요소와 같은 것은 버림) List.of(1, 2, 2, 3).stream().distinct() -> 1, 2, 3
.sorted() 중간 정렬 기계 요소들을 순서대로 정렬합니다. (숫자는 크기순, 문자는 알파벳순) .sorted()
.limit() 중간 개수 제한기 스트림의 요소 중 앞에서부터 주어진 개수만큼만 잘라서 통과시킵니다. (무한 스트림에 필수) .limit(5) (앞에서 5개만 통과)

중급 예시 종합:

Generated java

// 숫자 리스트에서
List<Integer> numbers = List.of(3, 1, 4, 1, 5, 9, 2, 6);

List<Integer> result = numbers.stream()       // 1. 재료를 벨트에 올리고
        .distinct()         // 2. 중복을 제거하고 (3,1,4,5,9,2,6)
        .filter(n -> n > 3)   // 3. 3보다 큰 것만 고르고 (4,5,9,6)
        .sorted()           // 4. 순서대로 정렬하고 (4,5,6,9)
        .map(n -> n * 10)     // 5. 각각 10을 곱해서 (40,50,60,90)
        .collect(Collectors.toList()); // 6. 리스트로 모아줘!

// result: [40, 50, 60, 90]
 
Use code with caution.Java

★★★ 고급: 더 복잡한 데이터 처리 및 집계

이 메서드들은 여러 요소를 합치거나, 더 복잡한 구조의 데이터를 다룰 때 사용됩니다.

메서드 종류 비유 설명 예시 코드
.flatMap() 중간 평탄화 기계 (상자 풀어서 내용물만 보내기) 각 요소가 또 다른 스트림(또는 컬렉션)일 때, 그 내부의 요소들을 모두 꺼내어 하나의 평평한 스트림으로 만들어 줍니다. (예: List<List<String>> -> Stream<String>) .flatMap(list -> list.stream())
.reduce() 최종 압축기 (전부 합쳐서 하나로) 스트림의 모든 요소를 돌면서, 주어진 로직에 따라 하나의 결과값으로 합쳐(줄여)줍니다. (예: 모든 숫자의 합 구하기, 최댓값 찾기) Optional<Integer> sum = numbers.stream().reduce((a, b) -> a + b)
.count() 최종 개수 카운터 스트림에 남아있는 요소의 총 개수를 셉니다. .count()
.anyMatch() 최종 "하나라도 있니?" 검사기 스트림의 요소 중 하나라도 주어진 조건을 만족하면 true를 반환하고 즉시 멈춥니다. .anyMatch(s -> s.isEmpty()) (빈 문자열이 하나라도 있니?)
.allMatch() 최종 "모두 다 그래?" 검사기 스트림의 모든 요소가 주어진 조건을 만족해야 true를 반환합니다. .allMatch(n -> n > 0) (모든 숫자가 양수니?)

고급 예시:

Generated java

// flatMap 예시
List<List<String>> listOfLists = List.of(
    List.of("a", "b"),
    List.of("c", "d")
);
List<String> flatList = listOfLists.stream()
        .flatMap(list -> list.stream()) // [[a,b],[c,d]] -> [a,b,c,d]
        .collect(Collectors.toList());

// reduce 예시
List<Integer> numbers = List.of(1, 2, 3, 4, 5);
int sum = numbers.stream()
        .reduce(0, (total, element) -> total + element); // (0+1=1), (1+2=3), (3+3=6), ...
// sum: 15
 

 

 

 

 

3-1

List<String> names = List.of("Alice", "Bob", "Anna");

for (String name : names) {
    if (name.startsWith("A")) {
        System.out.println(name);
    }
}

 

 

 

 

names.stream()
     .filter(name -> name.startsWith("A"))
     .forEach(System.out::println);

 

 

 

 

 

3-2

names.stream()
     .map(String::toUpperCase)
     .forEach(System.out::println);

 

 

 

 

 

 

3-3

names.stream()
     .filter(name -> name.startsWith("A"))
     .map(String::toUpperCase)
     .forEach(System.out::println);

 

🔍 헷갈릴 수 있는 포인트

"map은 원래 key-value 구조 아니었어?"

맞아
Map<K, V>는 자료구조(map 자료형)
→ 자바의 HashMap, TreeMap 같은 거지

여기서 말하는 .map()은 Stream의 메서드야
→ 각각의 값을 **"변형"**하는 함수형 연산자야

 

 

 

 

 

 

 

3-4

이때 두개의 매개변수는 어케 정해지는거지 ?

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

int sum = nums.stream()
              .reduce(0, (a, b) -> a + b);

System.out.println(sum); // 15

 

 

 

 

3-5

List<String> fruits = List.of("Apple", "Banana", "Cherry");

// "fruits 리스트의 각 요소(fruit)에 대해, 화면에 출력(println)하는 작업을 해줘"
fruits.forEach(System.out::println);

 

 

 

 

3-6

List<String> fruits = List.of("Apple", "Banana", "Cherry", "Avocado");

// "fruits를 스트림으로 만들고, 'A'로 시작하는 것만 걸러내서, 새로운 리스트로 모아줘"
List<String> aFruits = fruits.stream()
        .filter(fruit -> fruit.startsWith("A"))
        .collect(Collectors.toList());

System.out.println(aFruits);

 

 

 

 

 

 

3-7

List<String> fruits = List.of("Apple", "Banana", "Cherry", "Banana");

// "fruits 스트림에서,
//  각 과일 이름을 '그것의 길이(숫자)'로 바꾸고 (map),
//  중복된 길이는 제거하고 (distinct),
//  숫자 순서대로 정렬해서 (sorted),
//  리스트로 모아줘"
List<Integer> lengths = fruits.stream()
        .map(fruit -> fruit.length())
        .distinct()
        .sorted()
        .collect(Collectors.toList());

System.out.println(lengths);

 

 

 

 

 

 

 

3-8

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

// "numbers 스트림의 모든 요소를 하나로 합쳐줘.
//  초기값은 0이고, 합치는 규칙은 '누적값(sum)과 현재값(num)을 더하는 것'이야."
int totalSum = numbers.stream()
        .reduce(0, (sum, num) -> sum + num);

System.out.println(totalSum);

 

reduce의 두 매개변수: (a, b)

  • a: 누적된 값 (Accumulator). 지금까지 쓰러진 도미노들이 합쳐진 결과입니다.
  • b: 이번에 새로 들어온 값 (Element). 지금 막 쓰러뜨릴 차례인 다음 도미노입니다.

이제 reduce(0, (a, b) -> a + b) 코드를 따라가 봅시다.

시작점: reduce(0, ...)

  • 0 "초기값(Identity)" 입니다. 맨 처음에, 아직 아무 도미노도 쓰러뜨리지 않았을 때의 시작점입니다.
  • 우리의 누적값 a 0에서 시작합니다.

도미노 쓰러뜨리기 시작!

스트림의 요소: [1, 2, 3, 4, 5]

1단계

  • 누적된 값 a: 0 (맨 처음 초기값)
  • 새로 들어온 값 b: 1 (스트림의 첫 번째 요소)
  • 계산 (a, b) -> a + b: 0 + 1 = 1
  • 결과: 이제 새로운 누적값 a 1이 됩니다.

2단계

  • 누적된 값 a: 1 (방금 전 계산 결과)
  • 새로 들어온 값 b: 2 (스트림의 두 번째 요소)
  • 계산 (a, b) -> a + b: 1 + 2 = 3
  • 결과: 이제 새로운 누적값 a 3이 됩니다.

3단계

  • 누적된 값 a: 3 (방금 전 계산 결과)
  • 새로 들어온 값 b: 3 (스트림의 세 번째 요소)
  • 계산 (a, b) -> a + b: 3 + 3 = 6
  • 결과: 이제 새로운 누적값 a 6이 됩니다.

4단계

  • 누적된 값 a: 6 (방금 전 계산 결과)
  • 새로 들어온 값 b: 4 (스트림의 네 번째 요소)
  • 계산 (a, b) -> a + b: 6 + 4 = 10
  • 결과: 이제 새로운 누적값 a 10이 됩니다.

5단계

  • 누적된 값 a: 10 (방금 전 계산 결과)
  • 새로 들어온 값 b: 5 (스트림의 마지막 요소)
  • 계산 (a, b) -> a + b: 10 + 5 = 15
  • 결과: 이제 새로운 누적값 a 15가 됩니다.

최종 결과
스트림의 모든 요소를 다 사용했으므로, reduce 연산은 최종적으로 계산된 **마지막 누적값 15**를 반환합니다.

핵심 정리

reduce(초기값, (a, b) -> 계산식) 에서

  • a (첫 번째 매개변수)는 "중간 계산 결과 보관함" 이라고 생각하세요.
  • b (두 번째 매개변수)는 "컨베이어 벨트에서 지금 막 도착한 새 부품" 이라고 생각하세요.

reduce는 스트림의 모든 부품(b)이 지나갈 때까지, "(기존 중간 결과) + (새 부품)" 계산을 계속 반복해서 최종적으로 하나의 완성품을 만들어내는 **"압축 기계"**인 것입니다.

 

 

 

 

 

 

 

 

 

3-9

// 데이터 준비
class Person {
    String name;
    int age;
    // 생성자, getter 등 생략...
}
List<Person> people = List.of(
    new Person("철수", 22), new Person("영희", 25),
    new Person("민수", 31), new Person("지현", 29),
    new Person("준호", 35)
);

// "people 스트림을,
//  '나이를 10으로 나눈 몫(나이대)'을 기준으로 그룹으로 묶고 (groupingBy),
//  각 그룹에 속한 요소들의 개수를 세어줘 (counting)"
Map<Integer, Long> countByDecade = people.stream()
        .collect(Collectors.groupingBy(
            person -> person.getAge() / 10, // 그룹핑 기준: 20대(2), 30대(3)
            Collectors.counting()           // 그룹별 집계 방법: 개수 세기
        ));

System.out.println(countByDecade);

 

 

 

 

 

 

3-10

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

nums.stream()
    .filter(n -> n % 2 == 0)
    .forEach(System.out::println); // 2, 4, 6

 

 

 

 

 

3-11

List<Integer> nums = List.of(1, 2, 3);

List<Integer> squared = nums.stream()
    .map(n -> n * n)
    .collect(Collectors.toList());

System.out.println(squared); // [1, 4, 9]

 

 

 

 

 

3-12

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

int sum = nums.stream()
    .filter(n -> n % 2 == 0)
    .map(n -> n * n)
    .reduce(0, (a, b) -> a + b);

System.out.println(sum); // 4² + 2² = 4 + 16 = 20

 

 

 

3-13

초기값이 없는 reduce의 특징

reduce(초기값, ...) 형태가 아니라 reduce(...) 만 있을 때는, 스트림의 첫 번째와 두 번째 요소가 먼저 대결을 펼칩니다.

 

 

 

List<String> words = List.of("apple", "banana", "kiwi");

String longest = words.stream()
    .reduce((a, b) -> a.length() >= b.length() ? a : b)
    .orElse("");

System.out.println(longest); // banana

 

 

 

그런데 .orElse("")는 왜 있을까? (Optional의 등장)

여기에 중요한 함정이 있습니다.

만약 words 리스트가 비어있다면 어떻게 될까요? List.of()

 

 

 

 

 

 

 

 

3-14

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

Map<Boolean, List<Integer>> result = nums.stream()
    .collect(Collectors.partitioningBy(n -> n % 2 == 0));

System.out.println(result);
// 출력: {false=[1, 3, 5], true=[2, 4, 6]}

 

 

 

콜렉션이랑 결합되니깐 모르겟어,

또한 맵안에 리스트 뭐지 구조가

 Map<Boolean, List<Integer>> 구조 이해하기: "분리수거함"

이 복잡해 보이는 타입을 비유로 먼저 이해해 봅시다.

Map<Key, Value> "이름표(Key)가 붙은 여러 개의 상자(Value)" 라고 생각할 수 있습니다.

이 코드에서 Map<Boolean, List<Integer>>는:

  • Boolean (Key): 상자의 이름표가 true 또는 false 두 종류뿐입니다.
    • true 이름표가 붙은 상자
    • false 이름표가 붙은 상자
  • List<Integer> (Value): 각 상자 안에는 숫자들이 담긴 리스트가 들어 있습니다.

즉, 이 Map "true 상자"와 "false 상자" 단 두 개로 이루어진 거대한 분리수거함과 같습니다.

 

 

 

 

 

.collect(Collectors.partitioningBy(...)) : "분리수거 로봇" 작동

groupingBy가 "여러 종류의 이름표"를 만들 수 있는 똑똑한 분류 로봇이었다면, partitioningBy는 더 단순하고 빠른, 오직 'O' / 'X' 두 가지로만 나누는 전용 분리수거 로봇입니다.

  • Collectors.partitioningBy(...): "이제부터 두 그룹(true, false)으로 나눌 거야!" 라는 명령입니다. 'Partition'이라는 단어 자체가 '분할하다', '나누다'라는 뜻입니다.
  • 분류 기준 (람다식): n -> n % 2 == 0
    • 이것이 로봇의 유일한 판단 기준입니다.
    • "숫자(n)가 들어오면, 2로 나눈 나머지(n % 2)가 0인지(== 0), 즉 짝수인지 아닌지만 판단해."
    • "판단 결과가 true이면 -> 'true 상자'에 넣어."
    • "판단 결과가 false이면 -> 'false 상자'에 넣어."