공공부하자개발 · 영어 학습 노트
자바
고급모던 자바와 성능0/10 완료
  • 01제네릭과 와일드카드
  • 02멀티스레드와 동기화
  • 03람다식과 함수형 인터페이스
  • 04Stream API와 병렬 처리
  • 05Optional로 NPE 방지
  • 06메서드 활용 패턴 (고급)
  • 07Java 21 모던 문법
  • 08어노테이션·리플렉션·동적 프록시
  • 09CompletableFuture 심화와 가상 스레드 실전
  • 10JVM 메모리·GC·OOM 진단
사이트 소개개인정보처리방침연락처
© 2026 공부하자
홈 › 고급 › 03 / 10

람다식과 함수형 인터페이스

섹션 7진행 0 / 10
1왜 배우는가2핵심 원리3코드 예제4응용 변형 예제5자주 하는 실수 (Tip)6연습 문제7정리‹ 이전다음 ›

4. 응용 변형 예제

3절의 예제들은 "람다로 무엇을 할 수 있는가"를 보여 줬습니다. 여기서는 같은 개념을 다른 각도에서 씁니다 — 세 가지 문법을 나란히 비교하고, 체크 예외·커링·메모이제이션 같은 유틸로 추출하고, 정산 데이터와 null 엣지 케이스에 적용해 봅니다.

변형 1: 같은 정렬을 익명 클래스 → 람다 → 메서드 참조로

2.1 의 변천사를 실제로 실행해 세 방식이 같은 Comparator 계약을 만든다는 것을 확인합니다. 예제 5 가 완성형만 보여 줬다면 여기서는 세 형태를 나란히 두고, 만들어진 Comparator 를 값처럼 reversed() 로 재조합합니다. 레거시 코드를 리팩터링할 때 "익명 클래스 → 람다 → 메서드 참조" 순서로 한 단계씩 바꾸면 안전합니다.

java
import java.util.*;

public class SortEvolution {
    record Order(String id, long amount) {}

    static List<Order> sample() {
        return new ArrayList<>(List.of(new Order("O3", 9000), new Order("O1", 3000), new Order("O2", 5000)));
    }

    public static void main(String[] args) {
        // 1) 익명 클래스: 6줄 중 로직은 1줄
        List<Order> a = sample();
        a.sort(new Comparator<Order>() {
            @Override
            public int compare(Order x, Order y) {
                return Long.compare(x.amount(), y.amount());
            }
        });
        System.out.println("anonymous : " + a.stream().map(Order::id).toList());

        // 2) 람다: 로직만 남음
        List<Order> b = sample();
        b.sort((x, y) -> Long.compare(x.amount(), y.amount()));
        System.out.println("lambda    : " + b.stream().map(Order::id).toList());

        // 3) 메서드 참조 + Comparator 유틸: 의도(무엇으로 정렬)만 남음
        List<Order> c = sample();
        c.sort(Comparator.comparingLong(Order::amount));
        System.out.println("methodRef : " + c.stream().map(Order::id).toList());

        // 세 방식은 같은 Comparator 계약을 구현한다 → 서로 교체 가능
        Comparator<Order> byAmount = Comparator.comparingLong(Order::amount);
        System.out.println("reversed  : " + sample().stream().sorted(byAmount.reversed()).map(Order::id).toList());
    }
}
// 출력:
// anonymous : [O1, O2, O3]
// lambda    : [O1, O2, O3]
// methodRef : [O1, O2, O3]
// reversed  : [O3, O2, O1]

변형 2: 체크 예외를 감싸는 제네릭 ThrowingFunction 유틸

5절 실수 3 의 "람다 안 try-catch" 를 매번 쓰는 대신, 체크 예외를 던질 수 있는 함수형 인터페이스를 정의하고 표준 Function 으로 바꿔 주는 유틸을 만듭니다. unchecked 는 첫 실패에서 즉시 멈추고, lenient 는 실패 항목을 Optional.empty 로 흘려보내 부분 실패를 허용합니다. 파일·네트워크처럼 체크 예외가 많은 API 를 스트림에 꽂을 때 씁니다.

