자바 람다와 스트림 - 함수형 인터페이스 파이프라인 그리고 쓰지 말아야 할 때 (자바 중급 16단원)
이 단원에서 배우는 것
9단원에서 FeePolicy none = fee -> 0; 이라고 썼고, 12단원에서 loans.removeIf(isbn -> isbn.equals("B-002")) 를, 13단원에서 Predicate<T> 를 썼다. 화살표 문법을 "인터페이스 메서드 하나짜리를 짧게 쓴 것"이라고만 하고 넘어갔다. 중급 커리큘럼의 마지막 단원에서 그 정체를 정리한다.
람다와 스트림은 Java 8(2014)에서 들어온, 자바 역사상 가장 큰 문법 변화다. 그리고 실무에서 가장 많이 오용되는 기능이기도 하다. 이 단원은 쓰는 법만큼 쓰지 말아야 할 때에 지면을 쓴다. 대출 이력(LoanRecord)을 집계하는 관리자 통계 화면을 소재로 한다.
- 람다가 어떤 인터페이스로 변환되는지 알고
java.util.function의 표준 인터페이스를 쓴다 - 스트림 파이프라인을 중간 연산과 최종 연산으로 나눠 이해한다
- 스트림이 손해인 경우를 판단 기준과 실측으로 안다
왜 필요한가
10단원에서 만든 FeePolicy 같은 인터페이스를 Java 7까지는 이렇게 넘겨야 했다.
// Java 7 스타일 — 익명 클래스
Loan loan = new Loan("M-01", book, 15, new FeePolicy() {
@Override
public long discount(long fee) {
return fee / 2;
}
});
실제 로직은 fee / 2 한 줄인데 껍데기가 다섯 줄이다. 중요한 것이 부수적인 것에 파묻힌다. 람다는 이 껍데기를 지운다.
Loan loan = new Loan("M-01", book, 15, fee -> fee / 2);
그리고 이건 문법 설탕 이상이다. 동작을 값처럼 넘길 수 있게 되면 설계가 달라진다. "무엇을 할지"를 매개변수로 받는 메서드를 짤 수 있고, 그 결과가 removeIf, computeIfAbsent, Comparator.comparing 같은 API다.
스트림 쪽은 다른 문제를 푼다. 분류별 연체료 합계를 구하는 코드를 보자.
// 명령형 — 무엇을 하려는지가 코드 구조에 묻힌다
Map<String, Long> result = new HashMap<>();
for (LoanRecord r : records) {
if (r.fee() == 0) continue;
String key = r.category();
Long sum = result.get(key);
if (sum == null) sum = 0L;
result.put(key, sum + r.fee());
}
// 선언형 — 걸러내고, 묶고, 더한다
Map<String, Long> result = records.stream()
.filter(r -> r.fee() > 0)
.collect(Collectors.groupingBy(LoanRecord::category,
Collectors.summingLong(LoanRecord::fee)));
임시 변수도, 널 체크도, 반복 제어도 사라졌다. 대신 "무엇을 하는가"만 남는다. 이게 스트림의 가치다.
문법과 예제
람다는 함수형 인터페이스로 변환된다
람다는 그 자체로 타입이 없다. 추상 메서드가 정확히 하나인 인터페이스(함수형 인터페이스) 자리에 놓일 때 그 타입의 구현체로 변환된다.
@FunctionalInterface
public interface LoanValidator {
void validate(int overdueDays);
}
@FunctionalInterface 는 필수가 아니지만 붙이면 추상 메서드가 둘 이상이 되는 순간 컴파일 에러가 난다. 나중에 누군가 메서드를 하나 더 추가해 람다를 못 쓰게 만드는 사고를 막는다. 참고로 default, static 메서드는 몇 개를 넣어도 함수형 인터페이스 자격에 영향이 없다(10단원 참고). 10단원에서 FeePolicy 에 default 메서드를 여럿 넣고도 계속 람다로 쓸 수 있었던 이유가 이것이다.
LoanValidator v = days -> {
if (days < 0) throw new IllegalArgumentException("연체일 오류: " + days);
};
v.validate(3);
표준 함수형 인터페이스 — 직접 만들기 전에 여기부터
java.util.function 패키지에 자주 쓰는 모양이 이미 다 있다. 새로 정의하기 전에 여기 있는지 먼저 본다.
| 인터페이스 | 메서드 | 의미 | 도서관 예 |
|---|---|---|---|
Function<T,R> | R apply(T) | 변환 | LoanRecord → 연체료 |
Predicate<T> | boolean test(T) | 조건 검사 | 연체 상태인가 |
Consumer<T> | void accept(T) | 소비 | 연체 알림 발송 |
Supplier<T> | T get() | 공급 | 기본값 생성 |
BiFunction<T,U,R> | R apply(T,U) | 인자 2개 변환 | (하루 연체료, 연체일) → 총액 |
UnaryOperator<T> | T apply(T) | 같은 타입 변환 | 제목 정규화 |
import java.util.function.*;
public class FunctionDemo {
public static void main(String[] args) {
Function<Long, Long> doubled = fee -> fee * 2;
Predicate<String> notBlank = t -> !t.isBlank();
Supplier<String> defaultTitle = () -> "제목 없음";
BiFunction<Long, Integer, Long> totalFee = (dailyFee, days) -> dailyFee * days;
UnaryOperator<String> upper = String::toUpperCase;
System.out.println(doubled.apply(100L)); // 200
System.out.println(notBlank.test(" ")); // false
System.out.println(defaultTitle.get()); // 제목 없음
System.out.println(totalFee.apply(100L, 3)); // 300
System.out.println(upper.apply("java")); // JAVA
}
}
String::toUpperCase 가 메서드 참조다. s -> s.toUpperCase() 와 같고, 람다가 이미 있는 메서드를 그대로 호출하기만 할 때 쓴다. LoanRecord::fee(인스턴스 메서드), Integer::sum(정적 메서드), ArrayList::new(생성자) 형태가 있다.
스트림 파이프라인
스트림은 소스 → 중간 연산 여러 개 → 최종 연산 하나 로 구성된다. 중간 연산은 스트림을 반환하고, 최종 연산은 값이나 컬렉션을 반환한다.
public class LoanRecord {
private final String memberId;
private final String title;
private final String category;
private final int overdueDays;
private final long dailyFee;
public LoanRecord(String memberId, String title, String category, int overdueDays, long dailyFee) {
this.memberId = memberId; this.title = title; this.category = category;
this.overdueDays = overdueDays; this.dailyFee = dailyFee;
}
public String memberId() { return memberId; }
public String title() { return title; }
public String category() { return category; }
public int overdueDays() { return overdueDays; }
public long fee() { return overdueDays > 0 ? overdueDays * dailyFee : 0; }
}
import java.util.*;
import java.util.stream.*;
public class LoanStatDemo {
public static void main(String[] args) {
List<LoanRecord> records = List.of(
new LoanRecord("M-01", "자바의 정석", "컴퓨터", 3, 100),
new LoanRecord("M-01", "이펙티브 자바", "컴퓨터", 0, 100),
new LoanRecord("M-02", "매트릭스", "영상", 5, 500),
new LoanRecord("M-02", "코스모스", "과학", 12, 100),
new LoanRecord("M-03", "자바의 정석", "컴퓨터", 1, 100)
);
// 1. 전체 연체료
System.out.println(records.stream().mapToLong(LoanRecord::fee).sum());
// 2. 분류별 연체료 (선언 순서 유지하려면 Map 구현을 지정한다)
Map<String, Long> feeByCategory = records.stream()
.filter(r -> r.fee() > 0)
.collect(Collectors.groupingBy(LoanRecord::category,
LinkedHashMap::new,
Collectors.summingLong(LoanRecord::fee)));
System.out.println(feeByCategory);
// 3. 회원별 대출 자료 목록
Map<String, List<String>> titlesByMember = records.stream()
.collect(Collectors.groupingBy(LoanRecord::memberId,
LinkedHashMap::new,
Collectors.mapping(LoanRecord::title, Collectors.toList())));
System.out.println(titlesByMember);
// 4. 중복 제거 + 정렬 + 문자열 결합
System.out.println(records.stream()
.map(LoanRecord::title)
.distinct()
.sorted()
.collect(Collectors.joining(" / ")));
// 5. 연체료가 가장 큰 건
System.out.println(records.stream()
.max(Comparator.comparingLong(LoanRecord::fee))
.map(LoanRecord::title)
.orElse("연체 없음"));
// 6. 통계 한 번에
IntSummaryStatistics stat = records.stream()
.mapToInt(LoanRecord::overdueDays).summaryStatistics();
System.out.println("건수=" + stat.getCount() + " 총연체일=" + stat.getSum()
+ " 최대=" + stat.getMax() + " 평균=" + stat.getAverage());
}
}
4100
{컴퓨터=400, 영상=2500, 과학=1200}
{M-01=[자바의 정석, 이펙티브 자바], M-02=[매트릭스, 코스모스], M-03=[자바의 정석]}
매트릭스 / 이펙티브 자바 / 자바의 정석 / 코스모스
매트릭스
건수=5 총연체일=21 최대=12 평균=4.2
자주 쓰는 연산 정리
| 분류 | 연산 | 설명 |
|---|---|---|
| 중간 | filter | 조건에 맞는 것만 남긴다 |
| 중간 | map / mapToLong | 각 원소를 변환한다 |
| 중간 | flatMap | 중첩 구조를 한 겹 편다 |
| 중간 | distinct / sorted / limit | 중복 제거 / 정렬 / 개수 제한 |
| 최종 | toList (Java 16+) | 불변 리스트로 수집. collect(Collectors.toList())보다 짧다 |
| 최종 | collect | Collectors 로 원하는 형태로 수집 |
| 최종 | sum / count / max / min | 집계 |
| 최종 | anyMatch / allMatch / findFirst | 검사와 탐색. 조건을 만족하면 즉시 멈춘다 |
// flatMap — 회원별 대출 목록을 하나로 편다
List<List<String>> nested = List.of(List.of("자바의 정석", "매트릭스"), List.of("코스모스"));
System.out.println(nested.stream().flatMap(List::stream).toList());
// [자바의 정석, 매트릭스, 코스모스]
// 정렬 조건 두 개 — 회원번호 오름차순, 같으면 연체료 내림차순
List<LoanRecord> sorted = records.stream()
.sorted(Comparator.comparing(LoanRecord::memberId)
.thenComparing(Comparator.comparingLong(LoanRecord::fee).reversed()))
.toList();
중간 연산은 지연 실행된다
Stream.of("a", "b").map(x -> { System.out.println("실행됨 " + x); return x; });
System.out.println("위 map 은 아무것도 출력하지 않는다");
위 map 은 아무것도 출력하지 않는다
최종 연산이 없으면 중간 연산은 한 번도 실행되지 않는다. 이 지연 실행 덕분에 findFirst() 같은 연산이 필요한 만큼만 처리하고 멈출 수 있다. 반대로 "스트림을 만들어놨는데 아무 일도 안 일어난다"는 증상의 원인이기도 하다.
언제 쓰지 말아야 하는가
여기가 이 단원에서 가장 중요한 부분이다. 스트림은 만능이 아니다.
1. 기본형 박싱이 끼면 크게 느려진다
2천만 건 합계를 Java 17에서 재봤다.
int n = 20_000_000;
long s = 0;
for (int i = 0; i < n; i++) s += i; // for
IntStream.range(0, n).asLongStream().sum(); // IntStream
Stream.iterate(0, i -> i + 1).limit(n).mapToLong(i -> i).sum(); // Stream<Integer>
for : 17ms
IntStream : 31ms
boxed : 265ms
IntStream 은 for 문과 큰 차이가 없다. 문제는 Stream<Integer> 다. 15배 느리다. 원소마다 Integer 객체를 만들고 다시 꺼내는(박싱/언박싱) 비용이 붙기 때문이다. 숫자를 다룰 때는 Stream<Integer> 가 아니라 IntStream/LongStream/DoubleStream 을, 변환할 때는 map 이 아니라 mapToInt/mapToLong 을 쓴다. 위 예제에서 연체료 합계에 mapToLong 을 쓴 이유가 이것이다.
2. 반복문 안에서 break, continue, return 이 필요할 때
// 스트림으로는 이 구조를 그대로 옮길 수 없다
for (LoanRecord r : records) {
if (r.overdueDays() < 0) {
log.error("연체일 오류 memberId={} title={}", r.memberId(), r.title());
return ErrorCode.INVALID_DATA; // 메서드를 즉시 빠져나간다
}
process(r);
}
람다 안에서는 바깥 메서드를 return 할 수 없고, break/continue 도 없다. 억지로 스트림으로 옮기면 anyMatch 로 검사하고 다시 순회하는 식으로 두 번 도는 코드가 되거나, 예외를 흐름 제어에 쓰는 나쁜 코드가 된다. 이럴 때는 그냥 for 문이 옳다.
3. 람다 안에서 checked 예외를 던져야 할 때
// 컴파일 에러 — Function.apply 는 IOException 을 던지지 않는다
List<String> contents = paths.stream()
.map(p -> Files.readString(p, StandardCharsets.UTF_8))
.toList();
표준 함수형 인터페이스는 checked 예외를 선언하지 않는다. try-catch 로 감싸 RuntimeException 으로 바꾸는 코드를 람다 안에 넣으면 가독성이 순식간에 무너진다. 15단원에서 다룬 파일 입출력이나 JDBC처럼 checked 예외가 나오는 작업은 for 문으로 쓰는 편이 낫다.
4. 세 단계를 넘어가고 중간에 이름이 필요할 때
파이프라인이 길어지면 디버깅이 어려워진다. 중간 값을 찍어보려면 peek 를 넣어야 하고, 스택 트레이스는 람다 이름으로 가득 찬다. 대략적인 기준은 이렇다. 연산 3~4개를 넘고 중간 결과에 이름을 붙이고 싶어지면 파이프라인을 쪼개거나 for 문으로 돌아간다. 스트림의 목적은 짧게 쓰는 것이 아니라 읽기 쉽게 쓰는 것이다.
5. 병렬 스트림은 기본적으로 쓰지 않는다
records.parallelStream() // 대부분의 경우 이득이 없거나 손해다
앞의 벤치마크 조건(2천만 건, 원소별 작업이 단순, 다른 작업 없음)에서 parallel() 은 4ms까지 내려간다. 하지만 그건 이상적 조건이었다. 실무 조건은 다르다.
- 기본적으로 JVM 전체가 공유하는 공통 ForkJoinPool 하나를 쓴다. 웹 애플리케이션에서 요청마다 병렬 스트림을 돌리면 서로 스레드를 뺏는다.
- 원소 수가 적거나(수천 건 이하) 원소별 작업이 가벼우면 분할과 병합 비용이 이득보다 크다. 대출 이력 몇백 건 집계에는 아무 이득이 없다.
LinkedList,Files.lines()처럼 균등 분할이 어려운 소스는 병렬화 효과가 거의 없다.- 람다에 공유 상태가 있으면 결과가 틀어진다.
병렬 스트림은 "느려서 고쳐야 할 때, 측정한 뒤에" 도입한다. 습관적으로 쓰는 대상이 아니다.
실무에서 자주 틀리는 것
1. forEach 안에서 외부 컬렉션에 담는다
// 나쁜 코드 — 스트림을 for 문처럼 쓰고 있다
List<String> overdueTitles = new ArrayList<>();
records.stream().filter(r -> r.fee() > 0)
.forEach(r -> overdueTitles.add(r.title())); // 외부 상태를 건드린다
// 올바른 코드
List<String> overdueTitles = records.stream()
.filter(r -> r.fee() > 0)
.map(LoanRecord::title)
.toList();
위 코드는 순차 스트림에서는 동작하지만, 누군가 parallelStream() 으로 바꾸는 순간 여러 스레드가 ArrayList 를 동시에 건드려 원소가 누락되거나 예외가 난다. forEach 는 출력이나 알림 발송처럼 진짜 부수 효과가 목적일 때만 쓴다. 결과를 모으는 것은 collect 나 toList 의 일이다.
2. Collectors.toMap 의 중복 키를 생각하지 않는다
Map<String, Long> feeByMember = records.stream()
.collect(Collectors.toMap(LoanRecord::memberId, LoanRecord::fee));
Exception in thread "main" java.lang.IllegalStateException:
Duplicate key M-01 (attempted merging values 300 and 0)
한 회원이 두 건 이상 빌리면 바로 터진다. 개발 중에는 테스트 데이터가 회원당 한 건씩이라 안 나고, 운영에서 난다. 병합 방식을 항상 세 번째 인자로 명시한다.
Map<String, Long> feeByMember = records.stream()
.collect(Collectors.toMap(LoanRecord::memberId, LoanRecord::fee,
Long::sum, LinkedHashMap::new));
// {M-01=300, M-02=3700, M-03=100}
참고로 toMap 의 값이 null 이면 NullPointerException 이 난다. HashMap.put 은 null 값을 허용하는데 toMap 은 아니라는 점이 자주 걸린다.
3. 스트림을 두 번 쓰려 한다
Stream<LoanRecord> s = records.stream();
long count = s.count();
long sum = s.mapToLong(LoanRecord::fee).sum(); // IllegalStateException
java.lang.IllegalStateException: stream has already been operated upon or closed
스트림은 한 번 쓰면 끝이다. 컬렉션처럼 변수에 담아두고 재사용할 수 없다. 두 가지가 필요하면 소스에서 스트림을 두 번 만들거나, summaryStatistics() 처럼 한 번에 여러 값을 주는 연산을 쓴다.
4. 람다가 잡는 변수는 사실상 final 이어야 한다
long total = 0;
records.forEach(r -> total += r.fee());
// 컴파일 에러: local variables referenced from a lambda expression
// must be final or effectively final
람다는 바깥 지역 변수를 값으로 복사해서 가지고 간다. 값이 바뀌면 복사본과 원본이 달라지므로 아예 금지한다. 이 규칙을 우회하려고 long[] total = {0}; 같은 배열 트릭을 쓰는 코드를 종종 보는데, 병렬 실행에서 안전하지 않고 의도도 감춰진다. 합계는 mapToLong(...).sum() 으로 구한다.
5. peek 를 로깅 용도로 믿는다
List<String> result = records.stream()
.peek(r -> System.out.println("처리: " + r.title()))
.map(LoanRecord::title)
.toList();
peek 는 디버깅 보조 목적으로 만들어진 것이라 최종 연산이 무엇이냐에 따라 호출 횟수가 달라지거나 아예 생략될 수 있다. 예를 들어 count() 앞에서는 원소를 실제로 훑을 필요가 없다고 판단되면 peek 가 실행되지 않는다. 개발 중 잠깐 넣어보는 용도로만 쓰고, 운영 로그를 peek 에 의존하지 않는다.
스스로 확인하기
List<LoanRecord> records에서 "컴퓨터" 분류의 대출 건 중 연체료가 가장 큰 자료의 제목을 구하되, 해당하는 건이 없으면"없음"을 반환하는 코드를 스트림으로 작성하라.- 다음 코드를
for문으로 바꾸는 편이 나은 이유를 세 가지 대라.List<String> contents = paths.stream() .map(p -> { try { return Files.readString(p, StandardCharsets.UTF_8); } catch (IOException e) { throw new UncheckedIOException(e); } }) .filter(s -> !s.isBlank()) .toList(); - 아래 코드는
parallelStream()으로 바꾸면 결과가 틀어질 수 있다. 어디가 문제이고 어떻게 고치는가.Map<String, Integer> count = new HashMap<>(); records.stream().forEach(r -> count.merge(r.category(), 1, Integer::sum));
정답
- 다음과 같다.
Optional을get()으로 꺼내지 않고map으로 이어서orElse로 마무리하는 것이 요령이다.String title = records.stream() .filter(r -> "컴퓨터".equals(r.category())) .max(Comparator.comparingLong(LoanRecord::fee)) .map(LoanRecord::title) .orElse("없음"); - (1) checked 예외인
IOException을 람다 안에서 감싸느라try-catch가 파이프라인 한가운데 들어가 오히려 읽기 어려워졌다. (2) 예외가 났을 때 어느 파일에서 실패했는지 정보가 없다.for문이면 현재Path를 메시지에 넣을 수 있다. (3) 파일 입출력은 원소마다 비용이 크고 실패가 빈번한 작업이라continue로 건너뛰거나break로 중단하는 제어가 필요해질 가능성이 높은데, 스트림에는 그 문법이 없다. forEach안에서 외부의HashMap을 여러 스레드가 동시에 수정하게 된다.HashMap은 스레드 안전하지 않아 원소가 누락되거나 내부 구조가 깨진다. 외부 상태를 건드리지 말고collect로 수집하면 순차·병렬 어느 쪽에서도 안전하다.Map<String, Long> count = records.stream() .collect(Collectors.groupingBy(LoanRecord::category, Collectors.counting()));