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

메서드 활용 패턴 (고급)

섹션 6진행 0 / 10
1왜 배우는가2핵심 원리3패턴 모음4자주 하는 실수 (Tip)5연습 문제6정리‹ 이전다음 ›

3. 패턴 모음

각 패턴의 코드는 java-src/advanced/06_method_patterns/에서 실행해 볼 수 있습니다. Fn(함수 유틸), Discount, Rule, EventBus가 보조 클래스입니다.

패턴 1: 메서드 참조 4종 재정리

람다 x -> foo(x)처럼 인자를 그대로 넘기기만 한다면 메서드 참조로 줄입니다. 네 종류가 있고, String::length(임의 객체)와 Integer::parseInt(static)는 문법이 같으니 메서드가 static인지로 구분합니다.

java
Function<String, Integer> parse = Integer::parseInt;       // 1. static 메서드
String prefix = "LOG: ";
Function<String, String> tag = prefix::concat;             // 2. 특정 객체의 인스턴스 메서드
Function<String, Integer> len = String::length;            // 3. 임의 객체의 인스턴스 메서드 (첫 인자가 this)
Supplier<List<String>> newList = ArrayList::new;           // 4. 생성자

System.out.println(parse.apply("42") + 1);      // 출력: 43
System.out.println(tag.apply("start"));         // 출력: LOG: start
System.out.println(len.apply("hello"));         // 출력: 5
List<String> l = newList.get(); l.add("x");
System.out.println(l);                          // 출력: [x]

주의: prefix::concat은 참조를 만드는 시점의 prefix 객체에 묶입니다. 나중에 prefix 변수를 다른 값으로 바꿔도(final이 아니라면) 참조는 옛 객체를 씁니다.

패턴 2: 함수를 받는 메서드

"두 번 적용한다"는 동작은 무엇을 적용하든 같습니다. 그 "무엇"을 UnaryOperator<T>로 받으면 숫자에도 문자열에도 쓰는 하나의 메서드가 됩니다. 제네릭 + 함수형 인터페이스 매개변수가 고차 함수의 기본 형태입니다.

java
static <T> T applyTwice(UnaryOperator<T> f, T x) { return f.apply(f.apply(x)); }

System.out.println(applyTwice(x -> x * 3, 2));                       // 출력: 18
System.out.println(applyTwice(String::trim, "  hi  ").length());     // 출력: 2

주의: 매개변수 타입은 가장 넓은 함수형 인터페이스를 고릅니다. T -> T면 UnaryOperator<T>, T -> R이면 Function<T, R>, 결과가 boolean이면 Predicate<T>.

패턴 3: 함수를 반환하는 메서드

"n을 더하는 함수"를 만들어 돌려줍니다. 람다가 매개변수 n을 캡처하므로 adder(5)와 adder(10)은 서로 다른 값을 기억하는 독립된 함수입니다. 설정값에 따라 동작이 달라지는 함수를 만드는 함수 팩토리입니다.

java
static IntUnaryOperator adder(int n) { return x -> x + n; }          // n을 캡처
static Predicate<String> longerThan(int n) { return s -> s.length() > n; }

IntUnaryOperator addFive = adder(5);
IntUnaryOperator addTen = adder(10);
System.out.println(addFive.applyAsInt(1) + " " + addTen.applyAsInt(1));       // 출력: 6 11
Predicate<String> longerThan3 = longerThan(3);
System.out.println(longerThan3.test("java") + " " + longerThan3.test("go"));  // 출력: true false

주의: 캡처되는 변수는 effectively final이어야 합니다. 메서드 안에서 n++을 하면 컴파일 오류입니다.

패턴 4: 커링(currying)

두 인자 함수 (a, b) -> a * b를 "a를 받아서 b를 받는 함수를 돌려주는" 형태로 바꿉니다. 첫 인자만 적용한 curried.apply(3)이 그 자체로 "3배 함수"가 되어 재사용됩니다. 제네릭 유틸로 한 번 만들어 두면 어떤 BiFunction에도 적용됩니다.