java
import java.io.*;
import java.nio.file.*;
import java.util.*;
import java.util.function.Function;

public class ThrowingWrap {
    // 체크 예외를 던질 수 있는 함수형 인터페이스
    @FunctionalInterface
    interface ThrowingFunction<T, R, E extends Exception> {
        R apply(T t) throws E;
    }

    // 체크 예외를 언체크로 감싸 표준 Function 으로 변환하는 재사용 유틸
    static <T, R, E extends Exception> Function<T, R> unchecked(ThrowingFunction<T, R, E> f) {
        return t -> {
            try { return f.apply(t); }
            catch (RuntimeException e) { throw e; }
            catch (Exception e) { throw new RuntimeException(e); }
        };
    }

    // 실패한 항목은 건너뛰고 Optional.empty 로 표현하는 변형 (부분 실패 허용)
    static <T, R, E extends Exception> Function<T, Optional<R>> lenient(ThrowingFunction<T, R, E> f) {
        return t -> {
            try { return Optional.ofNullable(f.apply(t)); }
            catch (Exception e) { return Optional.empty(); }
        };
    }

    public static void main(String[] args) throws IOException {
        Path dir = Files.createTempDirectory("lam");
        Path ok1 = Files.writeString(dir.resolve("a.txt"), "hello");
        Path ok2 = Files.writeString(dir.resolve("b.txt"), "lambda");
        Path missing = dir.resolve("missing.txt");

        // Files.readString 은 IOException 을 던져 map(Files::readString) 이 컴파일되지 않는다
        List<String> texts = List.of(ok1, ok2).stream().map(unchecked(Files::readString)).toList();
        System.out.println(texts);

        try {
            List.of(ok1, missing).stream().map(unchecked(Files::readString)).toList();
        } catch (RuntimeException e) {
            System.out.println("wrapped: " + e.getCause().getClass().getSimpleName());
        }

        // 부분 실패: 없는 파일은 empty 로 흘려보내고 나머지는 계속
        List<String> partial = List.of(ok1, missing, ok2).stream()
            .map(lenient(Files::readString))
            .flatMap(Optional::stream)
            .toList();
        System.out.println(partial);
    }
}
// 출력:
// [hello, lambda]
// wrapped: NoSuchFileException
// [hello, lambda]

변형 3: 커링(부분 적용)과 메모이제이션 유틸

예제 1 은 Map<PayType, LongUnaryOperator> 에 완성된 함수를 넣었습니다. 여기서는 Function<Integer, LongUnaryOperator> 처럼 함수를 반환하는 함수로 요율만 먼저 고정(부분 적용)하고, 같은 입력을 반복 계산하지 않도록 memoize 로 감쌉니다. 요율·환율처럼 "설정값 하나를 고정한 함수"를 여러 곳에 전달하거나, 비싼 순수 함수의 호출 수를 줄일 때 택합니다.

java
import java.util.*;
import java.util.function.*;

public class CurryMemo {
    // 커링: (rate, amount) -> fee 를 rate -> (amount -> fee) 로 바꿔 부분 적용
    static Function<Integer, LongUnaryOperator> feeOf = rate -> amount -> amount * rate / 100;

    // 메모이제이션: 같은 입력은 한 번만 계산하는 재사용 유틸 (단일 스레드용)
    static <T, R> Function<T, R> memoize(Function<T, R> f) {
        Map<T, R> cache = new HashMap<>();
        return t -> cache.computeIfAbsent(t, f);
    }

    static int calls = 0;
    static long slowSquare(long n) { calls++; return n * n; }

