드디어 마지막 장이에요! Java 8이 가져온 함수형 스타일을 배웁니다. 반복문으로 하던 일을 더 짧고 읽기 좋게 바꾸는 마법 같은 도구, 람다식과 스트림을 천천히 익혀봐요.
(매개변수) -> 표현식 문법을 읽고 쓸 수 있어요.for문과 스트림을 비교하며 선언형 코드의 가독성을 느껴봐요.지금까지 우리는 데이터를 다룰 때 반복문(for·while)을 썼어요. "인덱스를 0부터 하나씩 올리며, 조건에 맞으면 무언가 한다" 같은 절차를 일일이 적었죠. 이런 방식을 명령형(imperative) 스타일이라고 해요.
Java 8(2014)에서 자바는 함수형(functional) 스타일을 새로 도입했어요. 핵심 도구가 바로 람다식과 스트림이에요. 이것들을 쓰면 "데이터에서 짝수만 골라 두 배로 만들어 모아라" 같은 작업을 한 줄의 흐름으로 선언형(declarative)하게 표현할 수 있어요.
for로 하나씩 돌기). 선언형은 "무엇을 원하는지"를 적는 방식이고요(예: "짝수만 걸러 모아라"). 람다·스트림은 선언형에 가까워 의도가 잘 드러나요..parallelStream() 같은 것)
즉, 람다·스트림은 "새로운 문법 자랑"이 아니라, 데이터를 더 짧고 안전하고 읽기 좋게 다루려는 실용적인 도구예요.
람다식(lambda expression)은 이름이 없는 함수(익명 함수)를 아주 짧게 표현한 거예요. 기본 문법은 이렇게 생겼어요.
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 이전에는, 짧은 동작 하나를 넘기려 해도 아래처럼 길게 써야 했어요.
// 예전 방식: 익명 클래스 — 길고 번거로움
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)라고 불러요.
(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 패키지에 새로 준비된 것도 있어요.
| 인터페이스 | 추상 메서드 | 하는 일 |
|---|---|---|
| Runnable | run() | 매개변수·반환 없이 실행만 (스레드에서 많이 봤죠) |
| 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()... // 여기서 데이터가 "흘러" 나가기 시작
스트림의 연산은 크게 두 종류예요. 이 구분이 이 장의 핵심이에요.
| 종류 | 대표 연산 | 특징 |
|---|---|---|
| 중간 연산 | 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] ← 원본은 그대로!
메서드 참조(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으로 줄일 수 있어요. 결과는 똑같아요.
.stream()으로 새로 만들어야 해요."리스트에서 짝수만 골라 두 배로 만들어 새 리스트에 담기" — 같은 작업을 두 방식으로 써볼게요.
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]
break, 인덱스 다루기 등)에 유리해요.(매개변수) -> 표현식. 예: (a, b) -> a + b.@FunctionalInterface). 람다는 이걸 구현하는 것..stream()으로 생성하며 원본을 바꾸지 않아요.list.stream().filter(...).map(...).collect(...) — 거르고·바꾸고·모으기.System.out::println)로 람다를 더 줄일 수 있고, 스트림은 지연 평가돼요.