문서 읽는 데 55분 · day26

Day 26 — Stream API (1): 데이터를 흐름으로 다루다

목차 22
전체 33강 중 26강 · 자바 기초
난이도 · 입문

지난 시간, 우리는 직접 도구 하나를 만들었어요. PostFilter.filter 기억나시죠? 게시물 리스트와 "조건"을 받아서, 조건에 맞는 것만 골라 돌려주던 메서드요. 안쪽을 들여다보면 빈 리스트를 하나 만들고, for 로 전부 훑으면서, 조건에 맞는 것만 add 하는 구조였어요. 거기에 "무슨 조건으로 거를지"만 람다로 바깥에서 넘겼고요.

그걸 다 만들고 나서 제가 이런 말을 했어요. "자바를 만든 사람들도 똑같은 생각을 했다"고요. 거르고·바꾸고·모으는 뼈대를 매번 손으로 짜지 말고, 아예 모든 컬렉션에 기본으로 넣어두자고요. 그렇게 들어간 도구가 바로 오늘의 주인공, 스트림(Stream) 이에요.

스트림을 한 단어로 풀면 "흐름"이에요. 리스트나 셋 같은 컬렉션을, 데이터가 한 줄기로 흐르는 물줄기처럼 다루는 거예요. 그 물줄기 위에 "공개 글만 통과시켜", "제목만 뽑아", "좋아요 순으로 줄 세워" 같은 장치를 차례로 걸어두면, 데이터가 그 장치들을 통과하면서 우리가 원하는 모양으로 바뀌어 나와요. 우리가 손으로 만든 filter 의 정식 버전이 바로 여기 들어 있고요.

오늘은 이 흐름을 "만들고" "중간에 가공하는" 데까지 가요. 결과를 최종적으로 "모아서 통계 내는" 본격적인 도구는 다음 시간 몫이에요. 오늘은 물줄기를 트고, 그 위에 거르개·변환기·정렬기를 거는 법까지요. 지난 시간 "조건을 람다로 넘긴다"를 손에 익혔다면, 오늘은 그 위에 올라타기만 하면 돼요. 시작해볼게요!

🎯 학습 목표

  • for 반복이 어떻게 enhanced for 를 거쳐 Stream 으로 진화했는지 이해하고, 같은 일을 두 방식으로 짤 수 있어요.
  • 컬렉션·배열·숫자 범위에서 각각 스트림을 만드는 법(.stream(), Arrays.stream(), IntStream)을 익혀요.
  • 중간 연산 다섯 가지(filter·map·sorted·distinct·flatMap)로 흐름을 가공할 수 있어요.
  • 흐름을 살짝 들여다보는 peek 으로, 스트림이 "최종 연산이 붙을 때까지 미뤄두는" 지연 평가(Lazy Evaluation)를 눈으로 확인해요.
  • 중간 연산을 여러 개 이어 붙여 "데이터 파이프라인"을 만들 수 있어요.

오늘의 로드맵

  • Step 1for 반복 → Stream: 지난 시간 만든 filter 를 정식 버전과 나란히 비교해요.
  • Step 2 — 흐름 만들기 (1): 모든 컬렉션의 .stream().
  • Step 3 — 흐름 만들기 (2): 배열의 Arrays.stream() 과 숫자 범위의 IntStream.
  • Step 4 — 중간 연산 filter: 조건으로 거르기.
  • Step 5 — 중간 연산 map: 원소의 모양 바꾸기.
  • Step 6 — 중간 연산 sorted · distinct: 줄 세우고 중복 없애기.
  • Step 7 — 중간 연산 flatMap: 중첩된 걸 한 줄기로 펼치기.
  • Step 8 — 지연 평가와 peek: 중간 연산은 언제 실제로 도나요?
  • Step 9 — 파이프라인: 중간 연산을 이어 붙이기.
  • Step 10 — 종합 실습: 인스타그램 요구사항을 흐름으로 풀기.

Step 1: for 반복 → Stream: 내가 만든 게 이미 자바에 있었네

지난 시간 우리가 만든 PostFilter.filter 를 다시 떠올려볼게요. "공개 게시물만 골라내기"를 한다고 하면, 그 방식은 이런 모양이었어요. 빈 리스트를 만들고, for 로 전부 훑고, 조건이 맞으면 add 하고, 다 돌면 돌려줘요.

Java
// com/instagram/javabasic/modern/StreamIntro.java
public static List<Post> publicPostsByFor(List<Post> posts) {
    List<Post> result = new ArrayList<>();
    for (Post post : posts) {
        if (post.getStatus() == PostStatus.PUBLIC) {
            result.add(post);
        }
    }
    return result;
}

자, 이제 같은 일을 스트림으로 해볼게요.

Java
public static List<Post> publicPostsByStream(List<Post> posts) {
    return posts.stream()
            .filter(post -> post.getStatus() == PostStatus.PUBLIC)
            .toList();
}

posts.stream() 으로 리스트를 흐름으로 바꾸고, .filter(조건) 으로 거르고, .toList() 로 다시 리스트로 모았어요. 우리가 지난 시간 손으로 만든 그 filter 가, 점 하나 찍으면 나오는 .filter() 로 이미 들어 있던 거예요. 게다가 거기에 넘기는 조건 post -> post.getStatus() == PostStatus.PUBLIC 은 지난 시간 배운 그 람다 그대로예요. 새로 배울 게 없어요.

두 코드가 무엇이 다른지 한눈에 볼게요.

텍스트
 [for 방식]  "어떻게 도는지"를 내가 다 적는다
   ┌───────────────────────────────────────────┐
   │ 빈 리스트 만들기   ← 준비물               │
   │ for 로 하나씩 꺼내기 ← 도는 방법          │
   │ if 조건 검사       ← 거르는 일            │
   │ add 로 담기        ← 모으는 일            │
   │ return            ← 돌려주기              │
   └───────────────────────────────────────────┘

 [stream 방식]  "무엇을 원하는지"만 적는다
   posts.stream().filter(공개냐?).toList()
        └ 흐름 ┘ └ 거른다 ┘  └ 모은다 ┘

for 방식은 "리스트를 어떻게 도는지(인덱스, 꺼내기, 담기)"를 내가 전부 적어요. 스트림 방식은 "공개 글만 원해"라는 결과만 적고, 도는 방법은 자바에게 맡겨요. 앞엣것을 명령형(이렇게 저렇게 해라), 뒤엣것을 선언형(이런 걸 원해)이라고 불러요. 오늘 우리는 명령형 for 를 선언형 스트림으로 옮겨 타는 연습을 하는 거예요.

두 방식의 결과는 당연히 똑같아요. 같은 리스트를 넣으면 같은 공개 글이 나와요. 코드베이스 StreamIntroTest 에서 "두 방식의 결과가 완전히 같다"를 확인해 뒀어요. 모양만 다르지 하는 일은 한 글자도 다르지 않아요.