    public static void main(String[] args) {
        LongUnaryOperator cardFee = feeOf.apply(3);     // 3% 고정 → 이후엔 금액만 넘김
        LongUnaryOperator kakaoFee = feeOf.apply(2);
        System.out.println(cardFee.applyAsLong(10_000) + " " + kakaoFee.applyAsLong(10_000));

        // 부분 적용된 함수를 값으로 보관해 설정처럼 사용
        Map<String, LongUnaryOperator> byType = Map.of("CARD", cardFee, "KAKAO", kakaoFee);
        System.out.println(byType.get("KAKAO").applyAsLong(50_000));

        Function<Long, Long> fast = memoize(CurryMemo::slowSquare);
        long sum = 0;
        for (long n : new long[]{7, 8, 7, 7, 8, 9}) sum += fast.apply(n);
        System.out.println("sum=" + sum + " calls=" + calls);   // 6번 호출했지만 계산은 3번
    }
}
// 출력:
// 300 200
// 1000
// sum=356 calls=3

변형 4: 정산 수수료 규칙 — 조건부 전략 목록과 fallback

예제 1 의 Map<PayType, 함수> 는 "타입 하나 = 규칙 하나"일 때만 맞습니다. 실무 정산은 "대형 가맹점 카드는 2%, 일반 카드는 3%"처럼 조건이 겹치는 규칙의 우선순위 목록입니다.

Predicate(적용 조건) + ToLongFunction(수수료) 쌍을 순서대로 두고 첫 매치를 쓰며, 매치가 없으면 예외를 던지는 fallback 함수로 넘깁니다. 미지원 결제수단 같은 부분 실패는 정산 자체를 멈추지 않고 따로 모읍니다.

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

public class SettlementFees {
    enum PayType { CARD, BANK, POINT, CRYPTO }
    record Payment(String merchant, PayType type, long amount) {}
    record FeeLine(String merchant, long gross, long fee, long net) {}

    // 규칙: 수수료 함수 + 적용 조건(Predicate). 조건이 맞는 첫 규칙을 쓴다
    record Rule(String name, Predicate<Payment> when, ToLongFunction<Payment> fee) {}

    static final List<Rule> RULES = List.of(
        new Rule("대형가맹점 카드 2%", p -> p.type() == PayType.CARD && p.amount() >= 1_000_000, p -> p.amount() * 2 / 100),
        new Rule("카드 3%",           p -> p.type() == PayType.CARD,                            p -> p.amount() * 3 / 100),
        new Rule("계좌 500원 상한",    p -> p.type() == PayType.BANK,                            p -> Math.min(500, p.amount() / 100)),
        new Rule("포인트 무료",        p -> p.type() == PayType.POINT,                           p -> 0L)
    );
    static final ToLongFunction<Payment> FALLBACK = p -> { throw new IllegalArgumentException("미지원 결제수단: " + p.type()); };

    static long fee(Payment p) {
        return RULES.stream().filter(r -> r.when().test(p)).findFirst()
                    .map(Rule::fee).orElse(FALLBACK).applyAsLong(p);
    }

    public static void main(String[] args) {
        List<Payment> payments = List.of(
            new Payment("m1", PayType.CARD, 2_000_000), new Payment("m1", PayType.CARD, 10_000),
            new Payment("m2", PayType.BANK, 300_000),   new Payment("m2", PayType.POINT, 5_000),
            new Payment("m3", PayType.CRYPTO, 1_000));

        // 가맹점별 정산: 부분 실패(미지원 결제수단)는 따로 모은다
        Map<String, List<Payment>> byMerchant = payments.stream()
            .collect(Collectors.groupingBy(Payment::merchant, TreeMap::new, Collectors.toList()));
        List<String> rejected = new ArrayList<>();
        for (var e : byMerchant.entrySet()) {
            long gross = 0, fee = 0;
            for (Payment p : e.getValue()) {
                try { fee += fee(p); gross += p.amount(); }
                catch (IllegalArgumentException ex) { rejected.add(p.merchant() + ": " + ex.getMessage()); }
            }
            System.out.println(new FeeLine(e.getKey(), gross, fee, gross - fee));
        }
        System.out.println("rejected = " + rejected);
    }
}
// 출력:
// FeeLine[merchant=m1, gross=2010000, fee=40300, net=1969700]
// FeeLine[merchant=m2, gross=305000, fee=500, net=304500]
// FeeLine[merchant=m3, gross=0, fee=0, net=0]
// rejected = [m3: 미지원 결제수단: CRYPTO]