java
public static <A, B, C> Function<A, Function<B, C>> curry(BiFunction<A, B, C> f) {
    return a -> b -> f.apply(a, b);          // a -> (b -> f(a, b))
}

BiFunction<Integer, Integer, Integer> mul = (a, b) -> a * b;
Function<Integer, Function<Integer, Integer>> curried = Fn.curry(mul);
System.out.println(curried.apply(3).apply(4));       // 출력: 12
Function<Integer, Integer> triple = curried.apply(3);
System.out.println(triple.apply(10));                // 출력: 30

주의: 타입 선언이 길어져 자바에서는 커링을 직접 쓰는 일이 드뭅니다. 개념은 알아 두되, 실무에서는 패턴 5의 부분 적용이 더 자연스럽습니다.

패턴 5: 부분 적용(partial application)

인자 일부를 미리 고정한 새 함수를 만듭니다. "인사말은 Hello로 고정, 이름만 바뀐다"처럼 설정은 한 번, 호출은 여러 번인 상황에 씁니다. 커링과 달리 원래 함수 모양을 바꾸지 않고 필요한 인자만 묶습니다.

java
public static <A, B, C> Function<B, C> partial(BiFunction<A, B, C> f, A a) {
    return b -> f.apply(a, b);
}

BiFunction<String, String, String> greet = (greeting, name) -> greeting + ", " + name + "!";
Function<String, String> hello = Fn.partial(greet, "Hello");
System.out.println(hello.apply("Kim"));     // 출력: Hello, Kim!
System.out.println(hello.apply("Lee"));     // 출력: Hello, Lee!

주의: 고정한 인자가 가변 객체면 나중에 바뀔 수 있습니다. 부분 적용에는 불변 값을 넘기세요.

패턴 6: 함수 합성 andThen / compose

작은 함수를 이어 붙여 큰 함수를 만듭니다. trim.andThen(upper).andThen(length)는 "다듬고, 대문자로, 길이"를 그 순서로 실행합니다. compose는 순서가 반대입니다. 합성된 함수는 그 자체로 변수에 담거나 다른 메서드에 넘길 수 있습니다.

java
Function<String, String> trim = String::trim;
Function<String, String> upper = String::toUpperCase;
Function<String, Integer> length = String::length;
Function<String, Integer> pipeline = trim.andThen(upper).andThen(length);
System.out.println(pipeline.apply("  java  "));        // 출력: 4

Function<Integer, Integer> plus1 = x -> x + 1;
Function<Integer, Integer> times2 = x -> x * 2;
System.out.println(plus1.andThen(times2).apply(5));    // 출력: 12   ((5+1)*2)
System.out.println(plus1.compose(times2).apply(5));    // 출력: 11   ((5*2)+1)

주의: 메서드 참조에는 .andThen을 직접 붙일 수 없습니다(String::trim.andThen(...)은 오류). 먼저 Function 변수에 담거나 캐스팅합니다.

패턴 7: Predicate 합성 and / or / negate

조건을 작은 Predicate로 만들고 논리 연산으로 조립합니다. notBlank.and(shortStr.negate())는 "비어 있지 않고 짧지 않다"로 읽힙니다. 복잡한 if 조건을 이름 있는 조각으로 나누고, 스트림 filter에 그대로 넘길 수 있습니다.

java
Predicate<String> notBlank = s -> !s.isBlank();
Predicate<String> shortStr = s -> s.length() < 5;
Predicate<String> valid = notBlank.and(shortStr.negate());

System.out.println(valid.test("hello world"));   // 출력: true
System.out.println(valid.test("hi"));            // 출력: false
System.out.println(valid.test("   "));           // 출력: false

주의: and는 단락 평가(short-circuit)합니다. 앞 조건이 false면 뒤는 실행되지 않으므로 null 검사를 앞에 두면 안전합니다. Predicate.not(String::isBlank)도 있습니다.

패턴 8: 제네릭 메서드 — 타입 추론