💡 오늘의 출발점: 스트림은 새로운 마법이 아니에요. 지난 시간 우리가 손으로 만든 "거르개"를, 자바가 미리 모든 컬렉션에 달아둔 거예요. .stream() 으로 흐름을 트고, 중간에 장치를 걸고, .toList() 로 모은다 — 이 세 박자가 전부예요.


Step 2: 흐름 만들기 (1): 모든 컬렉션의 .stream()

스트림으로 뭔가 하려면, 일단 흐름을 "터야" 해요. 가장 흔한 길은 컬렉션의 .stream() 이에요. 우리가 지금까지 써온 List·Set 에는 전부 .stream() 이 달려 있어요. 점 하나 찍고 stream() 을 부르면, 그 컬렉션의 원소들이 흐르는 물줄기가 시작돼요.

Java
// com/instagram/javabasic/modern/CollectionStreamDemo.java
public static List<Post> fromList(List<Post> posts) {
    return posts.stream().toList();
}

여기선 거르지도 바꾸지도 않고, 흐름을 그냥 한 바퀴 돌려서 .toList() 로 다시 리스트로 받았어요. 가장 단순한 형태죠. 중요한 건 "흐름은 끝에 뭔가를 붙여야 다시 눈에 보이는 결과로 돌아온다"는 점이에요. .stream() 만으로는 물줄기를 텄을 뿐, 아직 아무것도 손에 안 들어와요. .toList() 를 붙여야 비로소 리스트가 돼요.

Set 도 똑같아요. 점 하나 찍고 .stream() 이면 흐름이 시작돼요.

Java
public static List<String> fromSet(Set<String> tags) {
    return tags.stream().toList();
}
텍스트
   List<Post>  ──.stream()──▶  [ 흐름: Post Post Post ... ]  ──.toList()──▶  List<Post>
   Set<String> ──.stream()──▶  [ 흐름: tag  tag  tag  ... ]  ──.toList()──▶  List<String>
                                    └ 여기에 거르개·변환기를 걸 수 있어요 ┘

지금은 흐름과 결과 사이에 아무 장치가 없어서 넣은 게 그대로 나와요. 다음 Step 들에서 이 가운데에 filter·map 같은 장치를 하나씩 끼워 넣을 거예요. 흐름을 트는 입구(.stream())와 결과를 받는 출구(.toList())는 그대로 두고, 가운데만 바꿔 끼우는 거죠.

🙋 학생 질문 — "튜터님, 그냥 리스트를 그대로 쓰면 되지 왜 흐름으로 바꿔요?"

좋은 질문이에요. fromList 처럼 아무것도 안 하고 흐름을 한 바퀴 돌리는 건 사실 의미가 없어요. 그냥 리스트를 쓰면 되죠. 흐름이 빛을 발하는 건 가운데에 장치를 걸 때예요. "공개 글만, 제목만, 좋아요 순으로" 같은 여러 가공을 한 줄에 이어 붙일 때, for 를 여러 번 쓰는 것보다 훨씬 짧고 읽기 좋아져요. 지금 Step 2 는 그 입구와 출구만 먼저 익히는 단계예요. 가운데 장치는 Step 4 부터 본격적으로 걸어볼게요.


Step 3: 흐름 만들기 (2): 배열의 Arrays.stream() 과 숫자 범위의 IntStream

흐름을 트는 길이 컬렉션 말고도 두 개 더 있어요. 자주 쓰니까 같이 익혀둘게요.

첫째, 배열이에요. 배열에는 .stream() 이 달려 있지 않아요. 그래서 Arrays.stream(배열) 로 감싸서 흐름을 만들어요.

Java
// com/instagram/javabasic/modern/ArrayStreamDemo.java
public static List<String> fromArray(String[] names) {
    return Arrays.stream(names).toList();
}

둘째, 숫자 범위예요. "0부터 9까지", "1부터 100까지" 같은 연속된 숫자가 필요할 때가 많죠. 그럴 때 IntStream 을 써요. IntStream 은 이름 그대로 int 가 흐르는 전용 흐름이에요.

Java
public static List<Integer> indexes(int n) {
    return IntStream.range(0, n).boxed().toList();
}

public static List<String> rankLabels(int n) {
    return IntStream.rangeClosed(1, n)
            .mapToObj(i -> i + "위")
            .toList();
}

rangerangeClosed 는 한 글자 차이로 끝을 포함하느냐가 갈려요. 헷갈리기 쉬우니 표로 정리할게요.

호출 만들어지는 숫자 끝(n)은?
IntStream.range(0, 5) 0, 1, 2, 3, 4 제외
IntStream.rangeClosed(1, 5) 1, 2, 3, 4, 5 포함

range(0, n) 은 끝을 빼니까 배열 인덱스(0번부터 n-1번까지)를 만들 때 딱 맞고, rangeClosed(1, n) 은 끝을 포함하니까 "1위부터 n위까지" 같은 사람이 세는 번호에 잘 맞아요.

코드에 처음 보는 게 두 개 나왔죠. .boxed().mapToObj(...) 예요. IntStream 은 일반 흐름과 달리 "껍데기 없는 순수 int"가 흐르는 전용 통로라, 결과를 List<Integer> 같은 객체 리스트로 받으려면 한 번 변환해줘야 해요. .boxed() 는 int 를 그 짝꿍 객체인 Integer 로 감싸주고, .mapToObj(i -> ...) 는 int 를 우리가 원하는 객체(여기선 "1위" 같은 String)로 바꿔줘요. 지금은 "IntStream 결과를 리스트로 받을 땐 .boxed().mapToObj() 를 한 번 거친다" 정도만 기억하면 충분해요.


Step 4: 중간 연산 filter: 조건으로 거르기

이제 흐름 가운데에 장치를 걸어볼게요. 첫 번째 장치는 filter(거르개)예요. Step 1 에서 잠깐 봤지만, 이번엔 제대로요.

filter 는 지난 시간 우리가 만든 PostFilter.filter 와 하는 일이 똑같아요. 조건(람다)을 받아서, 그 조건이 true 인 원소만 통과시켜요. 지난 시간 배운 Predicate(조건을 담는 그릇) 를 그대로 넘기면 돼요.

Java
// com/instagram/javabasic/modern/FilterDemo.java
public static final Predicate<Post> POPULAR = post -> post.getLikeCount() >= 100;

public static List<Post> popularPosts(List<Post> posts) {
    return posts.stream()
            .filter(POPULAR)
            .toList();
}

POPULAR 는 "좋아요 100 이상이면 인기 글"이라는 조건을 담은 Predicate 예요. 지난 시간 만든 그 Predicate.filter() 에 그대로 꽂았어요. 람다를 변수에 담아 이름을 붙여두니, 조건이 뭘 뜻하는지 코드만 봐도 읽히죠.

filter 의 재미있는 점은, 여러 번 이어 붙일 수 있다는 거예요. filter 를 두 번 걸면 "두 조건을 모두 만족하는 것"만 남아요.

