도입 람다식 함수형 인터페이스 스트림이란 스트림 연산 메서드 참조 for vs 스트림 📝 문제풀이
◀ 이전 🎉 완주 · 목차
🌊 CHAPTER 16 · 고급

람다와 스트림 — 데이터를 흐름으로 다루기

드디어 마지막 장이에요! Java 8이 가져온 함수형 스타일을 배웁니다. 반복문으로 하던 일을 더 짧고 읽기 좋게 바꾸는 마법 같은 도구, 람다식스트림을 천천히 익혀봐요.

🎯 이 장을 끝내면
📖
도입 · 배경
Java 8이 바꾼 것 — 함수형 스타일
코드를 "어떻게"가 아니라 "무엇을" 중심으로 쓰게 됐어요.

지금까지 우리는 데이터를 다룰 때 반복문(for·while)을 썼어요. "인덱스를 0부터 하나씩 올리며, 조건에 맞으면 무언가 한다" 같은 절차를 일일이 적었죠. 이런 방식을 명령형(imperative) 스타일이라고 해요.

Java 8(2014)에서 자바는 함수형(functional) 스타일을 새로 도입했어요. 핵심 도구가 바로 람다식스트림이에요. 이것들을 쓰면 "데이터에서 짝수만 골라 두 배로 만들어 모아라" 같은 작업을 한 줄의 흐름으로 선언형(declarative)하게 표현할 수 있어요.

🔤
명령형·선언형(imperative·declarative) — 명령형은 "어떻게 하는지"를 한 단계씩 적는 방식이에요(for로 하나씩 돌기). 선언형은 "무엇을 원하는지"를 적는 방식이고요(예: "짝수만 걸러 모아라"). 람다·스트림은 선언형에 가까워 의도가 잘 드러나요.
🤔 왜 함수형 스타일을 도입했을까? 1. 간결함 — 여러 줄의 반복문·조건문이 짧은 한 줄로 줄어들어요.
2. 선언형 코드 — "어떻게 도는지(절차)"가 아니라 "무엇을 하는지(의도)"가 드러나서 읽기 쉬워요.
3. 병렬 처리가 쉬움 — 데이터를 흐름으로 다루면, 여러 코어로 나눠서 처리하기가 훨씬 수월해요. (.parallelStream() 같은 것) 즉, 람다·스트림은 "새로운 문법 자랑"이 아니라, 데이터를 더 짧고 안전하고 읽기 좋게 다루려는 실용적인 도구예요.
Java 8 2014 람다식 함수형 인터페이스 Stream filter map collect 선언형
🧭
이번 장의 읽는 법. 람다와 스트림은 처음엔 낯설어요. 하지만 "짧게 쓰는 방법" 정도로 편하게 받아들이면 됩니다. 예제를 눈으로 따라 읽는 것만으로도 충분히 감이 잡혀요.
람다식
이름 없는 짧은 함수
"매개변수를 받아 무언가 돌려주는 동작"을 아주 짧게 적는 문법이에요.

람다식(lambda expression)이름이 없는 함수(익명 함수)를 아주 짧게 표현한 거예요. 기본 문법은 이렇게 생겼어요.

🔤
익명 함수(anonymous function) — 이름을 붙이지 않고 바로 쓰는 함수예요. 보통 함수는 add처럼 이름을 짓지만, 잠깐 한 번 넘길 동작이라면 이름 없이 (a, b) -> a + b처럼 적는 거죠. 람다식이 바로 이 익명 함수예요.
(매개변수) -> 실행할_표현식

-> (화살표)를 기준으로, 왼쪽은 입력(매개변수), 오른쪽은 그 입력으로 할 일이에요. 몇 가지 예를 볼게요.

// 두 수를 받아 더해서 돌려준다
(a, b) -> a + b

// x를 받아 2배로 만들어 돌려준다
x -> x * 2

// 매개변수가 없으면 빈 괄호
() -> System.out.println("안녕!")

// 몸통이 여러 줄이면 중괄호와 return 사용
(a, b) -> {
    int sum = a + b;
    return sum;
}
💡
매개변수가 하나면 괄호를 생략할 수 있어요(x -> x * 2). 몸통이 한 표현식이면 중괄호·return도 생략돼요. 그래서 람다가 그렇게 짧은 거예요.

