본문으로 건너뛰기
안동민 개발노트 아이콘

안동민 개발노트

본문 시작
30장 : 람다와 함수형 인터페이스

행동 매개변수화

조건이 늘 때마다 복제되는 컬렉션 순회를 함수형 계약으로 추출하고, 값 매개변수와 행동 매개변수의 차이·순수성·조합 가능성을 실행 예제로 익힙니다.

메서드 매개변수에는 숫자와 문자열만 전달하는 것이 아닙니다. “어떤 상품을 선택할지”, “실패를 다시 시도할지”, “두 항목을 어떤 순서로 놓을지” 같은 행동도 객체로 표현해 전달할 수 있습니다. 알고리즘의 고정 부분은 컬렉션 순회와 결과 축적이고, 변하는 부분은 각 원소에 내릴 판단입니다. 변하는 판단을 매개변수로 받으면 새로운 조건이 생겨도 순회 코드를 복사하지 않습니다.

람다는 행동 객체를 짧게 만드는 문법입니다. 그러나 첫 설계 단계에서는 람다보다 경계를 먼저 찾아야 합니다. 입력과 출력이 무엇인지, 같은 입력에 같은 결과를 기대하는지, 외부 상태를 변경하는지 정해야 합니다. 계약이 흐리면 짧은 화살표 문법만 남고 테스트하기 어려운 콜백이 됩니다.

순회와 정책의 결합

아래 코드는 가격과 재고 조건마다 거의 같은 반복문을 가집니다. 세 번째 조건이 생기면 메서드를 하나 더 만들고, null 처리나 결과 불변화 개선도 모든 복사본에 적용해야 합니다. boolean 플래그를 추가해 한 메서드로 합치면 조건 조합이 늘수록 분기표가 커집니다.

bad/DuplicatedProductFilters.java
import java.util.ArrayList;
import java.util.List;

public final class DuplicatedProductFilters {
    record Product(String name, int price, int stock) {
    }

    static List<Product> expensive(List<Product> products, int minimum) {
        List<Product> result = new ArrayList<>();
        for (Product product : products) {
            if (product.price() >= minimum) {
                result.add(product);
            }
        }
        return result;
    }

    static List<Product> available(List<Product> products) {
        List<Product> result = new ArrayList<>();
        for (Product product : products) {
            if (product.stock() > 0) {
                result.add(product);
            }
        }
        return result;
    }

    public static void main(String[] args) {
        System.out.println("compile-only duplicated loop counterexample");
    }
}

값 매개변수는 알고리즘 안의 빈칸 하나를 채웁니다. minimumPrice를 받으면 비교 기준 숫자는 바뀌지만 비교 방식은 그대로입니다. 행동 매개변수는 비교 방식 자체를 바꿉니다. ProductRuleboolean test(Product)를 선언하면 호출자는 가격, 재고, 이름, 복합 조건을 구현할 수 있고 선택 알고리즘은 수정되지 않습니다.

행동을 인터페이스 구현 객체로 받는 설계는 람다 이전에도 가능합니다. 람다는 익명 클래스의 반복 문법을 줄일 뿐, 동적 디스패치와 객체 전달이라는 원리는 같습니다. 따라서 매개변수 이름과 메서드 시그니처가 도메인 의미를 잘 드러내야 합니다.

판단 계약과 선택 알고리즘

다음 예제는 ProductRule을 함수형 계약으로 정의합니다. select()는 규칙이 무엇인지 모르고 각 상품을 시험합니다. 호출자는 이름 있는 규칙, 람다, 조합 규칙을 모두 전달할 수 있습니다. 반환 목록은 List.copyOf()로 고정해 선택 결과가 뒤에서 수정되지 않게 합니다.

src/BehaviorParameterization.java
import java.util.ArrayList;
import java.util.List;

public final class BehaviorParameterization {
    record Product(String name, String category, int price, int stock) {
    }

    @FunctionalInterface
    interface ProductRule {
        boolean test(Product product);

        default ProductRule and(ProductRule other) {
            return product -> test(product) && other.test(product);
        }

        default ProductRule negate() {
            return product -> !test(product);
        }
    }

    static List<Product> select(List<Product> products, ProductRule rule) {
        List<Product> selected = new ArrayList<>();
        for (Product product : products) {
            if (rule.test(product)) {
                selected.add(product);
            }
        }
        return List.copyOf(selected);
    }

    static final class CategoryRule implements ProductRule {
        private final String category;

        CategoryRule(String category) {
            this.category = category;
        }

        @Override
        public boolean test(Product product) {
            return product.category().equals(category);
        }
    }

    public static void main(String[] args) {
        List<Product> products = List.of(
                new Product("Keyboard", "device", 120, 3),
                new Product("Cable", "device", 15, 0),
                new Product("Book", "book", 35, 8));
        ProductRule device = new CategoryRule("device");
        ProductRule available = product -> product.stock() > 0;
        ProductRule affordable = product -> product.price() <= 100;

        System.out.println(select(products, device.and(available)));
        System.out.println(select(products, available.and(affordable)));
        System.out.println(select(products, available.negate()));
    }
}

기본 메서드로 andnegate를 제공하면 호출자가 조건 조합을 선언적으로 읽을 수 있습니다. and는 왼쪽 조건이 false면 오른쪽을 평가하지 않는 단락 평가 의미를 가집니다. 오른쪽 규칙이 로그나 카운터를 변경한다면 평가 여부가 결과 외 상태에 영향을 줍니다. 판단 함수는 가능하면 부수 효과 없이 두어 조합 순서가 예측 가능하게 합니다.