<T>를 선언하면 컴파일러가 인자에서 T를 추론합니다. firstOr(List.of("a"), "없음")에서 T = String, firstOr(List.of(), 0)에서는 대입 대상이 없어도 두 번째 인자로 T = Integer를 알아냅니다. 타입 매개변수가 둘이면 <A, B>.

java
static <T> T firstOr(List<T> list, T fallback) { return list.isEmpty() ? fallback : list.get(0); }
static <A, B> String pair(A a, B b) { return "(" + a + ", " + b + ")"; }

System.out.println(firstOr(List.of("a", "b"), "없음"));   // 출력: a
System.out.println(firstOr(List.of(), 0));               // 출력: 0
System.out.println(pair("x", 1));                        // 출력: (x, 1)

주의: firstOr(List.of(1), "a")처럼 인자 타입이 충돌하면 T가 Object & Comparable & ...로 추론되어 이상한 타입이 됩니다. 컴파일은 되지만 의도가 아닙니다.

패턴 9: 명시적 타입 인자

인자가 없어 추론할 근거가 없거나(emptyOf()), 람다만 있어 애매할 때 클래스.<타입>메서드()로 직접 지정합니다. Collections.<String>emptyList()가 대표적입니다. 대입문이 있으면 대개 추론되므로 명시는 드물지만, 메서드 체인 중간이나 삼항 연산자 안에서 필요해집니다.

java
static <T> List<T> emptyOf() { return new ArrayList<>(); }
static <T, R> R convert(T value, Function<T, R> f) { return f.apply(value); }

List<String> empty = Main.<String>emptyOf();                     // 명시
empty.add("ok");
System.out.println(empty);                                       // 출력: [ok]
System.out.println(Main.<Integer, String>convert(7, i -> "n" + i));   // 출력: n7

주의: 타입 인자를 명시하려면 앞에 반드시 클래스. 또는 this.가 있어야 합니다. <String>emptyOf()만 쓰면 문법 오류입니다.

패턴 10: 전략을 람다로 주입

checkout은 "가격에 정책을 적용한다"만 알고, 정책이 무엇인지는 모릅니다. 정책을 함수형 인터페이스 Discount로 받으면 미리 정의한 rate(10)도, 즉석 람다 p -> p / 2도 넘길 수 있습니다. 중급의 전략 패턴에서 클래스를 없앤 형태입니다.

java
@FunctionalInterface
public interface Discount {
    long apply(long price);
    static Discount none() { return p -> p; }
    static Discount rate(int percent) { return p -> p - p * percent / 100; }
}

static long checkout(long price, Discount policy) { return policy.apply(price); }

System.out.println(checkout(10_000, Discount.none()));      // 출력: 10000
System.out.println(checkout(10_000, Discount.rate(10)));    // 출력: 9000
System.out.println(checkout(10_000, p -> p / 2));           // 출력: 5000

주의: Discount는 LongUnaryOperator와 같은 모양이지만 이름이 있는 인터페이스가 의도를 드러냅니다. 도메인 개념이면 직접 정의하고, 범용 변환이면 표준 인터페이스를 씁니다.

패턴 11: 콜백(callback)

"작업이 끝나면 이것을 해 달라"고 함수를 넘깁니다. 호출자는 완료 시점을 기다리지 않고, 작업하는 쪽이 적절한 때 accept를 부릅니다. 실패 시 콜백을 부르지 않는 것도 설계의 일부입니다.

java
static void download(String file, Consumer<Integer> onDone) {
    if (file.isEmpty()) return;         // 실패: 콜백 호출 없음
    onDone.accept(1024);
}

download("report.csv", size -> System.out.println("완료: " + size + "bytes"));   // 출력: 완료: 1024bytes
download("", size -> System.out.println("호출 안 됨"));                          // (출력 없음)

주의: 콜백 안에서 또 콜백을 부르면 중첩이 깊어집니다(callback hell). 그때는 패턴 19~21의 CompletableFuture로 평평하게 폅니다.

패턴 12: 이벤트 리스너

