Devin.KR
로그인

자바 람다와 스트림 - 함수형 인터페이스 파이프라인 그리고 쓰지 말아야 할 때 (자바 중급 16단원)

개발자 조회 1

이 단원에서 배우는 것

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단원에서 FeePolicydefault 메서드를 여럿 넣고도 계속 람다로 쓸 수 있었던 이유가 이것이다.

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())보다 짧다
최종collectCollectors 로 원하는 형태로 수집
최종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

IntStreamfor 문과 큰 차이가 없다. 문제는 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 는 출력이나 알림 발송처럼 진짜 부수 효과가 목적일 때만 쓴다. 결과를 모으는 것은 collecttoList 의 일이다.

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.putnull 값을 허용하는데 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 에 의존하지 않는다.

스스로 확인하기

  1. List<LoanRecord> records 에서 "컴퓨터" 분류의 대출 건 중 연체료가 가장 큰 자료의 제목을 구하되, 해당하는 건이 없으면 "없음" 을 반환하는 코드를 스트림으로 작성하라.
  2. 다음 코드를 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();
  3. 아래 코드는 parallelStream() 으로 바꾸면 결과가 틀어질 수 있다. 어디가 문제이고 어떻게 고치는가.
    Map<String, Integer> count = new HashMap<>();
    records.stream().forEach(r -> count.merge(r.category(), 1, Integer::sum));

정답

  1. 다음과 같다. Optionalget() 으로 꺼내지 않고 map 으로 이어서 orElse 로 마무리하는 것이 요령이다.
    String title = records.stream()
            .filter(r -> "컴퓨터".equals(r.category()))
            .max(Comparator.comparingLong(LoanRecord::fee))
            .map(LoanRecord::title)
            .orElse("없음");
  2. (1) checked 예외인 IOException 을 람다 안에서 감싸느라 try-catch 가 파이프라인 한가운데 들어가 오히려 읽기 어려워졌다. (2) 예외가 났을 때 어느 파일에서 실패했는지 정보가 없다. for 문이면 현재 Path 를 메시지에 넣을 수 있다. (3) 파일 입출력은 원소마다 비용이 크고 실패가 빈번한 작업이라 continue 로 건너뛰거나 break 로 중단하는 제어가 필요해질 가능성이 높은데, 스트림에는 그 문법이 없다.
  3. forEach 안에서 외부의 HashMap 을 여러 스레드가 동시에 수정하게 된다. HashMap 은 스레드 안전하지 않아 원소가 누락되거나 내부 구조가 깨진다. 외부 상태를 건드리지 말고 collect 로 수집하면 순차·병렬 어느 쪽에서도 안전하다.
    Map<String, Long> count = records.stream()
            .collect(Collectors.groupingBy(LoanRecord::category, Collectors.counting()));

공식 문서: java.util.stream 패키지 개요 (Java 17 API)