각 패턴의 코드는 java-src/advanced/06_method_patterns/에서 실행해 볼 수 있습니다. Fn(함수 유틸), Discount, Rule, EventBus가 보조 클래스입니다.
람다 x -> foo(x)처럼 인자를 그대로 넘기기만 한다면 메서드 참조로 줄입니다. 네 종류가 있고, String::length(임의 객체)와 Integer::parseInt(static)는 문법이 같으니 메서드가 static인지로 구분합니다.
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이 아니라면) 참조는 옛 객체를 씁니다.
"두 번 적용한다"는 동작은 무엇을 적용하든 같습니다. 그 "무엇"을 UnaryOperator<T>로 받으면 숫자에도 문자열에도 쓰는 하나의 메서드가 됩니다. 제네릭 + 함수형 인터페이스 매개변수가 고차 함수의 기본 형태입니다.
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>.
"n을 더하는 함수"를 만들어 돌려줍니다. 람다가 매개변수 n을 캡처하므로 adder(5)와 adder(10)은 서로 다른 값을 기억하는 독립된 함수입니다. 설정값에 따라 동작이 달라지는 함수를 만드는 함수 팩토리입니다.
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++을 하면 컴파일 오류입니다.
두 인자 함수 (a, b) -> a * b를 "a를 받아서 b를 받는 함수를 돌려주는" 형태로 바꿉니다. 첫 인자만 적용한 curried.apply(3)이 그 자체로 "3배 함수"가 되어 재사용됩니다. 제네릭 유틸로 한 번 만들어 두면 어떤 BiFunction에도 적용됩니다.
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의 부분 적용이 더 자연스럽습니다.
인자 일부를 미리 고정한 새 함수를 만듭니다. "인사말은 Hello로 고정, 이름만 바뀐다"처럼 설정은 한 번, 호출은 여러 번인 상황에 씁니다. 커링과 달리 원래 함수 모양을 바꾸지 않고 필요한 인자만 묶습니다.
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!주의: 고정한 인자가 가변 객체면 나중에 바뀔 수 있습니다. 부분 적용에는 불변 값을 넘기세요.
andThen / compose작은 함수를 이어 붙여 큰 함수를 만듭니다. trim.andThen(upper).andThen(length)는 "다듬고, 대문자로, 길이"를 그 순서로 실행합니다. compose는 순서가 반대입니다. 합성된 함수는 그 자체로 변수에 담거나 다른 메서드에 넘길 수 있습니다.
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 변수에 담거나 캐스팅합니다.
Predicate 합성 and / or / negate조건을 작은 Predicate로 만들고 논리 연산으로 조립합니다. notBlank.and(shortStr.negate())는 "비어 있지 않고 짧지 않다"로 읽힙니다. 복잡한 if 조건을 이름 있는 조각으로 나누고, 스트림 filter에 그대로 넘길 수 있습니다.
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)도 있습니다.
<T>를 선언하면 컴파일러가 인자에서 T를 추론합니다. firstOr(List.of("a"), "없음")에서 T = String, firstOr(List.of(), 0)에서는 대입 대상이 없어도 두 번째 인자로 T = Integer를 알아냅니다. 타입 매개변수가 둘이면 <A, B>.
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 & ...로 추론되어 이상한 타입이 됩니다. 컴파일은 되지만 의도가 아닙니다.
인자가 없어 추론할 근거가 없거나(emptyOf()), 람다만 있어 애매할 때 클래스.<타입>메서드()로 직접 지정합니다. Collections.<String>emptyList()가 대표적입니다. 대입문이 있으면 대개 추론되므로 명시는 드물지만, 메서드 체인 중간이나 삼항 연산자 안에서 필요해집니다.
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()만 쓰면 문법 오류입니다.
checkout은 "가격에 정책을 적용한다"만 알고, 정책이 무엇인지는 모릅니다. 정책을 함수형 인터페이스 Discount로 받으면 미리 정의한 rate(10)도, 즉석 람다 p -> p / 2도 넘길 수 있습니다. 중급의 전략 패턴에서 클래스를 없앤 형태입니다.
@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와 같은 모양이지만 이름이 있는 인터페이스가 의도를 드러냅니다. 도메인 개념이면 직접 정의하고, 범용 변환이면 표준 인터페이스를 씁니다.
"작업이 끝나면 이것을 해 달라"고 함수를 넘깁니다. 호출자는 완료 시점을 기다리지 않고, 작업하는 쪽이 적절한 때 accept를 부릅니다. 실패 시 콜백을 부르지 않는 것도 설계의 일부입니다.
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로 평평하게 폅니다.
콜백을 여러 개 등록해 두고 이벤트가 나면 모두 호출합니다. subscribe가 구독 해제 함수(Runnable)를 돌려주는 것이 핵심 기법입니다. 리스너 객체를 따로 보관하지 않아도 돌려받은 함수를 실행하면 해제됩니다.
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를 씁니다.
Supplier기본값을 만드는 비용이 클 때 값 대신 값을 만드는 함수를 넘깁니다. 필요할 때만 get()이 호출되므로 v가 있으면 expensive는 실행되지 않습니다. Optional.orElseGet, Map.computeIfAbsent, 로거의 Supplier 오버로딩이 모두 이 패턴입니다.
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로.
순수 함수(같은 입력 → 같은 출력)의 결과를 Map에 저장해 두 번째부터는 계산하지 않습니다. computeIfAbsent가 "없으면 계산해서 넣고, 있으면 꺼낸다"를 한 줄로 해 줍니다. 제네릭 데코레이터 memoize로 어떤 함수든 감쌀 수 있습니다.
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을 분리합니다. 캐시 크기 제한이 없으면 메모리 누수입니다.
팩토리얼·합계처럼 구조가 선형인 재귀는 reduce로 바꾸면 스택을 쓰지 않습니다. rangeClosed(1, n)이 수열을 만들고 reduce가 접습니다. 초급의 "재귀 → 반복문 전환"을 스트림으로 표현한 것입니다.
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). 병렬 스트림에서는 결합 법칙도 필요합니다.
Stream.iterate로 수열 생성iterate(초기값, 다음값 함수)는 무한 수열을 만들고 limit으로 자릅니다. 3인자 버전은 종료 조건을 받아 for문과 같아집니다. 상태가 둘 이상이면(피보나치) 배열이나 record로 묶어 넘깁니다.
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 없이 종단 연산을 부르면 멈추지 않습니다.
Stream.generate로 값 공급이전 값에 의존하지 않는 값을 Supplier로 계속 만듭니다. 순번 ID, 난수, 상수 반복에 씁니다. 외부 카운터를 캡처하면 상태가 있는 생성기가 됩니다.
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를 씁니다.
"공백 제거 → 빈 줄 제외 → 파싱 → 기준 이상만 → 점수순 → 포맷"을 한 메서드에 for문과 if로 쓰면 30줄입니다. 각 단계를 작은 메서드(parseScore, format)로 빼고 스트림으로 이으면 흐름이 한눈에 보이고 각 단계를 따로 테스트할 수 있습니다.
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)으로 거릅니다.
CompletableFuture.thenApply — 결과 변환비동기 작업의 결과를 동기 함수로 변환합니다. Stream.map과 같은 역할이며, 체인의 각 단계는 앞 단계가 끝나면 실행됩니다. join()으로 최종 결과를 기다립니다.
CompletableFuture<String> f1 = CompletableFuture.supplyAsync(() -> 21)
.thenApply(x -> x * 2)
.thenApply(x -> "answer=" + x);
System.out.println(f1.join()); // 출력: answer=42주의: thenApply의 함수가 예외를 던지면 체인 전체가 예외로 완료됩니다. exceptionally나 handle로 복구 지점을 둡니다.
thenCompose — 비동기 결과로 다음 비동기"사용자 ID를 찾은 뒤, 그 ID로 이메일을 조회"처럼 두 번째 작업이 첫 결과에 의존하고 그 자체도 비동기일 때 씁니다. thenApply를 쓰면 CompletableFuture<CompletableFuture<String>>이 되지만 thenCompose는 한 겹으로 펴 줍니다. Optional.flatMap과 같은 관계입니다.
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()); // 출력: u-kim@shop.com주의: 반환 타입이 CompletableFuture인 함수를 thenApply에 넣어도 컴파일은 됩니다. 결과 타입이 이중으로 감싸진 것을 보고 thenCompose로 바꾸세요.
thenCombine — 독립 작업 합치기가격 조회와 배송비 조회는 서로 의존하지 않으므로 동시에 시작하고 둘 다 끝나면 합칩니다. thenCompose로 직렬 연결하면 순차 실행이 되어 느립니다. 독립 작업은 thenCombine(둘) 또는 allOf(여럿)입니다.
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 안에서 두 번째를 만들면 직렬입니다.
Discount에 then(순차 적용)과 orCheaper(둘 중 싼 쪽) 조합 메서드를 default로 두면, 정책이 몇 개든 if 없이 조립됩니다. "10% 할인 후 1000원 추가 할인"과 "5만원 이상만 20%" 중 더 싼 쪽을 고르는 정책이 세 줄입니다.
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))). 정책 이름에 순서를 드러내거나 테스트로 고정하세요.
규칙 = 메시지 + Predicate. record로 묶고 validate가 모든 규칙을 돌려 위반 메시지를 모읍니다. 중급의 Validator 체인과 달리 규칙이 대상 타입에 독립적인 값이라 목록으로 관리하고 재사용할 수 있습니다.
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, "k@s.com"), nameRule, ageRule, emailRule)); // 출력: []
System.out.println(Rule.validate(new User("", 200, "x"), nameRule, ageRule, emailRule));
// 출력: [이름 필수, 나이 0~150, 이메일 형식]주의: 제네릭 가변 인자(Rule<T>...)는 힙 오염 경고가 나므로 배열에 쓰지 않고 읽기만 한다면 @SafeVarargs를 붙입니다.
Supplier<T>를 받아 "실패하면 최대 n번 다시 시도하는" Supplier<T>를 돌려줍니다. 원래 작업은 재시도를 전혀 모르고, 어떤 작업에든 retry(3, ...)로 감쌀 수 있습니다. 함수를 받아 함수를 돌려주는 데코레이터의 전형입니다.
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 레슨).
DB 조회 함수를 감싸 Map에 결과를 보관합니다. 메모이제이션(패턴 14)과 같지만 저장소를 바깥에서 주입받아 크기 제한·만료·공유를 호출자가 정할 수 있고, 적중 여부를 관찰할 수 있습니다.
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를 쓰되, 로더가 오래 걸리면 다른 키까지 막힐 수 있으니 주의합니다.
Comparator 조립정렬 기준은 Comparator.comparing(키 추출 함수)로 만들고 thenComparing, reversed(), reverseOrder()로 조합합니다. 키 추출은 record 접근자 메서드 참조가 가장 깔끔합니다. BinaryOperator.maxBy(comparator)는 reduce와 함께 "가장 큰 것"을 찾습니다.
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())는 그 키만 뒤집습니다. 위치에 따라 의미가 다릅니다.