그럼 이게 왜 편할까요? 익명 클래스와 비교하면 확 와닿아요. Java 8 이전에는, 짧은 동작 하나를 넘기려 해도 아래처럼 길게 써야 했어요.

🆚 익명 클래스 vs 람다 — 같은 일, 다른 길이 아래 두 코드는 완전히 같은 동작(리스트를 크기순 정렬)을 해요. 길이를 비교해보세요.
// 예전 방식: 익명 클래스 — 길고 번거로움
Collections.sort(list, new Comparator<Integer>() {
    @Override
    public int compare(Integer a, Integer b) {
        return a - b;
    }
});

// Java 8 람다: 핵심만 남김 — 짧고 명확
Collections.sort(list, (a, b) -> a - b);

보이시죠? 껍데기(new Comparator..., @Override, 메서드 이름, 타입)를 다 걷어내고 진짜 하고 싶은 동작(a - b)만 남긴 게 람다예요.

🧩
함수형 인터페이스
람다가 "구현하는 대상"
람다는 허공에 뜬 게 아니라, 어떤 인터페이스의 메서드를 채우는 거예요.

람다식은 사실 어떤 인터페이스의 메서드를 구현하는 거예요. 그런데 아무 인터페이스나 되는 건 아니고, 추상 메서드가 딱 하나뿐인 인터페이스여야 해요. 이런 걸 함수형 인터페이스(Functional Interface)라고 불러요.

📌 함수형 인터페이스의 조건 추상 메서드가 정확히 1개여야 해요. 메서드가 하나뿐이니, 람다를 보면 "아, 그 하나짜리 메서드를 이렇게 구현하라는 거구나" 하고 혼동 없이 연결돼요. 그래서 람다와 함수형 인터페이스는 한 쌍이에요. 함수형 인터페이스가 있어야 람다를 담을 수 있어요.
🔒
함수형 인터페이스 = 자물쇠
채워야 할 구멍(추상 메서드)이 딱 하나. 그래서 어떤 열쇠를 넣을지 헷갈릴 일이 없어요.
+
🗝️
람다 = 열쇠
그 하나뿐인 구멍을 채우는 실제 동작이에요. (a, b) -> a + b 처럼요.
// @FunctionalInterface: "이건 함수형 인터페이스야"라고 표시
// (추상 메서드가 1개가 아니면 컴파일 에러로 알려줌 → 안전장치)
@FunctionalInterface
interface Calculator {
    int operate(int a, int b);   // 추상 메서드 딱 1개
}

// 람다로 이 인터페이스를 구현
Calculator add = (a, b) -> a + b;
System.out.println(add.operate(3, 4));   // 7
🏷️
@FunctionalInterface필수는 아니지만 붙이면 좋아요. 실수로 메서드를 2개 이상 넣으면 컴파일러가 에러로 잡아줘요. "이 인터페이스는 람다용"이라는 의도도 명확히 드러나고요.

자바는 자주 쓰는 함수형 인터페이스를 미리 만들어 뒀어요. 우리가 이미 아는 것도 있고, java.util.function 패키지에 새로 준비된 것도 있어요.

인터페이스추상 메서드하는 일
Runnablerun()매개변수·반환 없이 실행만 (스레드에서 많이 봤죠)
Comparator<T>compare(a, b)두 값을 비교해 정렬 기준을 정함
Function<T,R>apply(t)T를 받아 R로 변환 (예: 숫자 → 문자열)
Predicate<T>test(t)T를 받아 참/거짓 판단 (조건 검사)
Consumer<T>accept(t)T를 받아 소비만 (반환 없음, 예: 출력)
Supplier<T>get()입력 없이 T를 공급 (값 만들어 주기)
import java.util.function.*;

Predicate<Integer> isEven = n -> n % 2 == 0;   // 짝수인가?
System.out.println(isEven.test(4));            // true

Function<Integer, String> toText = n -> "숫자:" + n;
System.out.println(toText.apply(7));           // 숫자:7

Consumer<String> printer = s -> System.out.println(s);
printer.accept("소비!");                        // 소비!