Java
public static List<Post> publicAndPopular(List<Post> posts) {
    return posts.stream()
            .filter(post -> post.getStatus() == PostStatus.PUBLIC)
            .filter(POPULAR)
            .toList();
}

첫 번째 거르개에서 공개 글만 통과하고, 그 통과한 것들이 두 번째 거르개에서 또 인기 글만 통과해요. 거르개를 두 개 직렬로 세운 셈이죠.

텍스트
   전체 글 ──[ 공개냐? ]──▶ 공개 글만 ──[ 좋아요 100+냐? ]──▶ 공개+인기 글만
            첫 번째 filter           두 번째 filter

예를 들어 "공개 250점", "비밀 300점", "공개 40점" 세 글이 있다면, popularPosts 는 100점 넘는 두 글(공개 250, 비밀 300)을 골라요. 하지만 publicAndPopular 는 공개이면서 100점 넘는 글 하나(공개 250)만 남겨요. 비밀 300점은 인기지만 공개가 아니라서 빠지는 거죠. 이 동작은 코드베이스 FilterDemoTest 에서 그대로 확인해 뒀어요.


Step 5: 중간 연산 map: 원소의 모양 바꾸기

filter 가 "줄이는" 장치라면, map(매핑) 은 "바꾸는" 장치예요. 흐르는 원소 하나하나를 다른 모양으로 변환해요. 개수는 그대로인데, 원소의 정체가 바뀌어요.

인스타그램에서 흔한 일이죠. 게시물 흐름에서 "제목만" 뽑고 싶을 때, 회원 흐름에서 "이름만" 뽑고 싶을 때요. 게시물(Post)이 흐르던 물줄기를, 제목(String)이 흐르는 물줄기로 바꾸는 거예요.

Java
// com/instagram/javabasic/modern/MapDemo.java
public static List<String> contents(List<Post> posts) {
    return posts.stream()
            .map(Post::getContent)
            .toList();
}

map(Post::getContent) 은 "각 게시물에서 내용만 꺼내라"는 뜻이에요. Post::getContent 는 지난 시간 배운 메서드 참조죠. 흐름에 흐르던 Post 가 이 장치를 지나면 String(내용) 으로 바뀌어 나와요.

같은 방식으로 숫자도 뽑을 수 있어요. 게시물 흐름을 좋아요 수(Integer) 흐름으로 바꿔봐요.

Java
public static List<Integer> likeCounts(List<Post> posts) {
    return posts.stream()
            .map(Post::getLikeCount)
            .toList();
}
텍스트
   [ Post   Post   Post ]   ──.map(Post::getContent)──▶   [ "카페 다녀옴"  "운동 시작" ... ]
     게시물이 흐르는 물줄기                                   제목(String)이 흐르는 물줄기

   원소의 "타입 자체"가 바뀐다:  Post ─▶ String,  Post ─▶ Integer

그리고 filtermap 은 같이 쓸 때 진짜 힘이 나요. 먼저 거르고, 그다음 바꾸는 거죠. "인기 글의 제목만"을 뽑아볼게요.

Java
public static List<String> popularContents(List<Post> posts) {
    return posts.stream()
            .filter(post -> post.getLikeCount() >= 100)
            .map(Post::getContent)
            .toList();
}

filter 로 인기 글만 남기고, 남은 것들을 map 으로 제목만 뽑았어요. "인기 글의 제목 목록"이 한 줄로 나오죠. 우리말로 읽으면 "흐름을 만들어서, 인기 글만 거르고, 제목으로 바꿔서, 리스트로 모아" 예요. 코드가 거의 우리말 문장 순서 그대로예요.

💡 filtermap 구별법: 거를지(filter) 바꿀지(map) 헷갈리면 이렇게 기억해요. "개수가 줄어드냐?"면 filter, "모양이 바뀌냐?"면 map. filter 를 지나도 Post 는 Post 그대로지만 개수가 줄고, map 을 지나면 개수는 그대로인데 Post 가 String 으로 변해요.


Step 6: 중간 연산 sorted · distinct: 줄 세우고 중복 없애기

장치를 두 개 더 배워요. sorted(정렬) 와 distinct(중복 제거) 예요.

sorted 는 흐르는 원소를 줄 세워요. 무엇을 기준으로 줄 세울지는, Day 18 에서 배운 Comparator(비교 기준) 를 그대로 넘겨서 정해요. "좋아요 많은 순"으로 게시물을 줄 세워볼게요.

Java
// com/instagram/javabasic/modern/SortedDistinctDemo.java
public static List<Post> byLikesDesc(List<Post> posts) {
    return posts.stream()
            .sorted(Comparator.comparingInt(Post::getLikeCount).reversed())
            .toList();
}

Comparator.comparingInt(Post::getLikeCount) 는 "좋아요 수를 기준으로 비교해"라는 뜻이고, 그 뒤에 .reversed() 를 붙이면 "큰 게 앞으로"(내림차순) 가 돼요. Day 18 에서 비교 기준을 클래스로 따로 만들었던 걸, 이젠 이렇게 한 줄로 표현해요.

distinct 는 중복을 없애요. 같은 원소가 여러 번 흘러도 한 번씩만 통과시켜요. "같다"의 기준은 우리가 Day 19 에서 배운 equals 예요. 게시물에서 작성자 이름만 뽑되, 같은 사람은 한 번씩만 나오게 해볼게요.

Java
public static List<String> distinctAuthors(List<Post> posts) {
    return posts.stream()
            .map(Post::getAuthorName)
            .distinct()
            .toList();
}

map 으로 작성자 이름을 뽑고, distinct 로 중복을 없앴어요. 한 사람이 글을 세 개 썼어도 이름은 한 번만 나와요.

장치들은 이어 붙일수록 강해져요. "중복 없는 작성자를, 가나다순으로"를 해볼게요.

Java
public static List<String> sortedDistinctAuthors(List<Post> posts) {
    return posts.stream()
            .map(Post::getAuthorName)
            .distinct()
            .sorted()
            .toList();
}

이름을 뽑고(map) → 중복을 없애고(distinct) → 가나다순으로 줄 세웠어요(sorted). sorted() 에 아무것도 안 넘기면, 문자열은 자연스러운 사전 순(가나다, ABC)으로 정렬돼요. 장치 세 개가 물줄기 위에 차례로 걸린 거예요.


Step 7: 중간 연산 flatMap: 중첩된 걸 한 줄기로 펼치기

map 은 "하나를 하나로" 바꿨죠. 이번엔 "하나를 여러 개로" 펼치는 장치, flatMap 이에요. 이름이 낯서니 먼저 어떤 문제를 푸는지부터 볼게요.

