람다와 스트림 — 자바도 함수형으로 쓸 수 있나요
for문 안에 if문, 그 안에 또 for문... 이런 코드를 작성하다 보면 한 가지 의문이 든다. "이걸 좀 더 깔끔하게 쓸 수는 없을까?" Java 8부터 도입된 람다와 스트림이 바로 그 답이다. 이번 글에서는 함수형 인터페이스부터 스트림, Optional까지 차근차근 정리해본다.
왜 함수형 프로그래밍인가?
먼저 간단한 예제를 보자. 리스트에서 짝수만 골라서 출력하는 코드다.
// 전통적인 방식 — for + if
List<Integer> numbers = List.of(1, 2, 3, 4, 5, 6, 7, 8, 9, 10);
List<Integer> evens = new ArrayList<>();
for (int n : numbers) {
if (n % 2 == 0) {
evens.add(n);
}
}
for (int n : evens) {
System.out.println(n);
}
이 코드 자체가 틀린 건 아니다. 하지만 **"무엇을 하겠다"**보다 **"어떻게 하겠다"**에 초점이 맞춰져 있다. 반복문을 돌리고, 조건을 확인하고, 새 리스트에 추가하고... 절차를 하나하나 지시하는 방식이다.
같은 코드를 스트림으로 바꾸면 이렇게 된다.
// 스트림 방식 — 무엇을 할지 선언
numbers.stream()
.filter(n -> n % 2 == 0) // 짝수만 걸러내기
.forEach(System.out::println); // 각각 출력
**"짝수를 걸러서 출력해라"**라는 의도가 코드에 그대로 드러난다. 이것이 함수형 프로그래밍의 핵심이다. 데이터를 어떻게 처리할지 절차적으로 기술하는 대신, 무엇을 할지 선언적으로 표현하는 것이다.
함수형 스타일의 장점을 정리하면 다음과 같다.
- 가독성: 의도가 코드에 드러나서 읽기 쉽다
- 간결함: 반복적인 보일러플레이트가 줄어든다
- 조합: filter, map, reduce 같은 연산을 파이프라인처럼 연결할 수 있다
함수형 인터페이스
람다를 이해하려면 먼저 함수형 인터페이스를 알아야 한다.
함수형 인터페이스는 추상 메서드가 정확히 1개인 인터페이스다. Java에서 람다는 이 함수형 인터페이스의 인스턴스로 취급된다.
// 직접 만드는 함수형 인터페이스
@FunctionalInterface
interface Calculator {
int calculate(int a, int b); // 추상 메서드 1개
}
@FunctionalInterface 어노테이션은 선택사항이다. 붙이면 컴파일러가 "추상 메서드가 1개인지" 검증해준다. 실수로 메서드를 2개 만들면 컴파일 에러가 난다.
자주 쓰는 내장 함수형 인터페이스
Java가 java.util.function 패키지에 미리 만들어둔 함수형 인터페이스가 있다. 매번 직접 만들 필요 없이 이걸 가져다 쓰면 된다.
| 인터페이스 | 메서드 시그니처 | 역할 |
|---|---|---|
Predicate<T> | boolean test(T t) | 조건 판별 (true/false) |
Function<T, R> | R apply(T t) | 입력 → 변환 → 출력 |
Consumer<T> | void accept(T t) | 입력을 소비 (반환값 없음) |
Supplier<T> | T get() | 입력 없이 값 생성 |
UnaryOperator<T> | T apply(T t) | 같은 타입 입력 → 출력 |
BiFunction<T, U, R> | R apply(T t, U u) | 두 입력 → 변환 → 출력 |
// Predicate — 조건 판별
Predicate<Integer> isEven = n -> n % 2 == 0;
System.out.println(isEven.test(4)); // true
// Function — 변환
Function<String, Integer> strLength = s -> s.length();
System.out.println(strLength.apply("Hello")); // 5
// Consumer — 소비
Consumer<String> printer = s -> System.out.println("출력: " + s);
printer.accept("안녕하세요"); // 출력: 안녕하세요
// Supplier — 생성
Supplier<Double> randomValue = () -> Math.random();
System.out.println(randomValue.get()); // 0.xxxx (랜덤)
이름이 생소하게 느껴질 수 있는데, 패턴을 기억하면 된다.
- Predicate: 판별하는 놈 (boolean 반환)
- Function: 변환하는 놈 (입력 → 출력)
- Consumer: 먹기만 하는 놈 (반환값 없음)
- Supplier: 만들기만 하는 놈 (입력 없음)
람다 표현식 기본 문법
람다 표현식은 함수형 인터페이스의 추상 메서드를 간결하게 구현하는 방법이다.
(매개변수) -> { 본문 }
기본 형태는 위와 같고, 상황에 따라 축약할 수 있다.
// 기본 형태
(int a, int b) -> { return a + b; }
// 타입 추론 — 컴파일러가 타입을 알아서 추론
(a, b) -> { return a + b; }
// 본문이 한 줄이면 중괄호와 return 생략
(a, b) -> a + b
// 매개변수가 1개면 괄호 생략
n -> n * 2
// 매개변수가 없으면 빈 괄호
() -> System.out.println("Hello")
실제로 사용하는 예제를 보자.
// 익명 클래스 방식 — 장황하다
Comparator<String> comp1 = new Comparator<String>() {
@Override
public int compare(String a, String b) {
return a.length() - b.length();
}
};
// 람다 방식 — 핵심만 남긴다
Comparator<String> comp2 = (a, b) -> a.length() - b.length();
같은 동작인데 코드 양이 확 줄었다. 익명 클래스에서 반복되는 형식적인 코드(메서드 이름, 타입 선언, 중괄호 등)를 전부 생략하고 실제 로직만 남긴 것이 람다다.
람다에서 외부 변수 사용
람다 안에서 외부 변수를 참조할 수 있다. 단, 한 가지 조건이 있다.
String prefix = "번호: "; // 사실상 final (effectively final)
Consumer<Integer> printWithPrefix = n -> {
System.out.println(prefix + n); // 외부 변수 참조 OK
};
// prefix = "새로운 값"; // 이렇게 바꾸면 컴파일 에러!
외부 변수는 effectively final(선언 이후 값이 변하지 않는 변수)이어야 한다. 이 제약이 있는 이유는 람다가 실행되는 시점이 변수가 선언된 시점과 다를 수 있기 때문이다. 변할 수 있는 변수를 참조하면 예측 불가능한 동작이 생길 수 있다.
메서드 레퍼런스
람다 표현식이 기존 메서드를 그대로 호출하기만 하는 경우, 메서드 레퍼런스로 더 간결하게 쓸 수 있다.
// 람다
names.forEach(name -> System.out.println(name));
// 메서드 레퍼런스 — 같은 동작
names.forEach(System.out::println);
:: 연산자가 메서드 레퍼런스를 나타낸다. 네 가지 형태가 있다.
1. 정적 메서드 레퍼런스
// 클래스::정적메서드
Function<String, Integer> parser = Integer::parseInt;
// 동일: s -> Integer.parseInt(s)
2. 특정 객체의 인스턴스 메서드 레퍼런스
// 객체::인스턴스메서드
String greeting = "Hello";
Supplier<Integer> lengthGetter = greeting::length;
// 동일: () -> greeting.length()
3. 임의 객체의 인스턴스 메서드 레퍼런스
// 클래스::인스턴스메서드
Function<String, String> upper = String::toUpperCase;
// 동일: s -> s.toUpperCase()
여기서 헷갈릴 수 있는데, String::toUpperCase는 "임의의 String 객체에 대해 toUpperCase를 호출하겠다"는 뜻이다. 첫 번째 매개변수가 메서드 호출 대상이 된다.
4. 생성자 레퍼런스
// 클래스::new
Supplier<ArrayList<String>> listFactory = ArrayList::new;
// 동일: () -> new ArrayList<>()
Function<String, StringBuilder> sbFactory = StringBuilder::new;
// 동일: s -> new StringBuilder(s)
처음에는 람다가 더 읽기 쉽게 느껴질 수 있다. 하지만 메서드 레퍼런스에 익숙해지면, **"이 람다는 그냥 기존 메서드를 호출하는 거구나"**라는 의도가 더 명확하게 드러난다.
스트림이란?
스트림(Stream)은 컬렉션의 요소를 함수형으로 처리하는 파이프라인이다. 데이터를 저장하는 자료구조가 아니라, 데이터를 흘려보내며 변환하는 통로라고 생각하면 된다.
// 스트림은 컬렉션에서 생성한다
List<String> names = List.of("Alice", "Bob", "Charlie", "David");
// 스트림으로 처리
long count = names.stream() // 스트림 생성
.filter(n -> n.length() > 3) // 글자 수 3 초과만 필터
.count(); // 개수 세기
System.out.println(count); // 3 (Alice, Charlie, David)
스트림의 핵심 특성을 정리하면 다음과 같다.
- 원본을 변경하지 않는다: 원본 컬렉션은 그대로 유지된다
- 지연 평가(Lazy Evaluation): 최종 연산이 호출될 때까지 중간 연산은 실행되지 않는다
- 일회용이다: 한 번 사용한 스트림은 다시 사용할 수 없다
스트림 파이프라인
스트림은 세 단계로 구성된다.
소스(Source) → 중간 연산(Intermediate) → 최종 연산(Terminal)
List<String> result = names.stream() // 1. 소스: 스트림 생성
.filter(n -> n.length() > 3) // 2. 중간 연산: 필터
.map(String::toUpperCase) // 2. 중간 연산: 변환
.sorted() // 2. 중간 연산: 정렬
.collect(Collectors.toList()); // 3. 최종 연산: 결과 수집
중간 연산은 새 스트림을 반환한다. 그래서 체이닝(연결)이 가능하다. 그리고 최종 연산이 호출되기 전까지는 아무것도 실행되지 않는다. 이것이 지연 평가다.
// 지연 평가 확인 — 최종 연산이 없으면 아무것도 실행되지 않는다
names.stream()
.filter(n -> {
System.out.println("필터링: " + n); // 이 줄이 출력되지 않는다!
return n.length() > 3;
});
// 최종 연산이 없으므로 filter 안의 코드는 실행되지 않음
중간 연산
자주 쓰는 중간 연산을 하나씩 살펴보자.
filter — 조건에 맞는 것만 남기기
List<Integer> numbers = List.of(1, 2, 3, 4, 5, 6, 7, 8, 9, 10);
// 짝수만 필터링
List<Integer> evens = numbers.stream()
.filter(n -> n % 2 == 0)
.collect(Collectors.toList());
// [2, 4, 6, 8, 10]
map — 각 요소를 변환하기
List<String> names = List.of("alice", "bob", "charlie");
// 모두 대문자로 변환
List<String> upperNames = names.stream()
.map(String::toUpperCase)
.collect(Collectors.toList());
// [ALICE, BOB, CHARLIE]
sorted — 정렬하기
List<String> names = List.of("Charlie", "Alice", "Bob");
// 기본 정렬 (사전순)
List<String> sorted = names.stream()
.sorted()
.collect(Collectors.toList());
// [Alice, Bob, Charlie]
// 글자 수 기준 정렬
List<String> byLength = names.stream()
.sorted(Comparator.comparingInt(String::length))
.collect(Collectors.toList());
// [Bob, Alice, Charlie]
distinct — 중복 제거
List<Integer> numbers = List.of(1, 2, 2, 3, 3, 3, 4);
List<Integer> unique = numbers.stream()
.distinct()
.collect(Collectors.toList());
// [1, 2, 3, 4]
flatMap — 중첩 구조 평탄화
flatMap은 처음에 좀 헷갈릴 수 있다. 각 요소를 스트림으로 변환한 뒤, 그 스트림들을 하나로 합친다.
// 2차원 리스트를 1차원으로 펼치기
List<List<Integer>> nested = List.of(
List.of(1, 2, 3),
List.of(4, 5),
List.of(6, 7, 8, 9)
);
List<Integer> flat = nested.stream()
.flatMap(Collection::stream) // 각 리스트를 스트림으로 변환 후 합침
.collect(Collectors.toList());
// [1, 2, 3, 4, 5, 6, 7, 8, 9]
// 문장을 단어로 분리
List<String> sentences = List.of("Hello World", "Java Stream");
List<String> words = sentences.stream()
.flatMap(s -> Arrays.stream(s.split(" "))) // 각 문장을 단어 스트림으로
.collect(Collectors.toList());
// [Hello, World, Java, Stream]
map은 "1대1 변환"이고, flatMap은 "1대다 변환 + 평탄화"라고 기억하면 된다.
최종 연산
최종 연산이 호출되어야 파이프라인이 실행된다. 최종 연산은 스트림을 소비하고, 결과값(또는 부작용)을 만들어낸다.
collect — 결과를 컬렉션으로 수집
List<String> names = List.of("Alice", "Bob", "Charlie");
// List로 수집
List<String> nameList = names.stream()
.filter(n -> n.length() > 3)
.collect(Collectors.toList());
// Set으로 수집
Set<String> nameSet = names.stream()
.collect(Collectors.toSet());
// 문자열로 합치기
String joined = names.stream()
.collect(Collectors.joining(", "));
// "Alice, Bob, Charlie"
// Map으로 수집 (이름 → 글자 수)
Map<String, Integer> nameLength = names.stream()
.collect(Collectors.toMap(
name -> name, // key
name -> name.length() // value
));
// {Alice=5, Bob=3, Charlie=7}
Java 16부터는 toList()를 직접 쓸 수 있다.
// Java 16+
List<String> result = names.stream()
.filter(n -> n.length() > 3)
.toList(); // Collectors.toList() 대신 간편하게
forEach — 각 요소에 대해 작업 수행
names.stream()
.filter(n -> n.startsWith("A"))
.forEach(System.out::println); // Alice
forEach는 반환값이 없다. 주로 출력이나 로깅에 쓴다.
count, min, max — 집계
List<Integer> numbers = List.of(3, 1, 4, 1, 5, 9);
long count = numbers.stream().count(); // 6
Optional<Integer> min = numbers.stream().min(Integer::compareTo); // 1
Optional<Integer> max = numbers.stream().max(Integer::compareTo); // 9
reduce — 요소를 하나로 합치기
List<Integer> numbers = List.of(1, 2, 3, 4, 5);
// 모든 요소의 합
int sum = numbers.stream()
.reduce(0, (a, b) -> a + b); // 초기값 0, 누적 함수
// 15
// 초기값 없이 사용 — Optional 반환
Optional<Integer> product = numbers.stream()
.reduce((a, b) -> a * b);
// Optional[120]
reduce는 "누적 연산"이다. 초기값과 누적 함수를 받아서 요소들을 하나로 합친다. 초기값을 주지 않으면 결과가 비어 있을 수 있으므로 Optional을 반환한다.
Optional — null을 다루는 안전한 방법
Optional은 값이 있을 수도 있고 없을 수도 있음을 명시적으로 표현하는 컨테이너다. null을 직접 다루는 대신 Optional을 쓰면 NullPointerException을 예방할 수 있다.
Optional 생성
// 값이 있는 경우
Optional<String> name = Optional.of("Alice");
// 값이 없는 경우
Optional<String> empty = Optional.empty();
// null일 수도 있는 값 — ofNullable 사용
String input = null;
Optional<String> maybe = Optional.ofNullable(input); // 비어 있는 Optional
// 주의: Optional.of(null)은 NullPointerException 발생!
Optional 값 꺼내기
Optional<String> name = Optional.of("Alice");
// isPresent로 확인 후 꺼내기 (비추천 — if/null 체크와 다를 바 없다)
if (name.isPresent()) {
System.out.println(name.get());
}
// ifPresent — 값이 있을 때만 실행
name.ifPresent(n -> System.out.println("이름: " + n));
// orElse — 값이 없으면 기본값 반환
String result = name.orElse("이름 없음");
// orElseGet — 값이 없을 때만 Supplier 실행 (비용이 큰 연산에 유리)
String result2 = name.orElseGet(() -> generateDefaultName());
// orElseThrow — 값이 없으면 예외 발생
String result3 = name.orElseThrow(() -> new IllegalArgumentException("이름 필수"));
orElse vs orElseGet 차이
이 차이는 꽤 중요하다.
// orElse — 값이 있어도 기본값 표현식이 항상 실행됨
String name1 = Optional.of("Alice")
.orElse(expensiveOperation()); // expensiveOperation()이 실행된다!
// orElseGet — 값이 없을 때만 Supplier가 실행됨
String name2 = Optional.of("Alice")
.orElseGet(() -> expensiveOperation()); // 실행되지 않는다
비용이 큰 연산이라면 orElseGet을 쓰는 것이 맞다.
Optional과 스트림 조합
// map으로 변환
Optional<String> name = Optional.of("alice");
Optional<String> upper = name.map(String::toUpperCase); // Optional[ALICE]
// flatMap — Optional을 반환하는 메서드와 조합
Optional<String> city = findUser("alice")
.flatMap(User::getAddress) // Optional<Address> 반환
.flatMap(Address::getCity); // Optional<String> 반환
Optional 사용 가이드
- 반환 타입으로 사용: 권장한다. "이 메서드는 결과가 없을 수 있다"는 것을 명확히 표현한다
- 매개변수로 사용: 권장하지 않는다. 호출하는 쪽에서
Optional.of(value)로 감싸야 하니 번거롭다 - 필드로 사용: 권장하지 않는다.
Optional은Serializable이 아니다 - 컬렉션을 Optional로 감싸지 않기: 비어 있는 컬렉션(
Collections.emptyList())을 반환하는 것이 낫다
// 좋은 예: 반환 타입
public Optional<User> findById(Long id) {
return Optional.ofNullable(userMap.get(id));
}
// 나쁜 예: 매개변수
public void process(Optional<String> name) { // 하지 말 것
// ...
}
실전 예제 — 학생 성적 처리
지금까지 배운 내용을 종합하는 예제를 만들어보자.
// 학생 클래스
public class Student {
private String name;
private String subject;
private int score;
public Student(String name, String subject, int score) {
this.name = name;
this.subject = subject;
this.score = score;
}
// getter 생략
public String getName() { return name; }
public String getSubject() { return subject; }
public int getScore() { return score; }
@Override
public String toString() {
return name + "(" + subject + ": " + score + ")";
}
}
List<Student> students = List.of(
new Student("김철수", "수학", 85),
new Student("이영희", "수학", 92),
new Student("박민수", "영어", 78),
new Student("최지은", "수학", 95),
new Student("정하늘", "영어", 88),
new Student("김철수", "영어", 70),
new Student("이영희", "영어", 96)
);
1. 수학 과목에서 90점 이상인 학생 이름
List<String> mathTop = students.stream()
.filter(s -> s.getSubject().equals("수학")) // 수학 과목만
.filter(s -> s.getScore() >= 90) // 90점 이상
.map(Student::getName) // 이름만 추출
.collect(Collectors.toList());
// [이영희, 최지은]
2. 과목별 평균 점수
Map<String, Double> avgBySubject = students.stream()
.collect(Collectors.groupingBy(
Student::getSubject, // 과목으로 그룹화
Collectors.averagingInt(Student::getScore) // 평균 계산
));
// {수학=90.67, 영어=83.0}
3. 전체 최고 점수 학생
Optional<Student> topStudent = students.stream()
.max(Comparator.comparingInt(Student::getScore));
topStudent.ifPresent(s ->
System.out.println("최고 점수: " + s)); // 이영희(영어: 96)
4. 학생별 전 과목 점수 합계
Map<String, Integer> totalByStudent = students.stream()
.collect(Collectors.groupingBy(
Student::getName, // 학생 이름으로 그룹화
Collectors.summingInt(Student::getScore) // 점수 합계
));
// {김철수=155, 이영희=188, 박민수=78, 최지은=95, 정하늘=88}
5. 80점 이상 학생 이름을 쉼표로 연결
String result = students.stream()
.filter(s -> s.getScore() >= 80)
.map(Student::getName)
.distinct() // 중복 이름 제거
.sorted() // 정렬
.collect(Collectors.joining(", "));
// "김철수, 이영희, 정하늘, 최지은"
이렇게 스트림을 쓰면 반복문과 임시 변수 없이도 복잡한 데이터 처리를 깔끔하게 할 수 있다.
스트림 주의사항
스트림이 편리하긴 하지만, 몇 가지 주의할 점이 있다.
1. 스트림은 재사용할 수 없다
Stream<String> stream = names.stream().filter(n -> n.length() > 3);
stream.forEach(System.out::println); // 정상 동작
stream.forEach(System.out::println); // IllegalStateException 발생!
한 번 소비된 스트림은 다시 쓸 수 없다. 다시 처리하고 싶으면 새 스트림을 만들어야 한다.
2. 디버깅이 어려울 수 있다
체이닝된 스트림 파이프라인은 중간에 브레이크포인트를 걸기 어렵다. peek()을 활용하면 중간 결과를 확인할 수 있다.
List<String> result = names.stream()
.filter(n -> n.length() > 3)
.peek(n -> System.out.println("필터 통과: " + n)) // 디버깅용 중간 확인
.map(String::toUpperCase)
.peek(n -> System.out.println("변환 결과: " + n)) // 디버깅용 중간 확인
.collect(Collectors.toList());
3. parallelStream은 신중하게
// 병렬 스트림 — 멀티 코어를 활용
List<Integer> result = numbers.parallelStream()
.filter(n -> n % 2 == 0)
.collect(Collectors.toList());
parallelStream()은 멀티 스레드로 처리해서 빠를 것 같지만, 항상 그런 건 아니다.
- 데이터가 적으면: 스레드 생성/관리 비용이 더 크다
- 순서가 중요하면: 순서 보장이 안 될 수 있다
- 공유 자원에 접근하면: 동시성 문제가 발생할 수 있다
- ForkJoinPool을 공유: 기본적으로 공통 ForkJoinPool을 쓰므로 다른 작업에 영향을 줄 수 있다
정말 데이터가 많고, 각 요소의 처리가 독립적이며, 순서가 중요하지 않을 때만 사용하자.
4. 무조건 스트림이 낫진 않다
// 단순 반복은 for문이 더 직관적일 수 있다
for (String name : names) {
if (name.startsWith("A")) {
System.out.println(name);
break; // 하나 찾으면 바로 종료
}
}
// 스트림으로 쓰면
names.stream()
.filter(n -> n.startsWith("A"))
.findFirst()
.ifPresent(System.out::println);
두 코드 모두 괜찮다. 무조건 스트림으로 바꿀 필요는 없다. 로직이 단순하면 for문이 더 읽기 쉬울 수도 있다. 중요한 건 팀의 컨벤션에 맞추는 것이다.
▸ TIP 이 글의 코드 예제를 직접 실행해보고 싶다면 Java 기본기 핸드북을 확인해보세요.
정리
이번 글에서 다룬 내용을 정리하면 다음과 같다.
| 개념 | 핵심 |
|---|---|
| 함수형 인터페이스 | 추상 메서드가 1개인 인터페이스. 람다의 타입이 된다 |
| 람다 표현식 | (params) -> body 형태로 함수형 인터페이스를 간결하게 구현 |
| 메서드 레퍼런스 | Class::method로 기존 메서드를 재사용 |
| 스트림 | 컬렉션을 함수형으로 처리하는 파이프라인 |
| 중간 연산 | filter, map, sorted, distinct, flatMap — 새 스트림 반환, 지연 평가 |
| 최종 연산 | collect, forEach, count, reduce — 스트림 소비, 파이프라인 실행 |
| Optional | null 대신 "값이 없을 수 있음"을 명시적으로 표현 |
기억해야 할 포인트 몇 가지를 남기자면 다음과 같다.
- Predicate(판별), Function(변환), Consumer(소비), Supplier(생성) — 이 네 가지 함수형 인터페이스만 기억해도 대부분 커버된다
- 스트림은 소스 → 중간 연산 → 최종 연산 순서로 흐른다. 최종 연산이 없으면 아무 일도 일어나지 않는다
- Optional은 반환 타입으로만 쓰고,
orElse와orElseGet의 차이를 알아두자 - parallelStream은 "빠르겠지?" 하고 무턱대고 쓰지 말 것
다음 글에서는 I/O와 NIO를 다룬다. InputStream부터 NIO의 Channel/Buffer까지, 자바의 입출력 체계를 한 번에 정리한다. 파일을 읽고 쓰는 방법이 궁금하다면 이어서 보자.