Supplier<String> greeter = () -> "안녕하세요";
System.out.println(greeter.get());             // 안녕하세요
🔎
지금 이 이름들을 전부 외울 필요는 없어요. "람다를 담는 그릇들"이 이렇게 준비돼 있구나 정도면 충분해요. 곧 나올 스트림이 이것들을 내부에서 알아서 써요.
🌊
스트림이란
데이터를 "흐름"으로 처리하기
컬렉션에서 데이터가 한 방향으로 흘러가며 가공되는 파이프라인이에요.

스트림(Stream)은 컬렉션(List·Set 등)의 데이터를 하나의 "흐름"으로 다루는 도구예요. 데이터가 수도관을 따라 흐르듯 한 방향으로 지나가면서, 중간중간 걸러지고 변형되다가 마지막에 결과로 모이는 그림을 떠올려 보세요.

스트림은 컬렉션의 .stream() 메서드로 만들어요.

import java.util.*;

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

// 리스트로부터 스트림 생성
numbers.stream()...   // 여기서 데이터가 "흘러" 나가기 시작
📚
컬렉션
원본 데이터 (List 등)
🚰
.stream()
흐름을 만든다
⚙️
중간 연산
filter·map 으로 가공
📦
최종 연산
collect·count 로 결과
⚠️ 아주 중요 — 스트림은 원본을 바꾸지 않아요 스트림으로 데이터를 걸러내고 변형해도, 원본 컬렉션은 그대로예요. 스트림은 원본을 "읽어서 흘려보내며" 새 결과를 만들 뿐, 원본을 건드리지 않아요. 💡 그래서 안전해요. 원본은 유지되고, 결과는 따로 받아요. 이 성질을 "원본 불변"이라고 기억해 두세요. (시험 단골!)
💧
비유. 스트림은 정수기 같아요. 수돗물(원본 리스트)을 통에 담아 두고, 정수기(스트림)에 흘려보내 필터로 거르고(filter) 맛을 더해(map) 깨끗한 물 한 컵(결과)을 받아요. 이때 원래 수돗물 통은 그대로 남아 있죠.
⚙️
스트림 연산
중간 연산 + 최종 연산
거르고(filter)·바꾸고(map)·모으는(collect) 파이프라인을 조립해요.

스트림의 연산은 크게 두 종류예요. 이 구분이 이 장의 핵심이에요.

종류대표 연산특징
중간 연산filter, map, sorted스트림을 또 다른 스트림으로 변환. 여러 개를 이어 붙일 수 있음. 아직 실행 안 됨.
최종 연산forEach, collect, count스트림을 소모하며 최종 결과를 냄. 이때 비로소 전체가 실행됨. 딱 한 번만.

중간 연산부터 볼게요. filter조건에 맞는 것만 걸러내고, map각 요소를 다른 값으로 변환해요.

import java.util.*;
import java.util.stream.*;

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

// filter: 짝수만 남긴다 → 2, 4, 6
numbers.stream()
       .filter(n -> n % 2 == 0);

// map: 각 값을 10배로 바꾼다 → 10, 20, 30, 40, 50, 60
numbers.stream()
       .map(n -> n * 10);

이제 최종 연산이에요. collect(Collectors.toList())는 결과를 새 리스트로 모으고, forEach하나씩 처리, count개수를 세요.

// collect: 결과를 리스트로 모으기
List<Integer> evens = numbers.stream()
                             .filter(n -> n % 2 == 0)
                             .collect(Collectors.toList());   // [2, 4, 6]

// forEach: 하나씩 출력
numbers.stream().forEach(n -> System.out.println(n));

// count: 조건에 맞는 개수 세기
long cnt = numbers.stream()
                  .filter(n -> n > 3)
                  .count();   // 3 (4, 5, 6)

진짜 힘은 이것들을 이어 붙일 때(파이프라인) 나와요. "짝수만 골라 → 10배로 바꿔 → 리스트로 모아라"를 한 흐름으로 적어봐요.

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

List<Integer> result = nums.stream()          // 흐름 시작
        .filter(n -> n % 2 == 0)               // 짝수만: 2, 4, 6
        .map(n -> n * 10)                      // 10배로: 20, 40, 60
        .collect(Collectors.toList());         // 리스트로 모으기