콜백을 여러 개 등록해 두고 이벤트가 나면 모두 호출합니다. subscribe가 구독 해제 함수(Runnable)를 돌려주는 것이 핵심 기법입니다. 리스너 객체를 따로 보관하지 않아도 돌려받은 함수를 실행하면 해제됩니다.

java
public class EventBus {
    private final List<Consumer<String>> listeners = new ArrayList<>();
    public Runnable subscribe(Consumer<String> listener) {
        listeners.add(listener);
        return () -> listeners.remove(listener);     // 구독 해제 함수
    }
    public void publish(String event) { for (Consumer<String> l : listeners) l.accept(event); }
}

EventBus bus = new EventBus();
Runnable unsubscribe = bus.subscribe(e -> System.out.println("A: " + e));
bus.subscribe(e -> System.out.println("B: " + e.toUpperCase()));
bus.publish("login");
// 출력:
// A: login
// B: LOGIN
unsubscribe.run();
bus.publish("logout");
// 출력:
// B: LOGOUT

주의: 리스너가 publish 중에 자기 자신을 해제하면 ConcurrentModificationException이 납니다. 실무에서는 CopyOnWriteArrayList를 씁니다.

패턴 13: 지연 실행 Supplier

기본값을 만드는 비용이 클 때 값 대신 값을 만드는 함수를 넘깁니다. 필요할 때만 get()이 호출되므로 v가 있으면 expensive는 실행되지 않습니다. Optional.orElseGet, Map.computeIfAbsent, 로거의 Supplier 오버로딩이 모두 이 패턴입니다.

java
static String valueOrDefault(String v, Supplier<String> fallback) {
    return v != null ? v : fallback.get();          // 필요할 때만 호출
}
static String expensive(String s) {
    System.out.println("  expensive 실행: " + s);
    return s;
}

String v1 = valueOrDefault("있음", () -> expensive("기본1"));   // expensive 호출 안 됨
String v2 = valueOrDefault(null, () -> expensive("기본2"));     // 출력:   expensive 실행: 기본2
System.out.println(v1 + " " + v2);                              // 출력: 있음 기본2

주의: valueOrDefault("있음", expensive("기본1"))처럼 값을 넘기면 인자 평가 규칙 때문에 무조건 실행됩니다. "비싼 기본값"은 항상 Supplier로.

패턴 14: 메모이제이션(memoization)

순수 함수(같은 입력 → 같은 출력)의 결과를 Map에 저장해 두 번째부터는 계산하지 않습니다. computeIfAbsent가 "없으면 계산해서 넣고, 있으면 꺼낸다"를 한 줄로 해 줍니다. 제네릭 데코레이터 memoize로 어떤 함수든 감쌀 수 있습니다.

java
public static <T, R> Function<T, R> memoize(Function<T, R> f) {
    Map<T, R> cache = new HashMap<>();
    return t -> cache.computeIfAbsent(t, f);
}

AtomicInteger calls = new AtomicInteger();
Function<Integer, Integer> slowSquare = n -> { calls.incrementAndGet(); return n * n; };
Function<Integer, Integer> fast = Fn.memoize(slowSquare);
System.out.println(fast.apply(9) + " " + fast.apply(9) + " " + fast.apply(9));   // 출력: 81 81 81
System.out.println("실제 호출: " + calls.get());                                  // 출력: 실제 호출: 1

// 재귀 피보나치 + 메모: 지수 시간 → 선형 시간
static final Map<Integer, Long> FIB = new HashMap<>();
static long fib(int n) {
    if (n < 2) return n;
    Long cached = FIB.get(n);
    if (cached != null) return cached;
    long v = fib(n - 1) + fib(n - 2);
    FIB.put(n, v);
    return v;
}
System.out.println(fib(80));      // 출력: 23416728348467685

주의: 재귀 안에서 computeIfAbsent를 쓰면 계산 중에 같은 맵을 수정하게 되어 ConcurrentModificationException이 납니다. 재귀 메모는 get/put을 분리합니다. 캐시 크기 제한이 없으면 메모리 누수입니다.

패턴 15: 재귀를 스트림/반복으로