변형 5: null 안전 Comparator 와 빈 리스트, 지연 Supplier

예제 5 의 Comparator 체인은 모든 키가 non-null 이라고 가정했습니다. 실제 데이터는 요소 자체가 null 이거나 정렬 키가 null 일 수 있고, 그때 comparing(Customer::tier) 는 NPE 를 던집니다.

바깥에는 nullsLast, 키에는 nullsLast(naturalOrder()) 를 겹쳐 요소·키 두 층을 모두 방어하고, 빈 리스트에서 sort/max 가 예외 없이 동작하는지, 그리고 Supplier 로 넘긴 기본값이 필요할 때만 계산되는지 확인합니다.

java
import java.util.*;
import java.util.function.Supplier;
import static java.util.Comparator.*;

public class NullSafeSort {
    record Customer(String name, String tier, Integer age) {}

    public static void main(String[] args) {
        List<Customer> list = new ArrayList<>(Arrays.asList(
            new Customer("kim", "GOLD", 30),
            new Customer("lee", null, 25),
            new Customer("park", "GOLD", null),
            null,
            new Customer("choi", "SILVER", 40)));

        // 요소 자체가 null 이면 comparing(Customer::tier) 에서 NPE → 바깥에 nullsLast, 키에도 nullsLast
        Comparator<Customer> safe = nullsLast(
            comparing(Customer::tier, nullsLast(naturalOrder()))
                .thenComparing(Customer::age, nullsFirst(naturalOrder())));
        list.sort(safe);
        list.forEach(c -> System.out.println(c == null ? "null" : c.name() + " " + c.tier() + " " + c.age()));

        // 빈 리스트: 정렬도 max 도 예외 없이 동작해야 한다
        List<Customer> empty = new ArrayList<>();
        empty.sort(safe);
        System.out.println("empty max = " + empty.stream().max(safe).isPresent());

        // null 키 없이 안전한 기본값으로 대체하는 대안
        Comparator<Customer> withDefault = comparing(c -> c.tier() == null ? "ZZZ" : c.tier());
        System.out.println(list.stream().filter(Objects::nonNull).sorted(withDefault).map(Customer::name).toList());

        // 지연 평가: 기본값 계산을 Supplier 로 미루면 필요할 때만 실행된다
        int[] built = {0};
        Supplier<String> lazyDefault = () -> { built[0]++; return "GUEST"; };
        String tier1 = Optional.ofNullable(list.get(0).tier()).orElseGet(lazyDefault);  // 있음 → 미실행
        String tier2 = Optional.ofNullable((String) null).orElseGet(lazyDefault);       // 없음 → 실행
        System.out.println(tier1 + " " + tier2 + " built=" + built[0]);
    }
}
// 출력:
// park GOLD null
// kim GOLD 30
// choi SILVER 40
// lee null 25
// null
// empty max = false
// [park, kim, choi, lee]
// GOLD GUEST built=1
응용 변형 예제
  • 변형 1: 같은 정렬을 익명 클래스 → 람다 → 메서드 참조로
  • 변형 2: 체크 예외를 감싸는 제네릭 ThrowingFunction 유틸
  • 변형 3: 커링(부분 적용)과 메모이제이션 유틸
  • 변형 4: 정산 수수료 규칙 — 조건부 전략 목록과 fallback
  • 변형 5: null 안전 Comparator 와 빈 리스트, 지연 Supplier
이전 섹션3 코드 예제4 / 7다음 섹션5 자주 하는 실수 (Tip)