System.out.println(result);   // [20, 40, 60]
System.out.println(nums);     // [1, 2, 3, 4, 5, 6]  ← 원본은 그대로!
🧠
읽는 법. 위 코드를 소리 내 읽으면 "nums에서, 짝수만 걸러서, 10배로 바꿔서, 리스트로 모아라" — 딱 하고 싶은 말 그대로죠? 이게 선언형 코드의 가독성이에요.
🔗
메서드 참조 · 특징
더 짧게, 그리고 똑똑하게
람다를 한 번 더 줄이는 문법과, 스트림의 실행 시점 이야기예요.

메서드 참조(method reference)는 "이미 있는 메서드를 그대로 쓰는 람다"를 더 짧게 적는 문법이에요. 클래스::메서드 형태로 써요.

List<String> words = Arrays.asList("사과", "바나나", "포도");

// 람다로 출력
words.forEach(w -> System.out.println(w));

// 메서드 참조로 더 짧게 (똑같은 동작!)
words.forEach(System.out::println);

w -> System.out.println(w)는 "받은 걸 그대로 println에 넘긴다"는 뜻이니, 아예 System.out::println으로 줄일 수 있어요. 결과는 똑같아요.

🕐 스트림의 성질 — 지연 평가(lazy evaluation) 스트림의 중간 연산(filter·map)은 적어두기만 하고 바로 실행되지 않아요. 최종 연산(collect·forEach·count)을 만나는 순간 비로소 전체 파이프라인이 한 번에 돌아요. 💡 그래서 최종 연산이 없으면 아무 일도 일어나지 않아요. "필터를 걸어놨는데 왜 안 돌지?" 싶으면, 최종 연산이 빠졌을 가능성이 커요.
♻️
스트림은 한 번 쓰면 재사용할 수 없어요. 최종 연산으로 소모되면 그 스트림은 닫혀요. 다시 처리하려면 .stream()으로 새로 만들어야 해요.
🆚
비교
for문 vs 스트림 — 같은 일, 다른 느낌
어느 쪽이 "무엇을 하는지" 더 잘 읽히나 비교해봐요.

"리스트에서 짝수만 골라 두 배로 만들어 새 리스트에 담기" — 같은 작업을 두 방식으로 써볼게요.

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

// ① 명령형 — for문: "어떻게 도는지"를 하나하나 적음
List<Integer> a = new ArrayList<>();
for (int n : nums) {
    if (n % 2 == 0) {       // 짝수인지 검사
        a.add(n * 2);       // 두 배로 담기
    }
}

// ② 선언형 — 스트림: "무엇을 하는지"가 그대로 보임
List<Integer> b = nums.stream()
        .filter(n -> n % 2 == 0)
        .map(n -> n * 2)
        .collect(Collectors.toList());

// 둘 다 결과: [4, 8, 12]
⚖️ 언제 무엇을? for문 — 익숙하고, 복잡한 흐름 제어(중간에 break, 인덱스 다루기 등)에 유리해요.
스트림 — "거르고·바꾸고·모으는" 데이터 가공에서 짧고 읽기 좋아요. 병렬 처리로 확장하기도 쉬워요. 둘은 경쟁이 아니라 도구 선택이에요. 데이터를 흐름처럼 가공한다면 스트림이, 세밀한 제어가 필요하면 for문이 어울려요.
🌱
천천히 익혀도 괜찮아요. 처음엔 for문이 편해요. 스트림은 filter·map·collect 세 개만 손에 익혀도 대부분의 데이터 가공을 우아하게 처리할 수 있어요.
🧠 이 장 핵심 요약
📝
문제풀이 · 점검
배운 걸 가볍게 점검해봐요
시험이 아니라 "내가 이해했나" 확인용이에요. 틀려도 바로 해설이 나와요.
🧪
문제를 풀면 즉시 정답과 해설이 나오고, 위쪽 바에 점수가 쌓여요. 네 가지 유형(객관식 · O/X · 빈칸 · 코드결과)을 섞어 두었어요. 편하게 눌러보세요.
🎉 완주 축하합니다! 자바 스터디 16개 챕터를 모두 마쳤어요. 로드맵으로 돌아가 지나온 여정을 되돌아보세요 — 이제 여러분은 자바의 든든한 기본기를 갖췄어요! ☕