팩토리얼·합계처럼 구조가 선형인 재귀는 reduce로 바꾸면 스택을 쓰지 않습니다. rangeClosed(1, n)이 수열을 만들고 reduce가 접습니다. 초급의 "재귀 → 반복문 전환"을 스트림으로 표현한 것입니다.

java
System.out.println(LongStream.rangeClosed(1, 20).reduce(1, (a, b) -> a * b));   // 출력: 2432902008176640000
System.out.println(IntStream.rangeClosed(1, 100).sum());                         // 출력: 5050

주의: reduce의 초기값은 항등원이어야 합니다(곱은 1, 합은 0). 병렬 스트림에서는 결합 법칙도 필요합니다.

패턴 16: Stream.iterate로 수열 생성

iterate(초기값, 다음값 함수)는 무한 수열을 만들고 limit으로 자릅니다. 3인자 버전은 종료 조건을 받아 for문과 같아집니다. 상태가 둘 이상이면(피보나치) 배열이나 record로 묶어 넘깁니다.

java
System.out.println(Stream.iterate(1, x -> x * 2).limit(8).toList());          // 출력: [1, 2, 4, 8, 16, 32, 64, 128]
System.out.println(Stream.iterate(1, x -> x < 50, x -> x * 3).toList());       // 출력: [1, 3, 9, 27]
System.out.println(Stream.iterate(new long[]{0, 1}, p -> new long[]{p[1], p[0] + p[1]})
        .limit(10).map(p -> p[0]).toList());                                   // 출력: [0, 1, 1, 2, 3, 5, 8, 13, 21, 34]

주의: 2인자 iterate는 무한이므로 limit이나 takeWhile 없이 종단 연산을 부르면 멈추지 않습니다.

패턴 17: Stream.generate로 값 공급

이전 값에 의존하지 않는 값을 Supplier로 계속 만듭니다. 순번 ID, 난수, 상수 반복에 씁니다. 외부 카운터를 캡처하면 상태가 있는 생성기가 됩니다.

java
AtomicInteger seq = new AtomicInteger();
System.out.println(Stream.generate(() -> "id-" + seq.incrementAndGet()).limit(3).toList());   // 출력: [id-1, id-2, id-3]
System.out.println(Stream.generate(() -> "ab").limit(3).collect(Collectors.joining()));       // 출력: ababab

주의: 병렬 스트림에서 generate의 호출 순서는 보장되지 않습니다. 순서가 필요하면 iterate나 IntStream.range를 씁니다.

패턴 18: 함수형 파이프라인으로 데이터 처리 메서드 분해

"공백 제거 → 빈 줄 제외 → 파싱 → 기준 이상만 → 점수순 → 포맷"을 한 메서드에 for문과 if로 쓰면 30줄입니다. 각 단계를 작은 메서드(parseScore, format)로 빼고 스트림으로 이으면 흐름이 한눈에 보이고 각 단계를 따로 테스트할 수 있습니다.

java
record Score(String name, int score) {}

static List<String> topScorers(List<String> lines, int cut) {
    return lines.stream()
            .map(String::trim)
            .filter(s -> !s.isEmpty())
            .map(Main::parseScore)
            .filter(s -> s != null && s.score() >= cut)
            .sorted(Comparator.comparingInt(Score::score).reversed())
            .map(Main::format)
            .toList();
}
static Score parseScore(String line) {
    String[] p = line.split(":");
    if (p.length != 2) return null;
    return new Score(p[0], Integer.parseInt(p[1]));
}
static String format(Score s) { return s.name().toUpperCase() + "(" + s.score() + ")"; }

List<String> raw = List.of(" kim:90 ", "lee:45", "", "park:72", "bad");
System.out.println(topScorers(raw, 60));     // 출력: [KIM(90), PARK(72)]

주의: 파이프라인 중간에 null을 흘리는 것(parseScore)은 편법입니다. 실무에서는 Optional을 돌려주고 flatMap(Optional::stream)으로 거릅니다.

패턴 19: CompletableFuture.thenApply — 결과 변환