인스타그램 게시물 본문에는 해시태그가 여러 개 들어 있어요. "오늘 카페 #카페 #일상" 처럼요. 우리가 하고 싶은 건 "모든 게시물의 해시태그를 전부 한 바구니에 모으기"예요. 그런데 문제가 있어요. 게시물 하나에서 해시태그가 여러 개 나와요. 게시물 흐름을 그냥 map 으로 "본문을 단어로 쪼개"면, 흐름 안에 "단어 배열"이 흐르게 돼요. 배열 안에 배열이 든, 중첩된 모양이 되는 거죠.

텍스트
 [ map 으로 단어 쪼개기 ]  ← 중첩이 생김
   게시물1 ─▶ [ #카페, #일상 ]        ┐
   게시물2 ─▶ [ #패션, #일상 ]        ├ 흐름 안에 "배열"이 흐른다 (바구니 안에 바구니)
                                      ┘

 [ flatMap 으로 펼치기 ]  ← 한 줄기로 합침
   게시물1 ─▶ #카페 ─▶ #일상 ─▶ #패션 ─▶ #일상   ← 단어가 하나하나 흐른다

flatMap 은 이 중첩을 풀어서 한 줄기로 펼쳐요. "게시물 하나 → 단어 여러 개"로 펼친 다음, 그 단어들을 전부 같은 물줄기에 합쳐 흘려보내요. 코드로 볼게요.

Java
// com/instagram/javabasic/modern/FlatMapDemo.java
public static List<String> allTags(List<Post> posts) {
    return posts.stream()
            .flatMap(post -> Arrays.stream(post.getContent().split(" ")))
            .filter(word -> word.startsWith("#"))
            .toList();
}

한 줄씩 뜯어볼게요. post.getContent().split(" ") 은 본문을 띄어쓰기로 잘라 단어 배열을 만들어요. Arrays.stream(...) 으로 그 배열을 다시 흐름으로 바꾸고요(Step 3 에서 배웠죠). flatMap 은 게시물마다 나온 이 작은 흐름들을 하나의 큰 흐름으로 합쳐요. 그러고 나서 .filter(word -> word.startsWith("#"))# 으로 시작하는 단어, 즉 해시태그만 남겨요.

여기에 지난 Step 의 distinct 만 더하면, 중복 없는 해시태그 목록이 돼요.

Java
public static List<String> distinctTags(List<Post> posts) {
    return posts.stream()
            .flatMap(post -> Arrays.stream(post.getContent().split(" ")))
            .filter(word -> word.startsWith("#"))
            .distinct()
            .toList();
}

"#일상" 이 여러 글에 나와도, distinct 덕분에 결과엔 한 번만 남아요. 이 동작은 코드베이스 FlatMapDemoTest 에서 확인해 뒀어요.

💡 map vs flatMap 한 줄 정리: 원소 하나가 결과 하나로 바뀌면 map, 원소 하나가 결과 여러 개로 펼쳐지면 flatMap 이에요. "게시물 하나 → 제목 하나"는 map, "게시물 하나 → 해시태그 여러 개"는 flatMap.


Step 8: 지연 평가와 peek: 중간 연산은 언제 실제로 도나요?

지금까지 배운 filter·map·sorted·distinct·flatMap 에는 공통점이 하나 있어요. 전부 "중간 연산"이라는 거예요. 그리고 중간 연산에는 비밀이 하나 숨어 있어요. 중간 연산은 곧바로 실행되지 않아요. "이런 장치를 걸어둘게" 하고 쌓아두기만 해요. 실제로 데이터가 흐르기 시작하는 건, 흐름 끝에 .toList() 같은 "최종 연산"이 붙는 순간이에요.

이걸 지연 평가(Lazy Evaluation), 우리말로 "게으른 실행"이라고 불러요. 일을 미룰 수 있는 데까지 미뤘다가, 결과가 진짜 필요해지는 순간(.toList())에 한꺼번에 도는 거예요.

말로만 들으면 안 와닿죠. 눈으로 보여드릴게요. 흐르는 원소를 살짝 들여다보는 장치, peek 을 쓸 거예요. peek 은 원소를 바꾸지 않고 그냥 "지나가는 걸 구경"만 하는 중간 연산이에요. 여기에 기록을 남기게 해서, 데이터가 언제 흐르는지 확인해봐요.

먼저, 최종 연산을 "붙이지 않은" 경우예요.

Java
// com/instagram/javabasic/modern/LazyEvaluationDemo.java
public static void withoutTerminal(List<Post> posts, List<String> log) {
    Stream<Post> stream = posts.stream()
            .peek(post -> log.add("peek:" + post.getContent()));
    // stream 변수만 만들고 최종 연산을 붙이지 않았어요 → log 는 비어 있어요.
}

흐름을 만들고 peek 까지 걸었어요. 그런데 .toList() 같은 최종 연산을 안 붙였죠. 이 메서드를 실행하면 log 에 기록이 몇 개 쌓일까요? 놀랍게도 0개예요. peek 이 단 한 번도 실행되지 않아요. 장치는 걸어뒀지만, 흐름을 "당겨가는" 최종 연산이 없으니 데이터가 아예 흐르질 않은 거예요.

이번엔 최종 연산을 붙여볼게요.

Java
public static List<String> withTerminal(List<Post> posts, List<String> log) {
    return posts.stream()
            .peek(post -> log.add("들어옴:" + post.getContent()))
            .filter(post -> post.getLikeCount() >= 100)
            .peek(post -> log.add("통과:" + post.getContent()))
            .map(Post::getContent)
            .toList();
}

이제 끝에 .toList() 가 붙었어요. "인기 글"(250점)과 "평범한 글"(40점) 두 개를 넣고 실행하면, log 에 이런 순서로 기록이 쌓여요.

텍스트
   들어옴:인기 글     ← 인기 글이 첫 peek 통과
   통과:인기 글       ← filter 통과(250>=100) 후 둘째 peek
   들어옴:평범한 글   ← 평범한 글이 첫 peek 통과
   (통과 기록 없음)   ← filter 에서 막힘(40<100), 둘째 peek 못 감

두 가지가 보여요. 첫째, .toList() 가 붙으니 비로소 peek 이 실행돼요. 둘째, 기록 순서가 "인기 글 들어옴 → 인기 글 통과 → 평범한 글 들어옴" 이에요. 즉 게시물을 전부 모아 첫 장치를 통과시키고, 그다음 전부 둘째 장치로 보내는 게 아니라, 원소 하나가 장치들을 끝까지 통과한 뒤 다음 원소가 흐르는 거예요. 한 명씩 세로로 흐른다고 생각하면 돼요. 이 순서는 코드베이스 LazyEvaluationDemoTest 에서 그대로 확인해 뒀어요.

그래서 중간 연산만 잔뜩 적어둬도, 최종 연산이 없으면 컴퓨터는 아무 일도 안 해요. 이 "최종 연산이 방아쇠"라는 감각이 다음 시간으로 이어져요. 다음 시간엔 .toList() 말고 결과를 본격적으로 "모으는" 최종 연산들(개수 세기, 다 더하기, 그룹으로 묶기)을 배우는데, 그 도구들이 바로 지금 미뤄둔 중간 연산들을 깨워서 실행시키는 방아쇠 역할을 해요.


Step 9: 파이프라인: 중간 연산을 이어 붙이기

오늘 배운 장치들을 죽 이어 붙이면, 그게 바로 "데이터 파이프라인"이에요. 지난 시간 마지막에 예고했던 그거요. 물이 관(pipe)을 따라 흐르며 여러 장치를 차례로 통과하듯, 데이터가 중간 연산들을 차례로 지나며 우리가 원하는 모양으로 가공돼 나와요.

실제 인스타그램에서 나올 법한 요구를 하나 풀어볼게요. "공개된 인기 글을, 좋아요 많은 순으로, 제목만 보여줘." 우리말 문장 하나가 그대로 파이프라인이 돼요.

Java
// com/instagram/javabasic/modern/PostPipeline.java
public static List<String> popularPublicTitles(List<Post> posts) {
    return posts.stream()
            .filter(post -> post.getStatus() == PostStatus.PUBLIC)
            .filter(post -> post.getLikeCount() >= 100)
            .sorted(Comparator.comparingInt(Post::getLikeCount).reversed())
            .map(Post::getContent)
            .toList();
}

위에서 아래로 읽으면 우리말 문장 그대로예요. 흐름을 만들고 → 공개 글만 거르고 → 인기 글만 거르고 → 좋아요 많은 순으로 줄 세우고 → 제목만 뽑아서 → 리스트로 모아요.

텍스트
   전체 글
     │  .filter(공개냐?)        공개 글만 통과
     ▼
     │  .filter(좋아요 100+?)    인기 글만 통과
     ▼
     │  .sorted(좋아요 내림차순)  큰 순서로 줄 세움
     ▼
     │  .map(제목 뽑기)          Post ─▶ String
     ▼
   .toList()  ──▶  [ "가장 인기 글", "두 번째 인기 글" ]

"공개 120점", "공개 300점", "비밀 500점", "공개 40점" 네 글을 넣으면, 결과는 "가장 인기 글"(300)과 "두 번째 인기 글"(120) 두 제목이에요. 비밀 500점은 공개가 아니라서, 평범한 40점은 인기가 아니라서 빠지고, 남은 둘이 좋아요 큰 순서로 줄을 섰죠. 이 결과는 코드베이스 PostPipelineTest 에서 확인해 뒀어요.

같은 일을 for 로 짠다고 상상해봐요. 빈 리스트 만들고, for 돌면서 두 조건 if 검사하고, 통과한 걸 또 정렬하고, 다시 for 돌면서 제목만 뽑아 또 다른 리스트에 담고... 줄이 한참 길어지고 중간 리스트도 여러 개 생겨요. 파이프라인은 그 모든 과정을 다섯 줄에 담고, 무엇보다 "무엇을 원하는지"가 그대로 읽혀요.


Step 10: 종합 실습: 인스타그램 요구사항을 흐름으로 풀기

마지막으로, 오늘 배운 장치들을 자유롭게 조합해 인스타그램의 여러 요구를 풀어볼게요. 하나씩 흐름으로 옮기는 연습이에요. 결과를 "모으는" 본격적인 도구는 다음 시간 몫이라, 오늘은 전부 .toList() 까지만 가요.

먼저 "중복 없는 작성자 목록을 가나다순으로"예요. Step 6 에서 배운 조합 그대로죠.

Java
// com/instagram/javabasic/modern/StreamComprehensive.java
public static List<String> distinctAuthorsSorted(List<Post> posts) {
    return posts.stream()
            .map(Post::getAuthorName)
            .distinct()
            .sorted()
            .toList();
}

다음은 "좋아요 많은 상위 3개 글의 제목"이에요. 여기서 새 장치 limit 이 나와요. limit(n) 은 앞에서 n개만 남기고 나머지는 흘려보내요. 이것도 중간 연산이라 흐름 가운데에 걸 수 있어요.

Java
public static List<String> top3Titles(List<Post> posts) {
    return posts.stream()
            .sorted(Comparator.comparingInt(Post::getLikeCount).reversed())
            .limit(3)
            .map(Post::getContent)
            .toList();
}

좋아요 내림차순으로 줄 세운 다음 limit(3) 으로 앞 세 개만 남기고, 그 제목을 뽑았어요. "Top 3" 같은 요구는 sorted + limit 조합이면 끝나요.

"본문에서 중복 없는 해시태그만 가나다순으로"는 Step 7 의 flatMap 에 정렬을 더한 거예요.

Java
public static List<String> distinctHashtagsSorted(List<Post> posts) {
    return posts.stream()
            .flatMap(post -> Arrays.stream(post.getContent().split(" ")))
            .filter(word -> word.startsWith("#"))
            .distinct()
            .sorted()
            .toList();
}

회원 쪽 요구도 풀어봐요. "팔로워 1000명 이상인 회원의 이름을, 팔로워 많은 순으로"예요.

Java
public static List<String> influencerNames(List<Member> members) {
    return members.stream()
            .filter(member -> member.getFollowers() >= 1000)
            .sorted(Comparator.comparingInt(Member::getFollowers).reversed())
            .map(Member::getUsername)
            .toList();
}

거르고(filter) → 줄 세우고(sorted) → 이름 뽑기(map). Step 9 의 파이프라인과 똑같은 결이죠. 게시물이든 회원이든, 같은 장치들을 조합해 푸는 거예요. 이 다섯 가지 요구는 모두 코드베이스 StreamComprehensiveTest 에 케이스로 담아 확인해 뒀어요.

오늘 우리가 한 건 전부 "흐름을 만들고, 중간에 가공하는" 데까지예요. 그런데 슬슬 아쉬운 게 보이죠. "인기 글이 몇 개야?"(개수 세기), "좋아요 총합은?"(다 더하기), "작성자별로 글을 묶어줘"(그룹 묶기) 같은 건 아직 못 해요. 이렇게 흐름의 결과를 한 덩어리로 "모으는" 도구가 다음 시간 주인공이에요.


마무리

오늘 우리는 컬렉션을 "흐름"으로 다루는 스트림을 배웠어요. 핵심만 다시 짚을게요.

  • 흐름 만들기 — 컬렉션은 .stream(), 배열은 Arrays.stream(), 숫자 범위는 IntStream.range/rangeClosed.
  • 중간 연산filter(거르기) · map(바꾸기) · sorted(줄 세우기) · distinct(중복 없애기) · flatMap(펼치기). 이어 붙일수록 강해져요.
  • 지연 평가 — 중간 연산은 쌓아두기만 하고, 최종 연산(.toList())이 붙을 때 비로소 흐름이 돌아요.
  • 파이프라인 — "무엇을 원하는지"를 우리말 문장 순서대로 이어 붙이면, for 여러 개가 짧은 한 흐름이 돼요.

스트림의 진짜 묘미는 "어떻게 도는지"를 자바에게 맡기고, 우리는 "무엇을 원하는지"만 적는다는 거예요. 지난 시간 람다로 "동작을 값처럼 넘기는" 힘을 얻었고, 오늘 그 힘 위에서 데이터를 물 흐르듯 가공하는 법을 익혔어요.

다음 시간엔 — 흐름의 결과를 "모으자"

오늘 우리는 흐름을 만들고 중간에 가공하는 데까지 갔어요. 그런데 끝마다 똑같이 .toList() 만 붙였죠. 다음 시간엔 그 자리에 들어갈 다른 최종 연산들을 배워요. 개수를 세고(count), 다 더하고(reduce), 조건을 만족하는 게 있는지 묻고, 무엇보다 작성자별·지역별로 데이터를 "그룹으로 묶는" 강력한 도구(collect)까지요. 오늘 Step 8 에서 본 "최종 연산이 방아쇠"라는 감각, 그게 다음 시간 출발점이에요. 그리고 그다음 시간엔, 흐름에서 값을 찾을 때 "없을 수도 있는 값"을 안전하게 다루는 Optional 도 만나요. 오늘 정말 수고 많으셨어요!


과제

오늘 배운 스트림 중간 연산을 직접 손에 익히는 과제예요. modern 패키지의 오늘 예제들을 옆에 두고 풀면 편해요. 최종 연산은 아직 .toList() 만 써요. 개수 세기·합계·그룹 묶기 같은 건 다음 시간에 배우니, 오늘은 "흐름을 만들고 중간 연산으로 가공해서 리스트로 받기"까지만 하면 돼요.

과제 1: [기초] filtermap 으로 인기 작성자 뽑기

게시물 리스트(List<Post>)를 받아, 좋아요 200 이상인 글의 작성자 이름 목록을 돌려주는 메서드를 만들어보세요.

  • 새 클래스 PopularAuthors 를 만들고, static List<String> names(List<Post> posts) 를 둬요.
  • 흐름을 만들어서 → 좋아요 200 이상만 거르고(filter) → 작성자 이름으로 바꿔서(map, Post::getAuthorName) → .toList() 로 모아요.
  • 같은 작성자가 두 번 나와도 그대로 둬요(중복 제거는 과제 2 에서).

과제 2: [응용] distinctsorted 더하기

과제 1 을 발전시켜, 좋아요 200 이상인 글의 작성자를, 중복 없이, 가나다순으로 돌려주세요.

  • 과제 1 의 흐름 끝(map 다음)에 distinctsorted 를 이어 붙이면 돼요.
  • 한 작성자가 인기 글을 여러 개 써도 이름은 한 번만, 그리고 가나다순으로 줄 서야 해요.
  • Step 6·Step 10 의 distinctAuthorsSorted 가 좋은 힌트예요.

과제 3: [심화] flatMap 으로 해시태그 순위 재료 만들기

게시물 본문에는 "오늘 카페 #카페 #일상" 처럼 해시태그가 들어 있어요. 게시물 리스트를 받아, 모든 게시물에서 해시태그만 뽑아 한 리스트로 펼친 결과(중복 포함)를 돌려주세요.

  • 새 클래스 HashtagCollector 를 만들고, static List<String> allTags(List<Post> posts) 를 둬요.
  • 게시물마다 본문을 split(" ") 으로 단어로 쪼개고, Arrays.stream(...) 으로 흐름을 만들어, flatMap 으로 한 줄기로 합쳐요.
  • # 으로 시작하는 단어만 filter 로 남겨요. 중복은 일부러 그대로 둬요 — 같은 해시태그가 몇 번 나왔는지는 다음 시간에 "개수 세기"로 순위를 매길 재료가 되거든요.
  • Step 7 의 allTags 와 거의 같아요. 직접 손으로 한 번 짜보는 게 핵심이에요.

생각해볼 주제

1. for 도 되는데, 굳이 스트림으로 바꾸면 뭐가 좋아질까?

Step 1 에서 우리는 "공개 글 거르기"를 for 방식과 스트림 방식 두 가지로 짰어요. 결과는 똑같았죠. 그렇다면 굳이 스트림으로 바꾸는 이유가 뭘까요? "공개이면서 인기인 글을, 좋아요 순으로, 제목만" 처럼 조건이 여러 개로 늘어났을 때 두 방식이 각각 어떻게 달라지는지 떠올려보세요. 그리고 반대로, 스트림이 항상 더 나을까요? 흐름이 짧을 때(예: 그냥 전부 출력)나, 코드를 처음 보는 사람 입장에서는 어느 쪽이 더 읽기 편할지도 함께 생각해보세요.

2. 중간 연산은 왜 "미뤄뒀다가" 한꺼번에 돌까?

Step 8 에서 우리는 최종 연산이 없으면 peek 이 한 번도 실행되지 않는 걸 봤어요. 자바는 왜 중간 연산을 곧바로 실행하지 않고 미뤄둘까요? 만약 게시물이 100만 개인데 "좋아요 많은 상위 3개 제목"만 필요하다면, 미뤄뒀다가 한 번에 도는 게 왜 유리할지 생각해보세요. 그리고 원소를 "한 줄씩 끝까지 흘려보내는" 방식이, 단계마다 전체를 다 처리하고 넘어가는 방식과 비교해 어떤 점에서 메모리에 더 이로울지도 떠올려보세요.

3. mapflatMap, 언제 무엇을 골라야 할까?

map 은 원소 하나를 결과 하나로, flatMap 은 원소 하나를 결과 여러 개로 펼쳐요. "게시물 → 제목"은 map, "게시물 → 해시태그 여러 개"는 flatMap 이었죠. 그런데 만약 flatMap 을 써야 할 자리에 map 을 쓰면 결과가 어떤 모양이 될까요(흐름 안에 무엇이 흐르게 될까요)? 반대로 "게시물 → 제목"에 굳이 flatMap 을 쓰면 어떻게 될지도 상상해보세요. 둘을 가르는 기준이 "원소 하나가 결과 몇 개를 만드는가"라는 걸 스스로 정리해보면 좋아요.

✅ 예시 답안정답 보기

오늘 과제는 전부 "흐름을 만들어서 → 중간 연산으로 가공해서 → .toList() 로 받기" 한 박자예요. modern 패키지의 오늘 예제(FilterDemo·MapDemo·SortedDistinctDemo·FlatMapDemo)를 옆에 두고 비교하면서 보면 편해요. 최종 연산은 아직 .toList() 만 쓴다는 점, 잊지 마세요.

과제 예시답안

과제 1 예시답안 — filtermap 으로 인기 작성자 뽑기

핵심 접근

"좋아요 200 이상인 글의 작성자 이름"은 두 단계로 쪼개져요. 먼저 인기 글만 거르고(filter), 남은 글에서 작성자 이름만 뽑아요(map). 거르기와 바꾸기를 순서대로 이어 붙이면 끝이에요. 중복 제거는 아직 안 하니까, 같은 사람이 인기 글을 두 개 썼으면 이름도 두 번 나와요.

예시 구현

Java
// com/instagram/javabasic/modern/solution/day26/PopularAuthors.java
public static List<String> names(List<Post> posts) {
    return posts.stream()
            .filter(post -> post.getLikeCount() >= 200)
            .map(Post::getAuthorName)
            .toList();
}

흐름을 만들고(stream) → 좋아요 200 이상만 통과시키고(filter) → 통과한 글에서 작성자 이름으로 바꿔서(map) → 리스트로 모았어요(toList). 우리말 문장 순서 그대로죠.

채점 포인트

확인할 점 왜 중요한가
filtermap 보다 먼저 오는가 먼저 거르고 나서 바꿔야, 인기 글만 이름이 뽑혀요
조건이 >= 200 인가 "200 이상"은 200 포함이에요. > 200 이면 정확히 200점인 글이 빠져요
map(Post::getAuthorName) 으로 String 흐름이 됐는가 결과 타입이 List<String> 이 되려면 흐름의 원소가 String 으로 바뀌어야 해요
끝에 .toList() 가 붙었는가 최종 연산이 없으면 흐름이 돌지 않아 결과가 안 나와요

흔한 실수

  • filtermap 의 순서를 바꿔 map 먼저 쓰면, 이름(String)으로 바뀐 흐름에서 getLikeCount() 를 부를 수 없어 컴파일이 안 돼요. 거르기가 먼저예요.
  • .toList() 를 깜빡하면 결과가 List<String> 이 아니라 Stream<String> 이라 받는 쪽에서 타입이 안 맞아요.
  • 조건을 > 200 으로 적어 "정확히 200점"인 글을 빠뜨리는 실수가 잦아요. 문제는 "200 이상"이에요.

과제 2 예시답안 — distinctsorted 더하기

핵심 접근

과제 1 의 결과에서 중복을 없애고 가나다순으로 줄 세우면 돼요. 흐름 끝(map 다음)에 distinctsorted 를 이어 붙이기만 하면 끝이에요. 새로 짤 게 거의 없고, 오늘 배운 장치를 하나씩 더 거는 연습이에요.

예시 구현

Java
// com/instagram/javabasic/modern/solution/day26/PopularAuthors.java
public static List<String> distinctSortedNames(List<Post> posts) {
    return posts.stream()
            .filter(post -> post.getLikeCount() >= 200)
            .map(Post::getAuthorName)
            .distinct()
            .sorted()
            .toList();
}

과제 1 의 흐름에 distinct(중복 없애기)와 sorted(가나다순 정렬) 두 장치가 더 걸렸어요. 한 사람이 인기 글을 여러 개 써도 이름은 한 번만, 그리고 가나다순으로 줄을 서요.

채점 포인트

확인할 점 왜 중요한가
distinctmap 다음에 오는가 이름(String)으로 바뀐 뒤에 중복을 없애야, "같은 이름"이 합쳐져요
sorted() 에 아무것도 안 넘겼는가 문자열은 기본 정렬이 사전 순(가나다)이라, 인자 없이도 가나다순이 돼요
distinctsorted 의 순서 둘 다 거쳐야 결과가 같지만, 보통 중복을 먼저 없앤 뒤 정렬하면 줄 세울 개수가 줄어 자연스러워요

흔한 실수

  • distinctmap 보다 먼저 걸면, 게시물(Post)을 기준으로 중복을 따져요. 우리가 원하는 건 "이름"의 중복이라, 이름으로 바꾼 뒤에 distinct 를 걸어야 해요.
  • sorted(Comparator...) 처럼 비교 기준을 억지로 넘기려다 복잡해지는 경우가 있어요. 문자열 가나다순은 그냥 sorted() 면 돼요.

과제 3 예시답안 — flatMap 으로 해시태그 순위 재료 만들기

핵심 접근

게시물 하나에서 해시태그가 여러 개 나오니까, "하나를 여러 개로 펼치는" flatMap 이 필요해요. 게시물마다 본문을 단어로 쪼개(split) 작은 흐름을 만들고(Arrays.stream), flatMap 으로 그 작은 흐름들을 한 줄기로 합쳐요. 그다음 # 으로 시작하는 단어만 남기면 돼요. 중복은 일부러 그대로 둬요 — 같은 해시태그가 몇 번 나왔는지가 다음 시간에 "인기 해시태그 순위"를 매길 재료가 되거든요.

예시 구현

Java
// com/instagram/javabasic/modern/solution/day26/HashtagCollector.java
public static List<String> allTags(List<Post> posts) {
    return posts.stream()
            .flatMap(post -> Arrays.stream(post.getContent().split(" ")))
            .filter(word -> word.startsWith("#"))
            .toList();
}

post.getContent().split(" ") 로 본문을 단어 배열로 자르고, Arrays.stream(...) 으로 흐름을 만들어, flatMap 으로 게시물별 작은 흐름들을 하나로 합쳤어요. 그 뒤 startsWith("#") 로 해시태그만 남겼고요.

채점 포인트

확인할 점 왜 중요한가
map 이 아니라 flatMap 을 썼는가 map 을 쓰면 흐름 안에 "단어 배열"이 흘러 중첩이 생겨요. flatMap 이라야 단어 하나하나로 펼쳐져요
Arrays.stream(...) 으로 배열을 흐름으로 바꿨는가 배열에는 .stream() 이 없어서 Arrays.stream() 으로 감싸야 해요
startsWith("#") 로 해시태그만 걸렀는가 "오늘", "카페" 같은 일반 단어까지 다 들어오면 안 돼요
중복을 그대로 뒀는가 과제 의도가 "중복 포함"이에요. distinct 를 넣으면 순위 재료가 사라져요

흔한 실수

  • flatMap 자리에 map 을 쓰면 결과가 List<String[]>(배열의 리스트)처럼 돼서 타입이 안 맞아요. "하나 → 여러 개"는 flatMap 이에요.
  • split(" ") 의 결과를 그냥 흐름에 넣으려다 막혀요. 배열은 Arrays.stream() 으로 한 번 감싸야 흐름이 돼요.
  • 습관적으로 distinct 를 붙여 중복을 없애는 경우가 있는데, 이 과제는 "중복 포함"이 핵심이에요.

생각해볼 주제 예시답안

생각해볼 주제 1 예시답안 — for 도 되는데, 굳이 스트림으로 바꾸면 뭐가 좋아질까?

[문제 상황 요약]

같은 "공개 글 거르기"를 for 와 스트림 두 방식으로 짜봤고 결과는 똑같았어요. 그렇다면 스트림으로 바꾸는 이유가 뭔지, 그리고 스트림이 항상 더 나은지를 따져보는 주제예요.

[튜터의 가이드 및 해설]

핵심은 "조건이 늘어날 때"예요. for 방식은 조건이 하나 늘 때마다 if 가 깊어지고, 정렬이나 변환이 끼면 중간 리스트를 새로 만들어 또 for 를 돌려야 해요. "공개이면서 인기인 글을, 좋아요 순으로, 제목만"을 for 로 짜면 거르기 for, 정렬 한 번, 제목 뽑기 for 가 줄줄이 생기고 중간 리스트도 여러 개 나와요. 스트림은 이걸 .filter().filter().sorted().map().toList() 한 흐름에 담고, 위에서 아래로 읽으면 "무엇을 원하는지"가 그대로 읽혀요. "어떻게 도는지(인덱스·중간 리스트)"가 사라지니 실수할 자리도 줄고요.

그렇다고 스트림이 항상 정답은 아니에요. 흐름이 아주 단순할 때(예: 리스트를 그냥 전부 출력) for 가 더 직관적일 수 있어요. 또 비전공 동료가 많은 팀에서는 for 가 더 빨리 읽히기도 하고요. 디버깅할 때도 for 안에 중단점을 찍고 한 줄씩 따라가는 게 더 쉬울 때가 있어요. 그래서 "조건·가공이 여러 개로 길어지면 스트림, 단순하면 for" 정도로 감을 잡으면 좋아요.

🎯 면접관을 홀리는 핵심 멘트

"스트림의 가치는 짧아지는 것보다 '무엇을 원하는지'가 코드에 그대로 드러난다는 데 있다고 봐요. 거르고·바꾸고·정렬하는 가공이 여러 단계로 길어질수록 for 는 중간 리스트와 인덱스 관리로 복잡해지는데, 스트림은 그 의도를 한 흐름으로 선언해요. 다만 단순 반복이나 디버깅 편의가 중요한 자리에선 for 가 더 나을 수 있어서, 길이와 가독성을 보고 고릅니다."


생각해볼 주제 2 예시답안 — 중간 연산은 왜 "미뤄뒀다가" 한꺼번에 돌까?

[문제 상황 요약]

최종 연산이 없으면 peek 이 한 번도 실행되지 않는 걸 봤어요. 자바가 중간 연산을 곧바로 실행하지 않고 미뤄두는(지연 평가) 이유, 그리고 "한 줄씩 끝까지 흘려보내는" 방식이 메모리에 왜 이로운지를 생각하는 주제예요.

[튜터의 가이드 및 해설]

미뤄두면 "필요한 만큼만" 일할 수 있어요. 게시물이 100만 개인데 "좋아요 많은 상위 3개 제목"만 필요하다고 해볼게요. 중간 연산이 곧바로 실행됐다면, 100만 개를 전부 거르고 전부 정렬한 다음에야 3개를 자르겠죠. 하지만 지연 평가 덕분에 자바는 최종 연산이 무엇을 원하는지 보고 "딱 필요한 만큼만" 흐르게 조절할 수 있어요. 특히 limit 같은 장치가 끼면, 앞에서 필요한 개수가 채워지는 순간 흐름을 멈춰버리기도 해요.

메모리 쪽 이로움도 커요. 스트림은 원소를 한 줄씩 끝까지 흘려보내요(Step 8 의 "들어옴 → 통과 → 다음 원소" 순서 기억하시죠?). 만약 단계마다 전체를 다 처리하고 넘어간다면, 단계와 단계 사이에 "중간 결과 리스트"를 통째로 메모리에 들고 있어야 해요. 거른 100만 개, 바꾼 100만 개… 이렇게요. 한 줄씩 흘리면 그 큰 중간 리스트를 만들 필요가 없어요. 원소 하나가 입구부터 출구까지 통과한 뒤 다음 원소가 들어오니까요. 그래서 데이터가 클수록 지연 평가의 이득이 커져요.

🎯 면접관을 홀리는 핵심 멘트

"지연 평가의 핵심은 '필요한 만큼만 일한다'예요. 중간 연산은 쌓아두기만 하고 최종 연산이 흐름을 당겨갈 때 실행되는데, 덕분에 limit 같은 게 끼면 조기에 멈출 수 있고, 단계마다 전체 중간 리스트를 메모리에 들고 있지 않아도 돼요. 원소를 하나씩 끝까지 흘려보내는 구조라, 데이터가 클수록 메모리와 속도 양쪽에서 유리합니다."


생각해볼 주제 3 예시답안 — mapflatMap, 언제 무엇을 골라야 할까?

[문제 상황 요약]

map 은 원소 하나를 결과 하나로, flatMap 은 원소 하나를 결과 여러 개로 펼쳐요. 둘을 잘못 바꿔 쓰면 결과가 어떤 모양이 되는지, 그래서 무엇을 기준으로 고르는지를 정리하는 주제예요.

[튜터의 가이드 및 해설]

가르는 기준은 딱 하나예요. "원소 하나가 결과를 몇 개 만드는가." 게시물 하나에서 제목 하나가 나오면 map, 게시물 하나에서 해시태그 여러 개가 나오면 flatMap 이에요.

잘못 쓰면 어떻게 될까요? 해시태그처럼 "하나 → 여러 개"인데 map 을 쓰면, 게시물 하나가 "단어 배열"로 바뀌어요. 그래서 흐름 안에 배열이 흐르는, 중첩된 모양(Stream<String[]>)이 돼요. .toList() 로 받으면 List<String[]>(배열의 리스트)이라, 우리가 원한 "해시태그 한 줄 목록"이 아니에요. 반대로 "게시물 → 제목"처럼 "하나 → 하나"인데 굳이 flatMap 을 쓰면, 제목 하나를 또 흐름으로 감싸야 해서 코드가 공연히 복잡해져요(제목 하나를 Stream.of(제목) 같은 걸로 또 감싸야 하니까요).

그래서 실무에서는 "결과가 컬렉션/배열이 되어 중첩이 생기느냐"를 신호로 봐요. map 을 썼는데 결과 타입에 List<List<...>>Stream<String[]> 처럼 껍데기가 한 겹 더 끼면, "아, 여긴 flatMap 자리구나" 하고 바꿔주면 돼요.

🎯 면접관을 홀리는 핵심 멘트

"mapflatMap 은 '원소 하나가 결과를 몇 개 만드느냐'로 갈라요. 하나면 map, 여러 개면 flatMap 이죠. 하나→여러 개인데 map 을 쓰면 Stream<String[]> 처럼 중첩이 생기는데, 그 한 겹의 껍데기가 바로 flatMap 으로 바꿔야 한다는 신호예요. 저는 결과 타입에 컬렉션이 중첩되면 flatMap 자리로 판단합니다."

전체 목록 자바 기초

더 배우려면

실무 프로젝트까지 가고 싶다면

팀스파르타 백엔드 부트캠프에서 인스타그램 클론을 풀스택으로 완성합니다.