Day 27 — Stream API (2): 흐름의 결과를 모으다
목차 21
지난 시간, 우리는 컬렉션을 "흐름"으로 다루는 법을 배웠어요. .stream() 으로 물줄기를 트고, 그 위에 filter·map·sorted 같은 중간 연산을 걸어 데이터를 가공했죠. 그런데 끝마다 똑같이 .toList() 만 붙였어요. "공개 글만 거르기"는 됐지만 "공개 글이 몇 개야?", "좋아요 총합은?", "작성자별로 묶어줘" 같은 건 아직 못 했고요.
그리고 한 가지 더 짚어둔 게 있었죠. 중간 연산은 곧바로 실행되지 않고 쌓아두기만 하다가, 흐름 끝에 최종 연산이 붙는 순간 비로소 데이터가 흐른다고요. .toList() 가 그 방아쇠였어요. 오늘은 바로 그 방아쇠 자리에 들어갈 다른 최종 연산들을 배워요.
개수를 세고(count), 조건을 만족하는 게 있는지 묻고(anyMatch), 모든 값을 하나로 짜내고(reduce), 무엇보다 작성자별·상태별로 데이터를 "그룹으로 묶는" 강력한 도구(collect 와 Collectors)까지요. 지난 시간 흐름을 만들고 가공하는 법을 손에 익혔다면, 오늘은 그 흐름의 결과를 원하는 모양으로 "모으는" 법을 익히는 거예요. 시작해볼게요!
🎯 학습 목표
- 최종 연산이 무엇인지 이해하고,
collect·count·forEach로 흐름을 결과로 바꿀 수 있어요. anyMatch·allMatch·noneMatch로 흐름 전체를 조건으로 판단할 수 있어요.reduce로 흐름의 모든 원소를 값 하나로 축약할 수 있어요.Collectors의 핵심 도구(groupingBy·counting·toSet·toMap·joining·partitioningBy)로 결과를 Map·Set·문자열 등 원하는 모양으로 모을 수 있어요.findFirst·findAny로 흐름에서 원소 하나를 찾고, "없을 수도 있는 값"을 안전하게 다루는 첫걸음을 떼요.- 병렬 스트림(
.parallel())이 무엇이고 언제 조심해야 하는지 감을 잡아요.
오늘의 로드맵
- Step 1 — 최종 연산이란:
collect(toList())로 방아쇠를 당기다. - Step 2 —
forEach와count: 흐름을 출력하고 개수를 세기. - Step 3 —
anyMatch·allMatch·noneMatch: 흐름을 조건으로 판단하기. - Step 4 —
reduce: 흐름을 값 하나로 짜내기. - Step 5 —
collect&Collectors(1):groupingBy로 묶기. - Step 6 —
collect&Collectors(2):toSet·toMap·joining. - Step 7 —
collect&Collectors(3):partitioningBy로 양분하기. - Step 8 —
findFirst와findAny: 흐름에서 하나 집어오기. - Step 9 — 병렬 스트림과 종합 실습.
Step 1: 최종 연산이란: collect(toList()) 로 방아쇠를 당기다
지난 시간 마지막에 본 장면을 다시 떠올려볼게요. 중간 연산(filter·map)만 잔뜩 적어둬도, 흐름 끝에 최종 연산이 없으면 컴퓨터는 아무 일도 안 했어요. 데이터를 흐르게 하는 방아쇠가 바로 최종 연산이에요.
오늘 우리가 줄곧 써온 .toList() 도 사실은 최종 연산의 하나였어요. 그런데 .toList() 는 결과를 "List 로" 모으는 한 가지 방식일 뿐이에요. 더 일반적인 도구가 collect(콜렉트, 모으기) 예요. collect(Collectors.toList()) 라고 쓰면 .toList() 와 똑같이 List 로 모아요.
// com/instagram/javabasic/modern/FinalOperationDemo.java
public static List<String> publicContents(List<Post> posts) {
return posts.stream()
.filter(post -> post.getStatus() == PostStatus.PUBLIC)
.map(Post::getContent)
.collect(Collectors.toList());
}
흐름을 만들고 → 공개 글만 거르고 → 내용만 뽑아서 → collect(Collectors.toList()) 로 List 에 담았어요. 지난 시간 .toList() 자리에 collect(Collectors.toList()) 가 들어온 것뿐이에요. 결과는 똑같아요.
[ 중간 연산만 ] posts.stream().filter(...).map(...)
└ 장치만 걸어둠. 데이터는 아직 안 흐름 (결과 없음)
[ 최종 연산 붙음 ] posts.stream().filter(...).map(...).collect(toList())
└ collect 가 방아쇠 ──▶ 데이터가 흐르고 List 가 나옴
"그럼 .toList() 두고 왜 굳이 collect 를 배워요?" 싶을 거예요. 이유는 collect 가 Collectors 라는 도구 상자와 짝을 이루면, List 말고도 정말 다양한 모양으로 모을 수 있기 때문이에요. 개수만큼 세서 Map 으로, 중복 없이 Set 으로, 문자열 하나로 이어 붙이기까지요. 그 도구 상자를 Step 5 부터 본격적으로 열어볼 거예요. 지금은 "최종 연산 collect 가 방아쇠다" 한 가지만 잡고 가요.
💡 오늘의 출발점: 흐름은 최종 연산이 붙어야 비로소 돌아요. 지난 시간 내내 쓴
.toList()가 그 방아쇠의 하나였고, 오늘은 그 자리에collect·count·reduce같은 다른 방아쇠들을 끼워 넣어요.
Step 2: forEach 와 count: 흐름을 출력하고 개수를 세기
방아쇠를 두 개 더 배워볼게요. 아주 자주 쓰는 forEach(각각에 대해) 와 count(개수) 예요.
count 부터 볼게요. 흐름에 원소가 몇 개 남았는지 세서 숫자 하나를 돌려줘요. 결과 타입은 long(아주 큰 정수까지 담는 정수 타입) 이에요.
// com/instagram/javabasic/modern/FinalOperationDemo.java
public static long countPopular(List<Post> posts) {
return posts.stream()
.filter(post -> post.getLikeCount() >= 100)
.count();
}
인기 글(좋아요 100 이상)만 거른 다음 .count() 로 개수를 셌어요. 여기서 지난 시간 복선 하나가 회수돼요. 지난 시간 과제에서 우리는 해시태그를 filter 로 남기기만 하고 "몇 개인지"는 못 셌죠. 이제 흐름 끝에 .count() 만 붙이면 개수가 한 줄로 나와요.
다음은 forEach 예요. 흐르는 원소 하나하나에 어떤 동작을 실행해요. 주로 화면에 출력하거나 기록을 남기는 "부작용(side effect)" 에 써요. 여기선 공개 글을 한 줄씩 만들어 담아볼게요.
public static List<String> feedLines(List<Post> posts) {
List<String> lines = new ArrayList<>();
posts.stream()
.filter(post -> post.getStatus() == PostStatus.PUBLIC)
.forEach(post -> lines.add(post.getAuthorName() + ": " + post.getContent()));
return lines;
}
공개 글만 거른 뒤, 각 글마다 "작성자: 내용" 형태의 줄을 만들어 바깥 리스트에 담았어요. forEach(동작) 은 "흐르는 원소마다 이 동작을 해라"는 뜻이에요. 화면에 출력한다면 forEach(post -> System.out.println(...)) 처럼 쓰고요.
count 와 forEach 의 동작은 코드베이스 FinalOperationDemoTest 에서 확인해 뒀어요.
🙋 학생 질문 — "튜터님, forEach 는 지난 시간 enhanced for 랑 뭐가 달라요?"
거의 같은 일을 해요. for (Post post : posts) 로 도는 것과 posts.stream().forEach(post -> ...) 는 둘 다 "원소를 하나씩 처리"하죠. 차이는 두 가지예요. 첫째, forEach 는 흐름 끝에 붙는 최종 연산이라 앞에 filter·map 같은 가공을 자연스럽게 이어 붙일 수 있어요. 둘째, forEach 안에서는 바깥 변수를 바꾸기가 까다로워요(람다의 규칙 때문이에요). 그래서 "단순 출력·기록"엔 forEach 가 깔끔하지만, 복잡하게 값을 쌓아야 하면 차라리 for 가 편하거나, 오늘 배울 reduce·collect 를 쓰는 게 더 좋아요.
Step 3: anyMatch·allMatch·noneMatch: 흐름을 조건으로 판단하기
이번엔 흐름 전체를 보고 "예/아니오" 하나로 답하는 최종 연산이에요. 세 개가 한 묶음으로 다녀요. 이름만 봐도 뜻이 보여요.
anyMatch(조건)— 하나라도 조건을 만족하면trueallMatch(조건)— 전부 조건을 만족하면truenoneMatch(조건)— 아무도 조건을 만족하지 않으면true
세 개 다 조건(람다)을 받아서 true/false 를 돌려줘요. 인스타그램에서 흔한 질문들을 그대로 코드로 옮겨볼게요.
// com/instagram/javabasic/modern/LogicalMatchDemo.java
public static boolean hasInfluencer(List<Member> members) {
return members.stream()
.anyMatch(member -> member.getFollowers() >= 1000);
}
public static boolean allPublic(List<Post> posts) {
return posts.stream()
.allMatch(post -> post.getStatus() == PostStatus.PUBLIC);
}
public static boolean noArchived(List<Post> posts) {
return posts.stream()
.noneMatch(post -> post.getStatus() == PostStatus.ARCHIVED);
}
hasInfluencer 는 "팔로워 1000명 이상인 회원이 한 명이라도 있나?", allPublic 은 "모든 글이 공개인가?", noArchived 는 "보관된 글이 하나도 없나?" 를 물어요. 셋 다 우리말 질문 그대로죠. 이 동작은 코드베이스 LogicalMatchDemoTest 에서 확인해 뒀어요.
💡
count와의 차이: "개수가 필요해"면count, "있냐 없냐만 필요해"면anyMatch류예요. 그리고anyMatch류는 답이 정해지면 흐름을 일찍 멈춰요. "하나라도 있나?"는 첫 번째를 찾는 순간 더 볼 것도 없이true를 돌려주거든요. 지난 시간 배운 지연 평가 덕분에 가능한 똑똑함이에요.
Step 4: reduce: 흐름을 값 하나로 짜내기
지금까지는 흐름을 List 나 개수, 참/거짓으로 바꿨어요. 이번엔 흐름의 모든 원소를 "하나의 값"으로 짜내는 reduce(리듀스, 축약) 예요. 좋아요를 전부 더한 총합, 팔로워를 전부 더한 합계 같은 거요.
reduce 는 두 가지를 받아요. 시작값(초기값) 하나와, "지금까지 모은 값에 다음 원소를 어떻게 합칠지"를 적은 람다예요. 모양은 reduce(초기값, (누적값, 다음원소) -> 새 누적값) 이에요.
// com/instagram/javabasic/modern/ReduceDemo.java
public static int totalLikes(List<Post> posts) {
return posts.stream()
.map(Post::getLikeCount)
.reduce(0, Integer::sum);
}
먼저 map(Post::getLikeCount) 으로 흐름을 "좋아요 숫자"의 흐름으로 바꾸고, reduce(0, Integer::sum) 으로 0부터 시작해 하나씩 더했어요. Integer::sum 은 "두 숫자를 더해라"라는 메서드 참조예요. 좋아요가 250, 120, 40 이면 0 → 250 → 370 → 410 순으로 쌓여 410 이 나와요.
reduce(0, 더하기) 가 좋아요 [250, 120, 40] 을 짜내는 과정
초기값 0
│ + 250 ─▶ 250
│ + 120 ─▶ 370
│ + 40 ─▶ 410 ← 최종 결과 하나
reduce 는 더하기만 하는 게 아니에요. 누적 자리에 "더 큰 값"을 남기게 하면 최댓값을 구해요. 합치는 람다만 바꾸면 돼요.
public static int mostLikes(List<Post> posts) {
return posts.stream()
.map(Post::getLikeCount)
.reduce(0, Integer::max);
}
Integer::max 는 "두 숫자 중 큰 것을 남겨라"예요. 그래서 흐르는 좋아요 중 가장 큰 값이 나와요. 초기값 0 이 있으니 결과는 늘 int 하나로 깔끔하게 나오고, 빈 흐름이면 초기값 0 이 그대로 답이 돼요. 이 동작들은 코드베이스 ReduceDemoTest 에서 확인해 뒀어요.
💡 초기값을 꼭 주세요:
reduce에 초기값 없이 람다만 넘기는 방식도 있어요. 그런데 그러면 "흐름이 비었을 때 돌려줄 값이 없는" 상황이 생겨서, 자바가 "값이 있을 수도 없을 수도 있는 상자"를 돌려줘요. 그 상자를 다루는 법은 다음 시간 몫이라, 오늘은reduce(0, ...)처럼 초기값을 주는 안전한 방식만 써요.
Step 5: collect & Collectors (1): groupingBy 로 묶기
이제 오늘의 진짜 주인공, collect 의 도구 상자 Collectors 를 열어봐요. 그중에서도 가장 강력한 groupingBy(그룹으로 묶기) 부터요.
groupingBy 는 "무엇을 기준으로 묶을지"를 받아서, 흐름을 Map 으로 분류해요. 게시물을 작성자 이름으로 묶으면, "작성자 → 그 사람이 쓴 글 목록" 이라는 Map 이 나와요.
// com/instagram/javabasic/modern/CollectorsDemo.java
public static Map<String, List<Post>> postsByAuthor(List<Post> posts) {
return posts.stream()
.collect(Collectors.groupingBy(Post::getAuthorName));
}
groupingBy(Post::getAuthorName) 은 "작성자 이름이 같은 것끼리 묶어라"예요. 결과 Map 의 키는 작성자 이름(String), 값은 그 작성자의 글 목록(List
게시물 흐름 ──groupingBy(작성자 이름)──▶ Map<작성자, 글 목록>
"minji" ─▶ [ 카페 글, 산책 글 ]
"jaehoon" ─▶ [ 코딩 글 ]
└ 같은 키끼리 한 바구니에 ┘
여기에 두 번째 인자를 더하면, 묶은 것을 "어떻게 집계할지"까지 정할 수 있어요. 글 목록 대신 "개수"가 필요하면 Collectors.counting() 을 같이 넘겨요. 그러면 지난 시간 우리가 남겨둔 숙제가 드디어 풀려요.
지난 시간 과제에서 우리는 해시태그를 중복 포함해서 한 리스트로 모았어요. "같은 해시태그가 몇 번 나왔는지는 다음 시간에 개수 세기로 순위를 매길 재료"라고 했었죠. 그 순간이 지금이에요.
public static Map<String, Long> hashtagFrequency(List<Post> posts) {
return posts.stream()
.flatMap(post -> Arrays.stream(post.getContent().split(" ")))
.filter(word -> word.startsWith("#"))
.collect(Collectors.groupingBy(Function.identity(), Collectors.counting()));
}
앞부분은 지난 시간 그대로예요. flatMap 으로 모든 해시태그를 한 줄기로 펼치고 # 으로 시작하는 단어만 남겼죠. 달라진 건 끝이에요. groupingBy(Function.identity(), Collectors.counting()) 으로 "같은 해시태그끼리 묶어서, 각자 몇 번 나왔는지 세라"고 했어요.
Function.identity() 는 "원소를 그대로 키로 써라"라는 뜻이에요. 해시태그 문자열 자체가 분류 기준이 되는 거죠. 결과는 "#일상 → 3, #카페 → 1, #개발 → 1" 같은 Map 이에요. 이제 어떤 해시태그가 인기인지 한눈에 보여요. 이 동작은 코드베이스 CollectorsDemoTest 에서 확인해 뒀어요.
💡
groupingBy한 줄 정리: 혼자 쓰면 "키 → 그 그룹의 원소 목록",counting()과 함께 쓰면 "키 → 그 그룹의 개수"예요. 묶기만 할지, 묶어서 세기까지 할지를 두 번째 인자로 고르는 거예요.
Step 6: collect & Collectors (2): toSet·toMap·joining
Collectors 도구 상자에서 자주 쓰는 세 가지를 더 꺼내요. toSet·toMap·joining 이에요.
먼저 toSet 이에요. toList 와 거의 같은데, List 가 아니라 Set(집합) 으로 모아요. Set 은 중복을 자동으로 걸러주죠. 그래서 "고유한 값 목록"이 필요할 때 편해요.
// com/instagram/javabasic/modern/CollectorsDemo.java
public static Set<String> distinctAuthors(List<Post> posts) {
return posts.stream()
.map(Post::getAuthorName)
.collect(Collectors.toSet());
}
작성자 이름을 뽑아 toSet() 으로 모았어요. 한 사람이 글을 여러 개 썼어도 이름은 한 번만 담겨요. 지난 시간 map 다음에 distinct 를 걸어 중복을 없앤 것과 결과가 비슷한데, 이번엔 모으는 단계에서 Set 이 알아서 걸러주는 거예요.
다음은 toMap 이에요. 흐름의 각 원소를 "키와 값" 한 쌍으로 만들어 Map 에 담아요. 키를 뽑는 람다와 값을 뽑는 람다, 두 개를 넘겨요. 회원 이름을 키로, 팔로워 수를 값으로 하는 Map 을 만들어볼게요.
public static Map<String, Integer> followerMap(List<Member> members) {
return members.stream()
.collect(Collectors.toMap(Member::getUsername, Member::getFollowers));
}
toMap(Member::getUsername, Member::getFollowers) 은 "이름을 키로, 팔로워 수를 값으로 담아라"예요. 결과는 "minji → 8500, jaehoon → 1240" 같은 Map 이에요. 이름만 알면 팔로워 수를 바로 찾을 수 있게 됐죠.
마지막은 joining 이에요. 흐름의 문자열들을 구분자로 이어 한 문장으로 만들어요. 모든 닉네임을 쉼표로 연결해볼게요.
public static String usernamesJoined(List<Member> members) {
return members.stream()
.map(Member::getUsername)
.collect(Collectors.joining(", "));
}
이름을 뽑아 joining(", ") 으로 이으면 "minji, jaehoon" 같은 문자열 하나가 나와요. 화면에 "참여자: minji, jaehoon" 처럼 보여줄 때 딱이에요. 세 도구의 동작은 모두 코드베이스 CollectorsDemoTest 에서 확인해 뒀어요.
Step 7: collect & Collectors (3): partitioningBy 로 양분하기
Collectors 의 마지막 도구는 partitioningBy(둘로 가르기) 예요. groupingBy 와 닮았는데, 분류 기준이 "참/거짓" 하나라서 흐름을 딱 두 덩어리로 나눠요.
"공개 글이냐?"를 기준으로 게시물을 갈라볼게요.
// com/instagram/javabasic/modern/CollectorsDemo.java
public static Map<Boolean, List<Post>> partitionByPublic(List<Post> posts) {
return posts.stream()
.collect(Collectors.partitioningBy(post -> post.getStatus() == PostStatus.PUBLIC));
}
partitioningBy(조건) 의 결과는 키가 true/false 두 개뿐인 Map 이에요. true 쪽에는 조건을 만족한 글(공개 글), false 쪽에는 나머지(비공개·보관 글)가 담겨요.
게시물 흐름 ──partitioningBy(공개냐?)──▶ Map<Boolean, 글 목록>
true ─▶ [ 공개 글, 공개 글 ] ← 조건 만족
false ─▶ [ 비공개 글 ] ← 나머지 전부
└ 바구니는 늘 딱 두 개 ┘
groupingBy 로도 비슷하게 할 수 있지만, "조건 하나로 둘로만 나눌 때"는 partitioningBy 가 더 잘 맞아요. 무엇보다 true/false 바구니가 항상 둘 다 생겨요. 조건을 만족하는 게 하나도 없어도 true 바구니가 빈 채로 존재해서, 꺼낼 때 "키가 없네?" 하고 당황할 일이 없어요. 이 동작은 코드베이스 CollectorsDemoTest 에서 확인해 뒀어요.
💡
groupingByvspartitioningBy: 분류 기준이 "여러 갈래"(작성자·상태처럼)면groupingBy, "예/아니오 두 갈래"면partitioningBy예요. 둘 다 Map 을 돌려주지만,partitioningBy는 키가true/false로 고정이라는 점이 달라요.
Step 8: findFirst 와 findAny: 흐름에서 하나 집어오기
지금까지는 흐름 전체를 List·Map·숫자로 바꿨어요. 이번엔 흐름에서 "딱 하나"만 집어오는 최종 연산이에요. findFirst(맨 앞 찾기) 와 findAny(아무거나 찾기) 예요.
그런데 여기서 처음 만나는 게 하나 있어요. 이 둘은 결과를 그냥 돌려주지 않고, "값이 있을 수도, 없을 수도 있는 상자"에 담아 돌려줘요. 흐름이 비었거나 조건을 만족하는 게 없으면 "빈 상자"가 나오거든요. 이 상자의 정식 이름과 제대로 다루는 법은 다음 시간에 배워요. 오늘은 상자를 여는 가장 간단한 방법 하나, .orElse(기본값) 만 써요. "상자에 값이 있으면 그 값을, 없으면 기본값을 줘"라는 뜻이에요.
// com/instagram/javabasic/modern/FindDemo.java
public static String firstPrivateContent(List<Post> posts) {
return posts.stream()
.filter(post -> post.getStatus() == PostStatus.PRIVATE)
.map(Post::getContent)
.findFirst()
.orElse("(비공개 글 없음)");
}
비공개 글만 거른 뒤 findFirst() 로 맨 앞 글의 내용을 집었어요. 비공개 글이 하나도 없으면 빈 상자가 나오니까, .orElse("(비공개 글 없음)") 로 그럴 때 보여줄 기본값을 정해뒀어요.
[ 비공개 글이 있을 때 ] findFirst() ─▶ ( "비밀 일기" 들어 있는 상자 ) ─.orElse(...)─▶ "비밀 일기"
[ 비공개 글이 없을 때 ] findFirst() ─▶ ( 빈 상자 ) ─.orElse(...)─▶ "(비공개 글 없음)"
findAny 도 거의 같아요. 차이는 "맨 앞"을 고집하지 않고 "아무거나 하나"를 가져온다는 점이에요. 순서가 중요하지 않을 때, 특히 잠시 뒤에 볼 병렬 스트림에서 더 빨라요.
public static String anyPopularContent(List<Post> posts) {
return posts.stream()
.filter(post -> post.getLikeCount() >= 100)
.map(Post::getContent)
.findAny()
.orElse("(인기 글 없음)");
}
값을 찾을 때 쓰는 비슷한 도구로 max(최댓값) 도 있어요. "가장 좋아요 많은 글"처럼요. max 도 빈 상자를 돌려줄 수 있어서, .orElse(null) 로 꺼낸 뒤 직접 확인해요.
public static String topLikedContent(List<Post> posts) {
Post top = posts.stream()
.max(Comparator.comparingInt(Post::getLikeCount))
.orElse(null);
return top == null ? "(글 없음)" : top.getContent();
}
max(Comparator.comparingInt(Post::getLikeCount)) 로 좋아요가 가장 많은 글을 찾고, .orElse(null) 로 꺼냈어요. 글이 하나도 없으면 null(값이 없음을 뜻하는 빈 자리) 이 나오니, top == null 인지 확인해서 안전하게 처리했고요. 이 동작들은 코드베이스 FindDemoTest 에서 확인해 뒀어요.
이 null 을 일일이 확인하는 게 조금 번거롭게 느껴지죠? 바로 그 불편함을 깔끔하게 풀어주는 게 다음 시간 주제예요. findFirst·max 가 돌려준 "값이 있을 수도, 없을 수도 있는 상자"를 안전하게 다루는 도구, 그게 다음 시간에 만날 Optional 이에요.
Step 9: 병렬 스트림과 종합 실습
오늘의 마지막이에요. 먼저 가볍게 병렬 스트림을 만나고, 그다음 지난 시간과 오늘 배운 걸 한데 엮어볼게요.
지금까지 우리 흐름은 원소를 한 줄로, 차례차례 처리했어요. 그런데 데이터가 아주 많을 때는, 흐름을 여러 갈래로 나눠 여러 일꾼(스레드) 이 동시에 처리하면 빨라질 수 있어요. 그게 병렬 스트림이에요. .stream() 대신 .parallelStream() 을 부르거나 중간에 .parallel() 을 붙이면 돼요.
// com/instagram/javabasic/modern/ParallelStreamDemo.java
public static int sequentialTotalLikes(List<Post> posts) {
return posts.stream()
.map(Post::getLikeCount)
.reduce(0, Integer::sum);
}
public static int parallelTotalLikes(List<Post> posts) {
return posts.parallelStream()
.map(Post::getLikeCount)
.reduce(0, Integer::sum);
}
두 메서드는 좋아요를 모두 더해 같은 결과를 줘요. 합산은 순서가 상관없는 일이라 여러 갈래로 나눠 더해도 총합이 같거든요. 이 일치는 코드베이스 ParallelStreamDemoTest 에서 확인해 뒀어요.
그런데 병렬 스트림은 공짜가 아니에요. 조심할 점이 있어요.
병렬 스트림, 이럴 때 조심해요
① 순서 보장 안 됨 ─ forEach 로 출력하면 뒤죽박죽 나올 수 있어요
② 작은 데이터엔 손해 ─ 갈래로 나누는 비용이 더 커서 오히려 느려요
③ 공유 변수 위험 ─ 여러 일꾼이 같은 변수를 건드리면 값이 꼬여요
그래서 "기본은 순차 스트림, 병렬은 데이터가 아주 많고 순서가 상관없을 때만 신중하게" 가 원칙이에요. 여러 일꾼이 동시에 일할 때 생기는 더 깊은 이야기는 한참 뒤에 따로 배워요. 오늘은 "이런 게 있고, 함부로 쓰면 손해다" 정도만 알면 충분해요.
이제 오늘의 마무리로, 지난 시간 중간 연산과 오늘 최종 연산을 한 흐름에 엮어볼게요. "좋아요 200 이상인 글을 작성자별로 세서, 가장 많이 쓴 상위 N명의 이름을 쉼표로 이어줘" 라는 요구예요.
// com/instagram/javabasic/modern/StreamCollectComprehensive.java
public static String topAuthorsByPopularPosts(List<Post> posts, int limit) {
Map<String, Long> countByAuthor = posts.stream()
.filter(post -> post.getLikeCount() >= 200)
.collect(Collectors.groupingBy(Post::getAuthorName, Collectors.counting()));
return countByAuthor.entrySet().stream()
.sorted((a, b) -> Long.compare(b.getValue(), a.getValue()))
.limit(limit)
.map(Map.Entry::getKey)
.collect(Collectors.joining(", "));
}
두 단계로 읽으면 쉬워요. 1단계에서 좋아요 200 이상 글만 거른 뒤(filter, 지난 시간) 작성자별로 묶어 개수를 세요(groupingBy + counting, 오늘). 그러면 "작성자 → 인기 글 수" Map 이 나와요.
2단계에서 그 Map 을 다시 흐름으로 펼쳐, 글 수가 많은 순으로 줄 세우고(sorted) 앞에서 limit 명만 잘라(limit) 이름만 뽑아(map) 쉼표로 이었어요(joining). 지난 시간 배운 중간 연산들과 오늘 배운 최종 연산이 한 흐름 안에서 자연스럽게 만나죠. 이 동작은 코드베이스 StreamCollectComprehensiveTest 에서 확인해 뒀어요.
Map 을 다시 흐름으로 펼칠 때 entrySet().stream() 을 썼어요. Map 의 "키-값 한 쌍"들을 흐름에 흘려보내는 거예요. 그래서 a.getValue() 로 글 수를 비교해 정렬하고, Map.Entry::getKey 로 작성자 이름을 뽑을 수 있었어요.
마무리
오늘 우리는 흐름의 결과를 원하는 모양으로 "모으는" 최종 연산들을 배웠어요. 핵심만 다시 짚을게요.
- 결과로 바꾸기 —
collect(toList())로 List,count로 개수,forEach로 출력·기록. - 조건으로 판단 —
anyMatch(하나라도) ·allMatch(전부) ·noneMatch(아무도 안). - 값 하나로 짜내기 —
reduce(초기값, 합치기)로 합계·최댓값. 초기값을 꼭 주기. - 모양 바꿔 모으기 —
Collectors의groupingBy(여러 갈래로 묶기) ·counting(세기) ·toSet(중복 없이) ·toMap(키-값으로) ·joining(문자열로) ·partitioningBy(참/거짓 두 갈래). - 하나 찾기 —
findFirst·findAny·max. 결과가 "있을 수도 없을 수도 있는 상자"라.orElse(기본값)로 안전하게.
지난 시간 흐름을 만들고 가공하는 법을 배웠다면, 오늘 그 흐름을 통계와 그룹으로 "모으는" 법까지 갖췄어요. 이제 인스타그램의 웬만한 데이터 요구는 스트림 한 흐름으로 풀 수 있어요.
다음 시간엔 — 없을 수도 있는 값을 안전하게
오늘 findFirst 와 max 에서 "값이 있을 수도, 없을 수도 있는 상자"를 만났죠. 우리는 .orElse(기본값) 로 급한 대로 열었지만, null 을 일일이 확인하는 게 살짝 번거로웠어요. 다음 시간엔 바로 그 상자의 정식 이름 Optional(옵셔널) 을 제대로 배워요. "값이 없을 수도 있다"를 코드로 솔직하게 드러내서, null 때문에 프로그램이 멈추는 사고를 막는 안전장치예요. 오늘 흘려둔 그 상자가 다음 시간 출발점이에요. 정말 수고 많으셨어요!
과제
오늘 배운 최종 연산과 Collectors 를 직접 손에 익히는 과제예요. modern 패키지의 오늘 예제(CollectorsDemo·ReduceDemo·FindDemo)를 옆에 두고 비교하면서 풀면 편해요.
과제 1: [기초] groupingBy 와 counting 으로 상태별 글 개수 세기
게시물 리스트(List<Post>)를 받아, 게시물 상태(PostStatus)별로 글이 몇 개인지 세는 메서드를 만들어보세요.
- 새 클래스
StatusReport를 만들고,static Map<PostStatus, Long> countByStatus(List<Post> posts)를 둬요. - 흐름을 만들어
collect(Collectors.groupingBy(Post::getStatus, Collectors.counting()))로 상태별 개수를 세요. - 결과 Map 의 키는
PostStatus(PUBLIC·PRIVATE·ARCHIVED), 값은 그 상태의 글 개수(Long) 예요. - Step 5 의
hashtagFrequency가 좋은 힌트예요. 묶는 기준만 "해시태그"에서 "상태"로 바뀐 거예요.
과제 2: [응용] sorted·limit·map·joining 으로 인플루언서 명단 만들기
회원 리스트(List<Member>)와 인원수 limit 를 받아, 팔로워가 많은 순서로 상위 limit 명의 이름을 " > " 로 이은 한 줄 문자열을 돌려주세요. (예: "minji > jaehoon")
- 새 클래스
InfluencerBoard를 만들고,static String topFollowersLine(List<Member> members, int limit)를 둬요. - 지난 시간 중간 연산(
sorted로 팔로워 내림차순,limit로 앞 N명)에 오늘 배운map·joining을 이어 붙이면 돼요. Comparator.comparingInt(Member::getFollowers).reversed()로 많은 순 정렬,Collectors.joining(" > ")로 연결이에요.
과제 3: [심화] 드디어 인기 해시태그 1위 뽑기
지난 시간 우리는 해시태그를 중복 포함해서 모으기만 했어요. 오늘 groupingBy + counting 으로 빈도까지 셀 수 있게 됐죠. 이제 한 걸음 더 나아가 가장 많이 등장한 해시태그 한 개를 뽑아보세요.
- 새 클래스
HashtagRanking을 만들고, 두 메서드를 둬요.static Map<String, Long> frequency(List<Post> posts)— Step 5 의hashtagFrequency처럼 해시태그별 등장 횟수 Map 을 만들어요.static String mostPopularTag(List<Post> posts)— 그 Map 에서 가장 횟수가 큰 해시태그를 돌려줘요.
- 1위를 뽑을 땐
frequency(posts).entrySet().stream()으로 Map 을 흐름으로 펼친 뒤,max(Comparator.comparingLong(Map.Entry::getValue))로 가장 큰 것을 찾아요. max결과는 "있을 수도 없을 수도 있는 상자"라.orElse(null)로 꺼낸 뒤,null이면"(해시태그 없음)"같은 기본값을 돌려주세요. (이null확인은 다음 시간Optional을 배우면 더 깔끔해져요.)
생각해볼 주제
1. groupingBy 한 줄 vs for 와 Map 직접 쓰기, 무엇이 다를까?
오늘 우리는 groupingBy 한 줄로 "작성자별 글 묶기"를 했어요. 같은 일을 지난 방식으로 한다면, 빈 Map 을 만들고 for 로 게시물을 돌면서 "이 작성자 키가 Map 에 있으면 목록에 추가, 없으면 새 목록을 만들어 넣기"를 직접 해야 해요. 두 방식이 코드 길이와 읽기 쉬움에서 어떻게 다를지 떠올려보세요. 그리고 groupingBy 가 안 보이게 대신 해주는 일(키가 처음 나올 때 빈 목록을 자동으로 만들어주는 것)이 무엇인지, 그게 왜 실수를 줄여주는지도 생각해보세요.
2. 병렬 스트림은 언제 득이고 언제 독일까?
Step 9 에서 우리는 .parallelStream() 으로 좋아요 합계를 구했어요. 결과는 순차 스트림과 같았죠. 그렇다면 항상 병렬이 더 나을까요? 게시물이 딱 3개일 때와 300만 개일 때, 병렬로 나누는 게 각각 득일지 독일지 따져보세요. 그리고 "순서가 중요한 작업"(예: 앞에서부터 순서대로 화면에 출력)에 병렬을 쓰면 왜 곤란해지는지도 떠올려보세요. 빠르게 만들려다 오히려 느려지거나 결과가 뒤섞이는 함정이 어디에 숨어 있을까요?
3. 자바는 왜 null 대신 "값이 있을 수도 없을 수도 있는 상자"를 만들었을까?
오늘 findFirst 와 max 는 값을 그냥 주지 않고 상자에 담아 줬어요. 우리는 .orElse(기본값) 로 열었고요. 만약 이 상자 없이 그냥 값을 돌려주는데 흐름이 비어 있다면, 무엇이 나와야 할까요? 그리고 그 "비어 있음"을 깜빡하고 바로 쓰면 프로그램에 어떤 사고가 날까요? 상자가 "이 값은 비어 있을 수도 있어, 꺼내기 전에 확인해"라고 알려주는 것이, 그냥 null 을 돌려주는 것보다 왜 더 안전한지 생각해보세요. 이 고민이 다음 시간 Optional 의 출발점이에요.
✅ 예시 답안정답 보기
오늘 과제는 전부 "흐름을 만들어서 → 가공하고 → 최종 연산으로 모으기" 한 박자예요. modern 패키지의 오늘 예제(CollectorsDemo·FindDemo)를 옆에 두고 비교하면서 보면 편해요. 특히 과제 3 은 지난 시간에 모아둔 해시태그 목록을 드디어 "순위"로 바꾸는 거라, 두 시간이 이어지는 재미가 있어요.
과제 예시답안
과제 1 예시답안 — groupingBy 와 counting 으로 상태별 글 개수 세기
핵심 접근
"상태별 글 개수"는 두 가지를 합친 일이에요. 먼저 같은 상태끼리 묶고(groupingBy), 각 묶음이 몇 개인지 세요(counting). Step 5 에서 해시태그 빈도를 셀 때 쓴 그 조합 그대로예요. 묶는 기준만 "해시태그"에서 "게시물 상태"로 바뀐 거죠. 분류 기준으로 Post::getStatus 를 넘기면, 키가 PostStatus(PUBLIC·PRIVATE·ARCHIVED) 인 Map 이 나와요.
예시 구현
// com/instagram/javabasic/modern/solution/day27/StatusReport.java
public static Map<PostStatus, Long> countByStatus(List<Post> posts) {
return posts.stream()
.collect(Collectors.groupingBy(Post::getStatus, Collectors.counting()));
}
흐름을 만들어서 → 상태별로 묶고 → 각 묶음을 세서 Map 으로 모았어요. 공개 글이 2개, 비공개가 1개, 보관이 1개라면 결과는 {PUBLIC=2, PRIVATE=1, ARCHIVED=1} 이 돼요.
채점 포인트
| 확인할 점 | 왜 중요한가 |
|---|---|
groupingBy 의 분류 기준이 Post::getStatus 인가 |
묶는 기준이 상태여야 키가 PostStatus 별로 나뉘어요 |
두 번째 인자로 Collectors.counting() 을 넘겼는가 |
안 넘기면 "상태 → 글 목록"이 되고, 넘겨야 "상태 → 개수"가 돼요 |
결과 타입이 Map<PostStatus, Long> 인가 |
counting() 의 결과는 Long 이에요. Integer 로 받으면 타입이 안 맞아요 |
collect 로 모았는가 |
groupingBy 는 collect 안에서 동작하는 도구라, collect(...) 로 감싸야 해요 |
흔한 실수
counting()을 빼먹으면 결과가Map<PostStatus, List<Post>>(상태 → 글 목록)가 돼요. "개수"가 필요하면counting()을 꼭 같이 넘겨요.- 개수를
Integer로 받으려다 막혀요.counting()은 큰 수까지 담으려고Long을 돌려줘요. 받는 타입도Long이어야 해요. groupingBy를collect없이 바로 흐름에 붙이려다 막혀요.groupingBy는 "어떻게 모을지"를 알려주는 도구일 뿐이라, 흐름을 실제로 모으는collect안에 넣어야 해요.
과제 2 예시답안 — sorted·limit·map·joining 으로 인플루언서 명단 만들기
핵심 접근
"팔로워 많은 순 상위 N명을 한 줄로"는 네 단계예요. 팔로워 내림차순으로 줄 세우고(sorted), 앞에서 N명만 자르고(limit), 이름만 뽑고(map), 쉼표 대신 " > " 로 이어요(joining). 앞의 두 개는 지난 시간 중간 연산, 뒤의 두 개는 오늘 배운 도구예요. 지난 시간과 오늘이 한 흐름에서 만나는 좋은 연습이에요.
예시 구현
// com/instagram/javabasic/modern/solution/day27/InfluencerBoard.java
public static String topFollowersLine(List<Member> members, int limit) {
return members.stream()
.sorted(Comparator.comparingInt(Member::getFollowers).reversed())
.limit(limit)
.map(Member::getUsername)
.collect(Collectors.joining(" > "));
}
팔로워 내림차순으로 정렬하고(reversed 로 큰 게 앞으로), limit 으로 앞 N명만 남기고, 이름을 뽑아, joining(" > ") 으로 이었어요. 회원이 seungwoo(320)·minji(8500)·jaehoon(1240) 이고 limit 이 2 면, 결과는 "minji > jaehoon" 이에요.
채점 포인트
| 확인할 점 | 왜 중요한가 |
|---|---|
.reversed() 를 붙였는가 |
안 붙이면 팔로워 적은 순(오름차순)이라 상위가 아니라 하위 N명이 나와요 |
limit 이 sorted 다음에 오는가 |
줄을 먼저 세운 뒤 잘라야 "상위 N명"이에요. 자르고 정렬하면 엉뚱한 N명이 잘려요 |
map(Member::getUsername) 으로 String 흐름이 됐는가 |
joining 은 문자열 흐름에만 쓸 수 있어요. 이름으로 바꾼 뒤에 이어야 해요 |
구분자가 " > " 인가 |
문제가 요구한 구분자예요. joining() 에 아무것도 안 주면 그냥 다 붙어버려요 |
흔한 실수
limit과sorted의 순서를 바꿔limit을 먼저 쓰면, 정렬되지 않은 앞 N명이 잘려 엉뚱한 사람이 뽑혀요. "줄 세우고 → 자르기" 순서가 핵심이에요.joining()에 구분자를 안 넘기면"minjijaehoon"처럼 이름이 다 붙어버려요. 구분자" > "를 꼭 넘겨요.map으로 이름을 뽑지 않고 회원(Member) 흐름 그대로joining을 부르려다 막혀요.joining은 문자열 전용이에요.
과제 3 예시답안 — 드디어 인기 해시태그 1위 뽑기
핵심 접근
지난 시간엔 해시태그를 중복 포함해서 모으기만 했어요. 오늘은 그걸 순위로 바꿀 차례예요. 두 단계로 쪼개면 쉬워요. 먼저 해시태그별 등장 횟수를 Map 으로 만들고(frequency), 그 Map 에서 횟수가 가장 큰 것을 뽑아요(mostPopularTag). 1위를 뽑을 땐 Map 을 다시 흐름으로 펼쳐 max 로 가장 큰 것을 찾는데, max 결과가 "있을 수도 없을 수도 있는 상자"라 .orElse(null) 로 안전하게 꺼내요.
예시 구현
// com/instagram/javabasic/modern/solution/day27/HashtagRanking.java
public static Map<String, Long> frequency(List<Post> posts) {
return posts.stream()
.flatMap(post -> Arrays.stream(post.getContent().split(" ")))
.filter(word -> word.startsWith("#"))
.collect(Collectors.groupingBy(Function.identity(), Collectors.counting()));
}
public static String mostPopularTag(List<Post> posts) {
Map.Entry<String, Long> top = frequency(posts).entrySet().stream()
.max(Comparator.comparingLong(Map.Entry::getValue))
.orElse(null);
return top == null ? "(해시태그 없음)" : top.getKey();
}
frequency 는 지난 시간 allTags 의 앞부분(해시태그를 한 줄기로 펼치기)에 오늘 배운 groupingBy + counting 을 붙인 거예요. mostPopularTag 는 그 Map 을 entrySet().stream() 으로 펼쳐, max 로 횟수(getValue)가 가장 큰 키-값 쌍을 찾고, 그 키(getKey)를 돌려줘요. #일상 이 3번, 나머지가 1번씩이면 결과는 "#일상" 이에요.
채점 포인트
| 확인할 점 | 왜 중요한가 |
|---|---|
frequency 가 flatMap 으로 해시태그를 펼쳤는가 |
게시물 하나에 태그가 여러 개라, map 이 아니라 flatMap 이라야 한 줄기로 펼쳐져요 |
groupingBy(Function.identity(), counting()) 을 썼는가 |
태그 자체를 키로 묶어 세야 "태그 → 횟수" Map 이 나와요 |
max 의 비교 기준이 getValue(횟수) 인가 |
키가 아니라 값(횟수)으로 비교해야 "가장 많이 나온" 태그가 뽑혀요 |
.orElse(null) 뒤에 null 확인을 했는가 |
해시태그가 하나도 없으면 빈 상자가 나와요. 확인 없이 getKey() 를 부르면 사고가 나요 |
흔한 실수
max(Comparator.comparingLong(Map.Entry::getValue))대신 키로 비교하면, 횟수가 아니라 "이름이 사전순으로 가장 뒤인 태그"가 뽑혀요. 비교 기준은 값(횟수) 이에요.max결과를 바로.getKey()로 꺼내려다 막혀요.max는 빈 상자를 줄 수 있어서,.orElse(...)로 한 번 꺼낸 뒤에 써야 해요..orElse(null)로 꺼내고null확인을 깜빡하면, 해시태그가 없는 입력에서 프로그램이 멈춰요. 다음 시간에 배울Optional을 쓰면 이 확인을 더 안전하게 할 수 있어요.
생각해볼 주제 예시답안
생각해볼 주제 1 예시답안 — groupingBy 한 줄 vs for 와 Map 직접 쓰기, 무엇이 다를까?
[문제 상황 요약]
오늘 groupingBy 한 줄로 "작성자별 글 묶기"를 했어요. 같은 일을 지난 방식(빈 Map + for)으로 하면 어떻게 다른지, 그리고 groupingBy 가 뒤에서 대신 해주는 일이 무엇인지를 따져보는 주제예요.
[튜터의 가이드 및 해설]
for 로 직접 묶으려면 이런 흐름이 돼요. 빈 Map<String, List<Post>> 을 만들고, 게시물을 for 로 돌면서, "이 작성자 키가 Map 에 이미 있나?"를 확인해요. 있으면 그 목록에 글을 추가하고, 없으면 새 빈 목록을 먼저 만들어 Map 에 넣은 다음 글을 추가하죠. 이 "키가 처음 나올 때 빈 목록을 만들어주는" 처리를 깜빡하면, 값이 없는 키에 글을 넣으려다 프로그램이 멈춰요. 초보자가 자주 빠지는 함정이에요.
groupingBy 는 바로 그 귀찮고 실수하기 쉬운 부분을 안 보이게 대신해줘요. 키가 처음 나오면 빈 목록을 알아서 만들고, 이미 있으면 그 목록에 더해줘요. 그래서 우리는 "무엇을 기준으로 묶을지"(Post::getAuthorName) 한 가지만 적으면 돼요. 코드가 짧아지는 것도 좋지만, 더 큰 이득은 "빈 목록 만들기를 깜빡하는 사고" 자체가 사라진다는 거예요.
그렇다고 for 방식이 늘 나쁜 건 아니에요. 묶는 규칙이 아주 특이하거나(예: 조건에 따라 같은 글을 두 그룹에 넣어야 한다거나), 한 번 도는 김에 묶기 말고 다른 일도 같이 해야 할 때는 for 가 더 자유로워요. 하지만 "단순히 어떤 기준으로 묶기"라면 groupingBy 가 짧고 안전해요.
🎯 면접관을 홀리는 핵심 멘트
"
groupingBy의 진짜 가치는 코드가 짧아지는 것보다, '키가 처음 나올 때 빈 컬렉션을 만들어주는' 반복되고 실수하기 쉬운 처리를 감춰준다는 데 있다고 봐요.for로 직접 묶으면 그 초기화를 깜빡해 사고가 나기 쉬운데,groupingBy는 분류 기준만 선언하면 돼서 의도가 또렷하게 드러나요. 다만 묶는 규칙이 특수하면for의 유연함이 필요할 때도 있어서, 단순 분류는groupingBy, 특수 로직은 직접 순회로 가릅니다."
생각해볼 주제 2 예시답안 — 병렬 스트림은 언제 득이고 언제 독일까?
[문제 상황 요약]
.parallelStream() 으로 좋아요 합계를 구했고 결과는 순차 스트림과 같았어요. 그렇다면 병렬이 항상 더 나은지, 데이터 크기와 작업 성격에 따라 득과 독이 어떻게 갈리는지 따져보는 주제예요.
[튜터의 가이드 및 해설]
병렬 스트림은 흐름을 여러 갈래로 쪼개 여러 일꾼이 동시에 처리해요. 그런데 쪼개고 다시 합치는 데도 비용이 들어요. 게시물이 딱 3개라면, 쪼개고 합치는 수고가 그냥 차례로 더하는 것보다 훨씬 커서 오히려 느려져요. 반대로 300만 개라면, 동시에 나눠 처리하는 이득이 그 수고를 충분히 넘어서서 빨라질 수 있어요. 그래서 "데이터가 충분히 많을 때만 병렬을 고민한다"가 기본이에요.
작업 성격도 중요해요. 합계처럼 "순서가 상관없는" 일은 병렬에 잘 맞아요. 어느 갈래가 먼저 끝나든 다 더하면 총합은 같으니까요. 하지만 "앞에서부터 순서대로 화면에 출력"하는 일에 병렬을 쓰면, 여러 일꾼이 제각각 끝나는 대로 출력해서 순서가 뒤죽박죽이 돼요. 또 여러 일꾼이 같은 변수 하나(예: 바깥의 카운터)를 동시에 건드리면, 값이 꼬여서 결과가 틀려지기도 해요.
정리하면 병렬은 "데이터가 아주 많고 + 순서가 상관없고 + 각 처리가 서로 독립적"일 때 득이에요. 셋 중 하나라도 어긋나면 독이 되기 쉽죠. 빠르게 만들려다 오히려 느려지거나 결과가 틀리는 함정이 여기 숨어 있어요. 그래서 실무에서는 "일단 순차로 짜고, 정말 느려서 측정으로 확인됐을 때만 병렬을 검토한다"는 신중한 태도를 가져요.
🎯 면접관을 홀리는 핵심 멘트
"병렬 스트림은 공짜 가속이 아니라 트레이드오프예요. 흐름을 쪼개고 합치는 고정 비용이 있어서, 데이터가 적으면 오히려 느려지고, 순서가 중요한 작업이나 공유 변수를 건드리는 작업에선 결과가 뒤섞이거나 틀어질 수 있어요. 그래서 '데이터가 충분히 크고, 순서 무관하고, 각 처리가 독립적'일 때만 득이라고 보고, 기본은 순차로 작성한 뒤 실제 측정으로 병목이 확인됐을 때만 병렬을 검토합니다."
생각해볼 주제 3 예시답안 — 자바는 왜 null 대신 "값이 있을 수도 없을 수도 있는 상자"를 만들었을까?
[문제 상황 요약]
findFirst 와 max 는 값을 그냥 주지 않고 상자에 담아 줬어요. 흐름이 비어 있을 때 이 상자가 왜 그냥 null 보다 안전한지, 그게 왜 다음 시간 Optional 의 출발점인지 생각하는 주제예요.
[튜터의 가이드 및 해설]
만약 상자 없이 그냥 값을 돌려주는데 흐름이 비어 있다면, 돌려줄 게 없으니 보통 null(값이 없음을 뜻하는 빈 자리) 이 나와요. 문제는 받는 쪽이 그걸 잊기 쉽다는 거예요. "당연히 값이 있겠지" 하고 바로 .getContent() 같은 걸 부르면, 빈 자리에서 동작을 부르는 셈이라 프로그램이 그 자리에서 멈춰버려요. 이게 그 유명한 "널 포인터 사고" 예요. 더 나쁜 건, 이 사고가 코드를 쓸 때는 안 보이고 실제로 빈 입력이 들어온 순간에야 터진다는 점이에요.
상자는 이 문제를 "잊을 수 없게" 만들어줘요. findFirst 가 상자를 돌려주면, 받는 사람은 그 상자를 열어야만 값을 쓸 수 있어요. 그리고 상자를 여는 순간(.orElse(기본값)) "비어 있으면 뭘 줄까?"를 강제로 정하게 되죠. 즉 "이 값은 비어 있을 수도 있다"는 사실이 타입에 드러나 있어서, 빈 경우를 처리하는 걸 깜빡하기 어려워요. 컴파일러가 "상자를 안 열고 그냥 쓰려고 하네?" 하고 막아주기도 하고요.
그래서 상자 방식은 null 보다 안전해요. null 은 "값이 없을 수도 있다"는 경고를 코드 어디에도 남기지 않지만, 상자는 그 경고를 타입 안에 박아둬요. 오늘 우리는 .orElse 로 급한 대로 열었지만, 다음 시간에 배울 Optional 은 이 상자를 더 우아하게 다루는 여러 방법을 줘요. "비어 있을 수도 있는 값"을 솔직하게 표현하고 안전하게 쓰는 게 Optional 의 핵심이에요.
🎯 면접관을 홀리는 핵심 멘트
"
null의 문제는 '값이 없을 수도 있다'는 사실이 타입에 드러나지 않아, 받는 쪽이 빈 경우 처리를 잊기 쉽다는 거예요. 그러면 실제 빈 입력이 들어온 순간에야 널 포인터 사고가 터지죠.Optional같은 상자는 '이 값은 비어 있을 수 있다'를 타입으로 강제해서, 꺼내기 전에 빈 경우를 어떻게 처리할지 반드시 정하게 만들어요. 사고를 런타임에서 컴파일·설계 시점으로 앞당기는 안전장치라고 이해하고 있습니다."