비동기 작업의 결과를 동기 함수로 변환합니다. Stream.map과 같은 역할이며, 체인의 각 단계는 앞 단계가 끝나면 실행됩니다. join()으로 최종 결과를 기다립니다.

java
CompletableFuture<String> f1 = CompletableFuture.supplyAsync(() -> 21)
        .thenApply(x -> x * 2)
        .thenApply(x -> "answer=" + x);
System.out.println(f1.join());      // 출력: answer=42

주의: thenApply의 함수가 예외를 던지면 체인 전체가 예외로 완료됩니다. exceptionally나 handle로 복구 지점을 둡니다.

패턴 20: thenCompose — 비동기 결과로 다음 비동기

"사용자 ID를 찾은 뒤, 그 ID로 이메일을 조회"처럼 두 번째 작업이 첫 결과에 의존하고 그 자체도 비동기일 때 씁니다. thenApply를 쓰면 CompletableFuture<CompletableFuture<String>>이 되지만 thenCompose는 한 겹으로 펴 줍니다. Optional.flatMap과 같은 관계입니다.

java
static CompletableFuture<String> findUserId(String name) {
    return CompletableFuture.supplyAsync(() -> "u-" + name);
}
static CompletableFuture<String> fetchEmail(String userId) {
    return CompletableFuture.supplyAsync(() -> userId + "@shop.com");
}

CompletableFuture<String> f2 = findUserId("kim").thenCompose(Main::fetchEmail);
System.out.println(f2.join());      // 출력: [email protected]

주의: 반환 타입이 CompletableFuture인 함수를 thenApply에 넣어도 컴파일은 됩니다. 결과 타입이 이중으로 감싸진 것을 보고 thenCompose로 바꾸세요.

패턴 21: thenCombine — 독립 작업 합치기

가격 조회와 배송비 조회는 서로 의존하지 않으므로 동시에 시작하고 둘 다 끝나면 합칩니다. thenCompose로 직렬 연결하면 순차 실행이 되어 느립니다. 독립 작업은 thenCombine(둘) 또는 allOf(여럿)입니다.

java
CompletableFuture<Integer> price = CompletableFuture.supplyAsync(() -> 10_000);
CompletableFuture<Integer> shipping = CompletableFuture.supplyAsync(() -> 2_500);
System.out.println(price.thenCombine(shipping, Integer::sum).join());   // 출력: 12500

주의: 두 Future를 먼저 변수에 만들어 둔 뒤 결합해야 병렬입니다. a.thenCombine(supplyAsync(...), ...)도 병렬이지만, thenCompose 안에서 두 번째를 만들면 직렬입니다.

패턴 22: 실무 예제 — 할인 정책 조합기

Discount에 then(순차 적용)과 orCheaper(둘 중 싼 쪽) 조합 메서드를 default로 두면, 정책이 몇 개든 if 없이 조립됩니다. "10% 할인 후 1000원 추가 할인"과 "5만원 이상만 20%" 중 더 싼 쪽을 고르는 정책이 세 줄입니다.

java
public interface Discount {
    long apply(long price);
    static Discount rate(int percent) { return p -> p - p * percent / 100; }
    static Discount fixed(long amount) { return p -> Math.max(0, p - amount); }
    static Discount minPrice(long threshold, Discount d) { return p -> p >= threshold ? d.apply(p) : p; }
    default Discount then(Discount next) { return p -> next.apply(this.apply(p)); }
    default Discount orCheaper(Discount other) { return p -> Math.min(this.apply(p), other.apply(p)); }
}

Discount vip = Discount.rate(10).then(Discount.fixed(1000));          // 10% 후 1000원
Discount event = Discount.minPrice(50_000, Discount.rate(20));       // 5만원 이상만 20%
Discount best = vip.orCheaper(event);
System.out.println(vip.apply(30_000));                               // 출력: 26000
System.out.println(event.apply(30_000) + " " + event.apply(60_000)); // 출력: 30000 48000
System.out.println(best.apply(30_000) + " " + best.apply(60_000));   // 출력: 26000 48000