한 알고리즘이 규칙을 여러 번 호출할 수 있는지도 계약에 포함해야 합니다. 병렬 처리나 재시도가 추가되면 호출 횟수와 순서가 달라질 수 있습니다. “정확히 한 번 실행”이 필요한 결제나 메시지 발행은 단순 조건식으로 전달하지 말고 별도의 명령 경계와 멱등성 정책을 둡니다.

Comparator 정책

선택만 행동 매개변수화의 사례가 아닙니다. Java의 Comparator<T>는 두 값을 받아 음수, 0, 양수로 순서를 표현합니다. sort 알고리즘은 비교 정책을 모르고 비교자만 호출합니다. 원본 목록을 직접 정렬할지 복사본을 만들지도 API 계약에서 분명히 해야 합니다.

src/ComparatorPolicy.java
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;

public final class ComparatorPolicy {
    record Ticket(String id, int severity, long openedAt) {
    }

    static List<Ticket> orderedCopy(List<Ticket> tickets, Comparator<Ticket> order) {
        List<Ticket> copy = new ArrayList<>(tickets);
        copy.sort(order);
        return List.copyOf(copy);
    }

    public static void main(String[] args) {
        List<Ticket> tickets = List.of(
                new Ticket("T-3", 2, 300),
                new Ticket("T-1", 1, 200),
                new Ticket("T-2", 1, 100));
        Comparator<Ticket> queueOrder = Comparator
                .comparingInt(Ticket::severity)
                .thenComparingLong(Ticket::openedAt)
                .thenComparing(Ticket::id);
        Comparator<Ticket> newestFirst = Comparator
                .comparingLong(Ticket::openedAt)
                .reversed();

        System.out.println(orderedCopy(tickets, queueOrder));
        System.out.println(orderedCopy(tickets, newestFirst));
        System.out.println("original=" + tickets);
    }
}

비교자는 추이성을 지켜야 하고 compare(a, b) == 0의 의미를 의도적으로 정해야 합니다. 비교 계약이 깨지면 sort가 잘못된 순서를 만들거나 실행 시점 예외를 낼 수 있습니다. 숫자 차이를 left.price() - right.price()로 반환하면 오버플로가 생길 수 있으므로 Integer.compare()나 비교자 팩터리를 사용합니다.

행동 경계 선택

모든 한 줄 조건을 인터페이스로 추출할 필요는 없습니다. 호출자마다 정책이 달라지거나 새로운 정책 추가가 빈번하고, 공통 알고리즘이 안정적일 때 행동 매개변수가 효과적입니다. 한 곳에서만 쓰이며 변경 이유도 같은 코드를 억지로 분리하면 이름과 간접 호출만 늘어납니다.

함수 객체의 수명도 생각합니다. 람다가 큰 객체를 캡처한 채 장기 레지스트리에 저장되면 해당 객체가 가비지 컬렉션되지 않습니다. 요청 범위 문맥을 애플리케이션 싱글턴 콜백에 등록하는 패턴은 메모리 누수와 데이터 혼선을 만듭니다. 순수한 작은 정책은 오래 보관해도 안전하지만 가변 캡처는 소유자와 해제 시점을 정해야 합니다.

연습 문제

최대 횟수를 if 문에 고정하지 말고 RetryPolicy가 현재 시도와 실패를 받아 다음 지연을 반환하게 합니다. 음수는 중단, 0 이상은 재시도를 뜻합니다. 실행기는 연산을 호출하고 정책 결정 기록을 남깁니다. 예제에서는 실제 sleep을 하지 않습니다.

해답 보기
src/RetryDecisionSolution.java
import java.time.Duration;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.atomic.AtomicInteger;

public final class RetryDecisionSolution {
    @FunctionalInterface
    interface Operation<T> {
        T run();
    }

    @FunctionalInterface
    interface RetryPolicy {
        Duration nextDelay(int failedAttempt, RuntimeException failure);
    }

    record Execution<T>(T value, List<String> decisions) {
        Execution {
            decisions = List.copyOf(decisions);
        }
    }

    static <T> Execution<T> execute(Operation<T> operation, RetryPolicy policy) {
        List<String> decisions = new ArrayList<>();
        int attempt = 1;
        while (true) {
            try {
                return new Execution<>(operation.run(), decisions);
            } catch (RuntimeException failure) {
                Duration delay = policy.nextDelay(attempt, failure);
                decisions.add("attempt=" + attempt + ", delay=" + delay.toMillis());
                if (delay.isNegative()) {
                    throw failure;
                }
                attempt += 1;
            }
        }
    }

    public static void main(String[] args) {
        AtomicInteger calls = new AtomicInteger();
        Operation<String> flaky = () -> {
            int call = calls.incrementAndGet();
            if (call < 3) {
                throw new IllegalStateException("temporary " + call);
            }
            return "ok";
        };
        RetryPolicy twoRetries = (attempt, failure) -> attempt <= 2
                ? Duration.ofMillis(25L * attempt)
                : Duration.ofMillis(-1);

        System.out.println(execute(flaky, twoRetries));
        System.out.println("calls=" + calls.get());
    }
}

해답은 연산, 재시도 판단, 실제 대기 수단을 더 분리할 여지가 있습니다. 운영에서는 재시도 가능한 예외 분류, 전체 시간 예산, 지터, 인터럽트 보존, 멱등 연산 여부를 함께 다룹니다.