주의: 조합 순서가 결과를 바꿉니다(rate(10).then(fixed(1000)) ≠ fixed(1000).then(rate(10))). 정책 이름에 순서를 드러내거나 테스트로 고정하세요.

패턴 23: 실무 예제 — 검증 규칙 조합

규칙 = 메시지 + Predicate. record로 묶고 validate가 모든 규칙을 돌려 위반 메시지를 모읍니다. 중급의 Validator 체인과 달리 규칙이 대상 타입에 독립적인 값이라 목록으로 관리하고 재사용할 수 있습니다.

java
public record Rule<T>(String message, Predicate<T> check) {
    public static <T> Rule<T> of(String message, Predicate<T> check) { return new Rule<>(message, check); }

    @SafeVarargs
    public static <T> List<String> validate(T target, Rule<T>... rules) {
        List<String> errors = new ArrayList<>();
        for (Rule<T> r : rules) if (!r.check.test(target)) errors.add(r.message);
        return errors;
    }
}

record User(String name, int age, String email) {}
Rule<User> nameRule = Rule.of("이름 필수", u -> !u.name().isBlank());
Rule<User> ageRule = Rule.of("나이 0~150", u -> u.age() >= 0 && u.age() <= 150);
Rule<User> emailRule = Rule.of("이메일 형식", u -> u.email().contains("@"));
System.out.println(Rule.validate(new User("Kim", 30, "[email protected]"), nameRule, ageRule, emailRule));   // 출력: []
System.out.println(Rule.validate(new User("", 200, "x"), nameRule, ageRule, emailRule));
// 출력: [이름 필수, 나이 0~150, 이메일 형식]

주의: 제네릭 가변 인자(Rule<T>...)는 힙 오염 경고가 나므로 배열에 쓰지 않고 읽기만 한다면 @SafeVarargs를 붙입니다.

패턴 24: 실무 예제 — 재시도 데코레이터 함수

Supplier<T>를 받아 "실패하면 최대 n번 다시 시도하는" Supplier<T>를 돌려줍니다. 원래 작업은 재시도를 전혀 모르고, 어떤 작업에든 retry(3, ...)로 감쌀 수 있습니다. 함수를 받아 함수를 돌려주는 데코레이터의 전형입니다.

java
public static <T> Supplier<T> retry(int maxAttempts, Supplier<T> action) {
    return () -> {
        RuntimeException last = null;
        for (int i = 1; i <= maxAttempts; i++) {
            try { return action.get(); }
            catch (RuntimeException e) {
                last = e;
                System.out.println("  시도 " + i + " 실패: " + e.getMessage());
            }
        }
        throw new IllegalStateException(maxAttempts + "번 모두 실패", last);
    };
}

AtomicInteger attempts = new AtomicInteger();
Supplier<String> flaky = () -> {
    if (attempts.incrementAndGet() < 3) throw new RuntimeException("timeout");
    return "성공(" + attempts.get() + "번째)";
};
System.out.println(Fn.retry(5, flaky).get());
// 출력:
//   시도 1 실패: timeout
//   시도 2 실패: timeout
// 성공(3번째)
try {
    Fn.retry(2, () -> { throw new RuntimeException("down"); }).get();
} catch (IllegalStateException e) {
    System.out.println(e.getMessage());     // 출력: 2번 모두 실패  (앞에 "시도 1/2 실패: down" 두 줄)
}

주의: 재시도는 멱등한 작업에만 안전합니다. 결제 요청을 재시도하면 두 번 결제될 수 있습니다. 실무에서는 백오프(대기 시간 증가)와 재시도할 예외 종류 지정이 추가됩니다(배치 05 레슨).

패턴 25: 실무 예제 — 캐시 래퍼

DB 조회 함수를 감싸 Map에 결과를 보관합니다. 메모이제이션(패턴 14)과 같지만 저장소를 바깥에서 주입받아 크기 제한·만료·공유를 호출자가 정할 수 있고, 적중 여부를 관찰할 수 있습니다.

java
public static <T, R> Function<T, R> cached(Function<T, R> loader, Map<T, R> store) {
    return key -> {
        if (store.containsKey(key)) {
            System.out.println("  cache hit: " + key);
            return store.get(key);
        }
        R value = loader.apply(key);
        store.put(key, value);
        return value;
    };
}

Map<String, String> store = new HashMap<>();
Function<String, String> loadProfile = Fn.cached(
        id -> { System.out.println("  DB 조회: " + id); return id.toUpperCase(); }, store);
System.out.println(loadProfile.apply("kim"));
// 출력:
//   DB 조회: kim
// KIM
System.out.println(loadProfile.apply("kim"));
// 출력:
//   cache hit: kim
// KIM
System.out.println(store.size());      // 출력: 1

주의: containsKey + get은 멀티스레드에서 원자적이지 않습니다. 동시 접근이 있으면 ConcurrentHashMap.computeIfAbsent를 쓰되, 로더가 오래 걸리면 다른 키까지 막힐 수 있으니 주의합니다.

패턴 26: 메서드 참조로 Comparator 조립

정렬 기준은 Comparator.comparing(키 추출 함수)로 만들고 thenComparing, reversed(), reverseOrder()로 조합합니다. 키 추출은 record 접근자 메서드 참조가 가장 깔끔합니다. BinaryOperator.maxBy(comparator)는 reduce와 함께 "가장 큰 것"을 찾습니다.

java
record Emp(String name, int age, long salary) {}
List<Emp> emps = List.of(new Emp("Kim", 30, 5000), new Emp("Lee", 25, 7000), new Emp("Park", 30, 6000));

System.out.println(emps.stream()
        .sorted(Comparator.comparingInt(Emp::age).thenComparing(Emp::salary, Comparator.reverseOrder()))
        .map(Emp::name).toList());                                        // 출력: [Lee, Park, Kim]
BinaryOperator<Emp> richer = BinaryOperator.maxBy(Comparator.comparingLong(Emp::salary));
System.out.println(emps.stream().reduce(richer).map(Emp::name).orElse("-"));   // 출력: Lee

주의: comparing(Emp::salary).reversed()는 전체를 뒤집지만, thenComparing(Emp::salary, reverseOrder())는 그 키만 뒤집습니다. 위치에 따라 의미가 다릅니다.

패턴 모음
  • 패턴 1: 메서드 참조 4종 재정리
  • 패턴 2: 함수를 받는 메서드
  • 패턴 3: 함수를 반환하는 메서드
  • 패턴 4: 커링(currying)
  • 패턴 5: 부분 적용(partial application)
  • 패턴 6: 함수 합성 andThen / compose
  • 패턴 7: Predicate 합성 and / or / negate
  • 패턴 8: 제네릭 메서드 — 타입 추론
  • 패턴 9: 명시적 타입 인자
  • 패턴 10: 전략을 람다로 주입
  • 패턴 11: 콜백(callback)
  • 패턴 12: 이벤트 리스너
  • 패턴 13: 지연 실행 Supplier
  • 패턴 14: 메모이제이션(memoization)
  • 패턴 15: 재귀를 스트림/반복으로
  • 패턴 16: Stream.iterate로 수열 생성
  • 패턴 17: Stream.generate로 값 공급
  • 패턴 18: 함수형 파이프라인으로 데이터 처리 메서드 분해
  • 패턴 19: CompletableFuture.thenApply — 결과 변환
  • 패턴 20: thenCompose — 비동기 결과로 다음 비동기
  • 패턴 21: thenCombine — 독립 작업 합치기
  • 패턴 22: 실무 예제 — 할인 정책 조합기
  • 패턴 23: 실무 예제 — 검증 규칙 조합
  • 패턴 24: 실무 예제 — 재시도 데코레이터 함수
  • 패턴 25: 실무 예제 — 캐시 래퍼
  • 패턴 26: 메서드 참조로 Comparator 조립
이전 섹션2 핵심 원리3 / 6다음 섹션4 자주 하는 실수 (Tip)