본문 바로가기

조건을 매개변수로 넘기는 행동 매개변수화

조건마다 복사되던 반복문에서 바뀌는 판단만 떼어 내 매개변수로 넘기는 행동 매개변수화를 익히고, 그 판단을 람다로 짧게 쓰고 조합하는 방법을 배웁니다.

쇼핑몰 상품 목록에서 가격이 100 이상인 상품만 골라 달라는 요구가 들어왔다고 해 봅시다. 목록을 반복문으로 훑으면서 조건에 맞는 상품을 새 목록에 담으면 되는 간단한 일입니다. 그런데 며칠 뒤에는 재고가 있는 상품을, 그다음에는 전자기기 가운데 재고가 있는 상품을 골라 달라는 요구가 이어집니다. 요구가 올 때마다 반복문을 하나씩 더 쓰다 보면 조건 한 줄만 다르고 나머지는 똑같은 메서드가 쌓입니다.

이 글에서는 반복문에서 바뀌지 않는 부분과 바뀌는 부분을 나눕니다. 목록을 훑으며 결과를 모으는 일은 메서드에 그대로 두고, 상품 하나를 고를지 말지 정하는 판단만 바깥에서 받아 오는 것입니다. 이렇게 동작 자체를 매개변수로 넘기는 설계를 행동 매개변수화(behavior parameterization)라고 합니다. 넘길 동작을 짧게 쓰는 문법이 람다식(lambda expression)이고, 이 장의 나머지 글에서 람다의 규칙을 하나씩 살펴봅니다.

조건 한 줄만 다른 반복문

먼저 요구가 올 때마다 메서드를 하나씩 추가한 코드를 보겠습니다. expensive는 가격이 기준 이상인 상품을, available은 재고가 남은 상품을 골라 새 목록으로 돌려줍니다.

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) {
        List<Product> products = List.of(
                new Product("Keyboard", 120, 3),
                new Product("Cable", 15, 0),
                new Product("Book", 35, 8));
        System.out.println(expensive(products, 100));
        System.out.println(available(products));
    }
}

Product는 레코드이므로 목록을 출력하면 상품마다 구성 요소의 이름과 값이 함께 찍힙니다. 두 메서드 모두 원래 목록의 순서를 유지한 채 조건에 맞는 상품만 새 목록에 담습니다.

[Product[name=Keyboard, price=120, stock=3]]
[Product[name=Keyboard, price=120, stock=3], Product[name=Book, price=35, stock=8]]

첫 줄은 가격이 100 이상인 Keyboard 하나이고, 둘째 줄은 재고가 0인 Cable을 뺀 두 상품입니다. 결과는 맞지만 두 메서드를 나란히 놓고 보면 다른 곳은 if의 조건 한 줄뿐입니다. 세 번째 조건이 생기면 이 반복문을 또 복사해야 하고, 공통 부분을 고칠 때도 문제가 생깁니다. 예를 들어 결과를 수정할 수 없는 목록으로 돌려주기로 정했다면 복사본마다 같은 수정을 해야 하고, 하나라도 빠뜨리면 메서드마다 동작이 달라집니다.

복사를 피하려고 filter(products, 0, true, null)처럼 플래그 매개변수를 늘려 메서드 하나로 합치는 방법도 있습니다. 하지만 이렇게 하면 조건이 하나 늘 때마다 매개변수와 분기가 함께 늘어납니다. 호출하는 쪽에서는 0, true, null이 각각 무슨 뜻인지 메서드 선언을 찾아봐야 알 수 있습니다. 게다가 '이름이 B로 시작하는 상품'처럼 미리 준비하지 않은 조건은 여전히 표현할 수 없습니다.

값을 넘기는 매개변수와 동작을 넘기는 매개변수

expensive의 minimum은 값을 넘기는 매개변수입니다. 호출할 때마다 100이나 50 같은 기준값은 바뀌지만, '가격이 기준 이상인가'라는 비교 방식은 메서드 안에 고정돼 있습니다. 재고를 보는 조건은 기준값이 아니라 비교 방식 자체가 다르므로 값 매개변수로는 표현할 수 없습니다. 바꾸고 싶은 것이 값이 아니라 동작이라면 동작을 넘겨야 합니다.

자바에서 동작을 넘기는 방법은 그 동작을 메서드로 가진 객체를 넘기는 것입니다. 상품 하나를 받아 고를지 말지를 boolean으로 돌려주는 메서드 하나만 가진 인터페이스를 만들면 됩니다.

interface ProductRule {
    boolean test(Product product);
}

반복문을 가진 메서드는 이제 조건을 직접 쓰지 않고, 매개변수로 받은 ProductRule의 test를 상품마다 호출합니다. 그 규칙이 무엇을 검사하는지는 모른 채 true가 나온 상품만 모읍니다. 새 조건이 필요하면 ProductRule을 구현한 객체를 하나 더 만들어 넘기면 되므로, 반복문은 다시 고칠 일이 없습니다.

ProductRule을 구현하는 방법은 여러 가지입니다. 이름 있는 클래스를 선언해도 되고, 익명 클래스로 그 자리에서 만들어도 됩니다. Java 8부터는 람다식으로 더 짧게 쓸 수 있습니다. product -> product.stock() > 0은 'product를 받아 product.stock() > 0의 값을 돌려주는 test의 구현'이라는 뜻으로, 화살표 왼쪽이 매개변수이고 오른쪽이 돌려줄 값입니다. 람다에는 타입이 하나도 적혀 있지 않지만, 이 식이 ProductRule 자리에 쓰였다는 사실만으로 컴파일러는 product가 Product이고 결과가 boolean이어야 한다는 것을 압니다.

람다는 ProductRule처럼 추상 메서드가 하나뿐인 인터페이스 자리에만 쓸 수 있으며, 이런 인터페이스를 함수형 인터페이스(functional interface)라고 부릅니다. 추상 메서드가 하나뿐이어야 람다 본문이 어느 메서드의 구현인지 정해지기 때문입니다. 무엇이 함수형 인터페이스가 되는지에 대한 정확한 규칙은 함수형 인터페이스를 다루는 글에서 살펴봅니다.

판단을 받아 고르는 select 메서드

다음 예제는 이 아이디어를 코드로 옮긴 것입니다. 상품에는 분류(category)를 추가했습니다. select는 앞의 두 메서드에서 조건 한 줄을 rule.test(product)로 바꾼 것이고, 규칙은 두 방식으로 만듭니다. CategoryRule은 분류 이름을 필드에 기억하는 이름 있는 클래스이고, available과 affordable은 람다입니다. ProductRule에는 default 메서드인 and와 negate도 있어서, 이미 만든 규칙을 조합해 새 규칙을 얻을 수 있습니다.

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()));
    }
}

and의 본문은 &&를 쓰므로, device.and(available)에서는 and를 호출한 device가 먼저 검사되고 그 결과가 true일 때만 available이 검사됩니다. 이 순서는 결과에는 영향이 없지만, 뒤에서 보듯 규칙이 몇 번 호출되는지는 바꿉니다.

[Product[name=Keyboard, category=device, price=120, stock=3]]
[Product[name=Book, category=book, price=35, stock=8]]
[Product[name=Cable, category=device, price=15, stock=0]]

첫 줄은 전자기기이면서 재고가 있는 상품입니다. Cable은 전자기기지만 재고가 0이고 Book은 분류가 다르므로 Keyboard만 남습니다. 둘째 줄은 재고가 있으면서 가격이 100 이하인 상품인데, Keyboard는 가격이 120이라 빠지고 Book만 남습니다. 셋째 줄의 available.negate()는 재고 조건을 뒤집은 규칙이므로 재고가 0인 Cable을 고릅니다. 세 번 모두 select는 한 글자도 바뀌지 않았고, 넘긴 규칙만 달라졌습니다.

@FunctionalInterface는 이 인터페이스를 람다로 구현하려고 만들었다는 표시입니다. 동작을 바꾸지는 않지만, 누군가 추상 메서드를 하나 더 추가하면 인터페이스 선언에서 바로 컴파일 오류가 납니다. 이 애노테이션의 쓰임은 중첩 클래스 고르는 기준에서 살펴봤습니다.

and의 본문 product -> test(product) && other.test(product)를 자세히 보겠습니다. and는 규칙을 실행하지 않고, 두 규칙을 차례로 시험하는 새 규칙을 람다로 만들어 돌려주기만 합니다. 람다 안의 test(product)는 람다 자신이 아니라 and를 호출받은 규칙의 test이므로, device.and(available)에서는 device의 test가 불립니다. 람다는 자기만의 this를 만들지 않아서 람다 밖의 this가 그대로 보이기 때문입니다. 이 성질은 다음 글에서 익명 클래스와 비교하며 자세히 봅니다.

select가 결과를 List.copyOf로 감싸 돌려주는 것도 의도한 것입니다. 이렇게 만든 목록은 수정할 수 없으므로, 받은 쪽에서 add를 호출하면 UnsupportedOperationException이 발생합니다. 수정할 수 없는 목록과 정렬 결과 다루기에서 본 것처럼, 결과를 넘겨받은 코드가 그 목록을 슬쩍 바꿔 다른 곳에 영향을 주는 일을 막으려는 것입니다.

and는 왼쪽 규칙이 false이면 오른쪽 규칙을 부르지 않는다

첫 번째 출력인 device.and(available)이 상품마다 어떻게 평가되는지 따라가 보겠습니다. &&는 왼쪽이 false이면 오른쪽을 계산하지 않는 단축 평가(short-circuit evaluation)를 합니다. 그래서 device가 false인 상품에는 available이 아예 호출되지 않습니다.

device.and(available)의 상품별 평가

device가 false이면 available을 호출하지 않고 두 조건을 통과한 상품만 결과에 추가합니다. 현재 상품·실행된 조건·누적 결과를 고정해 단락 평가를 보여 줄 수 있습니다.

device.and(available)의 상품별 평가

device.and(available)의 상품별 평가

조건의 단락 평가

device.and(available)의 상품별 평가

device를 먼저 검사합니다. false인 Book은 available을 호출하지 않고 제외됩니다.

왼쪽이 false이면 오른쪽 판단을 건너뛴다원 device.and(available)의 상품 한 개 처리 분기다. device=false는 available을 호출하지 않고 제외하며 device=true일 때만 available을 검사한다. true/false는 반환 불리언이다. Keyboard 포함, Cable·Book 제외라는 원표는 보조로 유지한다. 모바일도 같은 5노드5관계이고 콜백을 병렬로 호출한다는 뜻이 아니다.상품 하나device true?available true?선택 결과에 추가제외truefalsetruefalse
왼쪽이 false이면 오른쪽 판단을 건너뛴다원 device.and(available)의 상품 한 개 처리 분기다. device=false는 available을 호출하지 않고 제외하며 device=true일 때만 available을 검사한다. true/false는 반환 불리언이다. Keyboard 포함, Cable·Book 제외라는 원표는 보조로 유지한다. 모바일도 같은 5노드5관계이고 콜백을 병렬로 호출한다는 뜻이 아니다.상품 하나devicetrue?availabletrue?선택 결과에 추가제외truefalsetruefalse

Keyboard

device 결과
true
available 평가
호출 · true
선택
포함

Cable

device 결과
true
available 평가
호출 · false
선택
제외

Book

device 결과
false
available 평가
호출하지 않음
선택
제외

선택 결과에는 Keyboard만 포함됩니다. Cable은 available=false이므로 제외됩니다.

이 예의 조건

  • device.and(available)은 상품별로 순차 단락 평가를 수행합니다.
관계도의 보충 설명
원 도표의 전체 관계·라벨·조건
구분내용
2원 device.and(available)의 상품 한 개 처리 분기다. device=false는 available을 호출하지 않고 제외하며 device=true일 때만 available을 검사한다. true/false는 반환 불리언이다. Keyboard 포함, Cable·Book 제외라는 원 표는 보조로 유지한다. 모바일도 같은 5개 노드와 5개 관계이고 콜백을 병렬로 호출한다는 뜻이 아니다.
원 도표의 설명과 주석
구분내용
설명원 device.and(available)의 상품 한 개 처리 분기다. device=false는 available을 호출하지 않고 제외하며 device=true일 때만 available을 검사한다. true/false는 반환 불리언이다. Keyboard 포함, Cable·Book 제외라는 원 표는 보조로 유지한다. 모바일도 같은 5개 노드와 5개 관계이고 콜백을 병렬로 호출한다는 뜻이 아니다.
전체 읽기 정보device.and(available)의 상품별 평가 device.and(available)의 상품별 평가 device.and(available)의 상품별 평가 조건의 단락 평가 device.and(available)의 상품별 평가 device를 먼저 검사합니다. false인 Book은 available을 호출하지 않고 제외됩니다. Keyboard device 결과 true available 평가 호출 · true 선택 포함 Cable device 결과 true available 평가 호출 · false 선택 제외 Book device 결과 false available 평가 호출하지 않음 선택 제외 선택 결과에는 Keyboard만 포함됩니다. Cable은 available=false이므로 제외됩니다.
예의 값과 변화
관찰·상황값·보충 정보변화·이유
왼쪽 판단

상품의 device를 먼저 호출합니다.

  • device false: available 미호출
  • device true: 다음 판단
  • false가 오른쪽 콜백 실행을 막습니다.
오른쪽 판단

device가 true인 상품만 available을 검사합니다.

  • available true: 선택에 추가
  • available false: 제외
  • 두 조건이 모두 true여야 결과에 들어갑니다.
원 결과

Keyboard만 선택되고 Cable·Book은 제외됩니다.

  • 포함: Keyboard
  • 제외: Cable,Book
  • 콜백을 병렬로 호출하거나 모든 상품에 두 검사를 보장한다고 해석하지 않습니다.

device.and(available)의 상품별 평가

device가 false이면 available을 호출하지 않고 두 조건을 통과한 상품만 결과에 추가합니다. 현재 상품·실행된 조건·누적 결과를 고정해 단락 평가를 보여 줄 수 있습니다.

device.and(available)의 상품별 평가

device.and(available)의 상품별 평가

조건의 단락 평가

device.and(available)의 상품별 평가

device를 먼저 검사합니다. false인 Book은 available을 호출하지 않고 제외됩니다.

왼쪽이 false이면 오른쪽 판단을 건너뛴다원 device.and(available)의 상품 한 개 처리 분기다. device=false는 available을 호출하지 않고 제외하며 device=true일 때만 available을 검사한다. true/false는 반환 불리언이다. Keyboard 포함, Cable·Book 제외라는 원표는 보조로 유지한다. 모바일도 같은 5노드5관계이고 콜백을 병렬로 호출한다는 뜻이 아니다.상품 하나device true?available true?선택 결과에 추가제외truefalsetruefalse
왼쪽이 false이면 오른쪽 판단을 건너뛴다원 device.and(available)의 상품 한 개 처리 분기다. device=false는 available을 호출하지 않고 제외하며 device=true일 때만 available을 검사한다. true/false는 반환 불리언이다. Keyboard 포함, Cable·Book 제외라는 원표는 보조로 유지한다. 모바일도 같은 5노드5관계이고 콜백을 병렬로 호출한다는 뜻이 아니다.상품 하나devicetrue?availabletrue?선택 결과에 추가제외truefalsetruefalse

Keyboard

device 결과
true
available 평가
호출 · true
선택
포함

Cable

device 결과
true
available 평가
호출 · false
선택
제외

Book

device 결과
false
available 평가
호출하지 않음
선택
제외

선택 결과에는 Keyboard만 포함됩니다. Cable은 available=false이므로 제외됩니다.

이 예의 조건

  • device.and(available)은 상품별로 순차 단락 평가를 수행합니다.

관계도의 보충 설명

원 도표의 전체 관계·라벨·조건
구분내용
2원 device.and(available)의 상품 한 개 처리 분기다. device=false는 available을 호출하지 않고 제외하며 device=true일 때만 available을 검사한다. true/false는 반환 불리언이다. Keyboard 포함, Cable·Book 제외라는 원 표는 보조로 유지한다. 모바일도 같은 5개 노드와 5개 관계이고 콜백을 병렬로 호출한다는 뜻이 아니다.
원 도표의 설명과 주석
구분내용
설명원 device.and(available)의 상품 한 개 처리 분기다. device=false는 available을 호출하지 않고 제외하며 device=true일 때만 available을 검사한다. true/false는 반환 불리언이다. Keyboard 포함, Cable·Book 제외라는 원 표는 보조로 유지한다. 모바일도 같은 5개 노드와 5개 관계이고 콜백을 병렬로 호출한다는 뜻이 아니다.
전체 읽기 정보device.and(available)의 상품별 평가 device.and(available)의 상품별 평가 device.and(available)의 상품별 평가 조건의 단락 평가 device.and(available)의 상품별 평가 device를 먼저 검사합니다. false인 Book은 available을 호출하지 않고 제외됩니다. Keyboard device 결과 true available 평가 호출 · true 선택 포함 Cable device 결과 true available 평가 호출 · false 선택 제외 Book device 결과 false available 평가 호출하지 않음 선택 제외 선택 결과에는 Keyboard만 포함됩니다. Cable은 available=false이므로 제외됩니다.
예의 값과 변화
관찰·상황값·보충 정보변화·이유
왼쪽 판단

상품의 device를 먼저 호출합니다.

  • device false: available 미호출
  • device true: 다음 판단
  • false가 오른쪽 콜백 실행을 막습니다.
오른쪽 판단

device가 true인 상품만 available을 검사합니다.

  • available true: 선택에 추가
  • available false: 제외
  • 두 조건이 모두 true여야 결과에 들어갑니다.
원 결과

Keyboard만 선택되고 Cable·Book은 제외됩니다.

  • 포함: Keyboard
  • 제외: Cable,Book
  • 콜백을 병렬로 호출하거나 모든 상품에 두 검사를 보장한다고 해석하지 않습니다.
상품deviceavailable결과
Keyboardtruetrue선택
Cabletruefalse제외
Bookfalse호출하지 않음제외

device는 세 상품 모두에 호출되지만 available은 Keyboard와 Cable에만 호출됩니다. 두 규칙이 상품을 보고 true나 false를 돌려주기만 한다면 이 차이는 결과에 영향이 없고, 순서를 바꿔 available.and(device)로 써도 고르는 상품은 같습니다. 하지만 규칙이 로그를 남기거나 호출 횟수를 세는 것처럼 값을 돌려주는 것 말고도 바깥 상태를 바꾼다면 이야기가 달라집니다. 이렇게 결과값 외에 바깥에 남는 영향을 부수 효과(side effect)라고 합니다.

다음 프로그램은 규칙이 호출될 때마다 한 줄씩 출력하게 만들어, 부수 효과가 있는 규칙에서 조합 순서가 무엇을 바꾸는지 보여 줍니다. traced는 규칙 하나를 받아, 원래 규칙을 실행한 뒤 그 결과를 출력하는 새 규칙을 돌려줍니다. 규칙을 받아 규칙을 돌려주므로 이것도 행동 매개변수화의 한 예입니다.

src/ShortCircuitTrace.java
import java.util.List;

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

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

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

    static ProductRule traced(String name, ProductRule rule) {
        return product -> {
            boolean result = rule.test(product);
            System.out.println("  " + name + "(" + product.name() + ") -> " + result);
            return result;
        };
    }

    static void run(String title, List<Product> products, ProductRule rule) {
        System.out.println(title);
        for (Product product : products) {
            if (rule.test(product)) {
                System.out.println("  selected " + product.name());
            }
        }
    }

    public static void main(String[] args) {
        List<Product> products = List.of(
                new Product("Keyboard", "device", 3),
                new Product("Cable", "device", 0),
                new Product("Book", "book", 8));
        ProductRule device = traced("device", product -> product.category().equals("device"));
        ProductRule available = traced("available", product -> product.stock() > 0);

        run("device.and(available)", products, device.and(available));
        run("available.and(device)", products, available.and(device));
    }
}

들여 쓴 줄 가운데 -> true나 -> false로 끝나는 줄은 규칙이 호출될 때마다 traced가 찍은 것이고, selected 줄은 두 규칙을 모두 통과한 상품입니다. 상품마다 호출 줄이 몇 개인지 세어 보면 어느 규칙이 건너뛰어졌는지 알 수 있습니다.

device.and(available)
  device(Keyboard) -> true
  available(Keyboard) -> true
  selected Keyboard
  device(Cable) -> true
  available(Cable) -> false
  device(Book) -> false
available.and(device)
  available(Keyboard) -> true
  device(Keyboard) -> true
  selected Keyboard
  available(Cable) -> false
  available(Book) -> true
  device(Book) -> false

두 조합 모두 Keyboard 하나만 고르지만 호출 기록은 다릅니다. device.and(available)에서는 device가 3번, available이 2번 호출되고, Book은 device에서 걸러져 available이 실행되지 않습니다. 순서를 뒤집으면 available이 3번, device가 2번 호출되고, 이번에는 Cable이 available에서 걸러져 device가 실행되지 않습니다. 출력 대신 데이터베이스 조회나 외부 서비스 호출이었다면, 같은 결과를 내면서도 호출 횟수와 대상이 달라진 셈입니다.

그래서 판단을 넘길 때는 규칙을 부수 효과 없이 만드는 것이 기본입니다. 같은 상품에는 언제나 같은 답을 내고 바깥 상태를 바꾸지 않는 규칙이라면, 조합 순서나 호출 횟수가 바뀌어도 결과를 예측할 수 있습니다. 규칙을 몇 번, 어떤 순서로 호출할지는 규칙을 받은 메서드가 정하는 일이고, 나중에 병렬 처리나 재시도가 들어가면 얼마든지 달라질 수 있기 때문입니다. 결제나 메시지 발송처럼 정확히 한 번만 일어나야 하는 일은 조건 판단 안에 넣지 말고, 고르기가 끝난 뒤 따로 실행합니다.

JDK에 이미 있는 Predicate

ProductRule처럼 '값 하나를 받아 boolean을 돌려주는' 인터페이스는 아주 흔해서, JDK는 java.util.function 패키지에 Predicate<T>를 미리 준비해 두었습니다. 추상 메서드 이름도 test이고 and, or, negate라는 default 메서드도 있습니다. Predicate의 Javadoc은 and가 단축 평가를 한다고, 즉 이 조건이 false이면 다른 조건을 평가하지 않는다고 명시합니다. 실제로 앞 예제의 ProductRule을 Predicate<Product>로 바꿔도 출력은 똑같습니다.

이 글에서 인터페이스를 직접 만든 것은 동작을 넘기는 구조를 처음부터 보여 주기 위해서입니다. 실제 코드에서는 표준 인터페이스로 충분한지 먼저 확인합니다. Predicate를 쓰면 스트림의 filter처럼 Predicate를 받는 JDK API에 그대로 넘길 수 있습니다. 반대로 ProductRule처럼 도메인 이름을 가진 인터페이스는 무엇을 판단하는지가 타입 이름에 드러나고, 도메인에 맞는 default 메서드를 더할 수 있다는 장점이 있습니다. java.util.function의 표준 인터페이스는 표준 함수형 인터페이스와 함수 조합에서 정리합니다.

정렬 순서도 넘기는 동작이다: Comparator

고르는 일만 행동 매개변수화의 예가 아닙니다. Comparable과 Comparator에서 쓴 Comparator<T>도 같은 구조입니다. 정렬 알고리즘은 원소를 비교하고 자리를 바꾸는 일을 맡고, 두 원소 가운데 무엇이 앞인지 정하는 판단은 Comparator로 받아 옵니다. compare(a, b)는 a가 앞이면 음수, 같으면 0, 뒤면 양수를 돌려줍니다.

다음 예제는 장애 처리 티켓을 두 가지 순서로 정렬합니다. orderedCopy는 원본 목록을 복사해 그 복사본을 정렬한 뒤, 수정할 수 없는 목록으로 돌려줍니다.

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);
    }
}

비교자를 만드는 부분부터 읽어 보겠습니다. Comparator.comparingInt(Ticket::severity)는 티켓에서 int 값 하나를 꺼내 그 값으로 비교하는 비교자를 만듭니다. Ticket::severity는 '티켓을 받아 severity()를 호출한 결과를 돌려주는 함수', 즉 ticket -> ticket.severity()를 줄여 쓴 메서드 참조(method reference)이며, 람다 문법을 다루는 글에서 다시 설명합니다. thenComparingLong과 thenComparing은 앞 기준으로 비교한 결과가 0일 때만 쓰는 다음 기준이고, reversed()는 지금까지 만든 비교자의 순서를 뒤집습니다.

실행 결과:

[Ticket[id=T-2, severity=1, openedAt=100], Ticket[id=T-1, severity=1, openedAt=200], Ticket[id=T-3, severity=2, openedAt=300]]
[Ticket[id=T-3, severity=2, openedAt=300], Ticket[id=T-1, severity=1, openedAt=200], Ticket[id=T-2, severity=1, openedAt=100]]
original=[Ticket[id=T-3, severity=2, openedAt=300], Ticket[id=T-1, severity=1, openedAt=200], Ticket[id=T-2, severity=1, openedAt=100]]

첫 줄은 queueOrder로 정렬한 결과입니다. 심각도(severity)가 1인 T-1과 T-2가 앞에 오고, 둘은 심각도가 같으므로 다음 기준인 열린 시각(openedAt)이 빠른 T-2가 먼저입니다. 둘째 줄은 열린 시각의 내림차순이라 가장 최근인 T-3이 맨 앞에 옵니다.

마지막 줄에서 원본의 순서가 그대로인 것은 orderedCopy가 복사본만 정렬했기 때문입니다. 이 예제에서는 복사가 꼭 필요하기도 합니다. List.of로 만든 목록은 수정할 수 없어서, tickets.sort(queueOrder)처럼 원본을 직접 정렬하려 하면 UnsupportedOperationException이 발생합니다.

비교자를 직접 구현할 때는 지켜야 할 규칙이 있습니다. compare(a, b)와 compare(b, a)는 부호가 반대여야 하고, a가 b보다 앞이고 b가 c보다 앞이면 a도 c보다 앞이어야 합니다. 이 규칙이 깨지면 정렬 결과가 틀어지거나 List.sort가 IllegalArgumentException을 던질 수 있습니다. 또 left.price() - right.price()처럼 뺄셈으로 비교하면 오버플로 때문에 부호가 뒤집힐 수 있다는 것을 Comparable과 Comparator에서 확인했습니다. Integer.compare나 위의 comparingInt 같은 팩터리 메서드를 쓰면 이런 실수를 피할 수 있습니다.

언제 동작을 매개변수로 뺄까

모든 조건을 인터페이스로 뽑아낼 필요는 없습니다. 행동 매개변수화가 제값을 하는 것은 공통 알고리즘은 안정적인데, 판단은 호출하는 곳마다 다르고 새 판단이 계속 추가되는 경우입니다. 상품 필터와 정렬 순서가 대표적입니다. 반대로 한 곳에서만 쓰는 조건을 인터페이스로 분리하면 따라가야 할 이름과 호출 단계만 늘어납니다.

동작을 넘기기로 했다면 이름에 신경 써야 합니다. 람다 자체에는 이름이 없으므로, 그 동작이 무엇인지는 인터페이스 이름(ProductRule), 메서드 이름(test), 매개변수 이름(rule), 람다를 담은 변수 이름(available)이 알려 줍니다. select(products, rule)은 무엇을 하는지 읽히지만 process(items, f)는 그렇지 않습니다. 또 람다는 본문에서 사용하는 바깥 객체를 붙잡고 있으므로, 오래 보관하는 콜백은 그 객체의 수명까지 늘립니다. 이 문제는 다음 글에서 다룹니다.

정리

  • 반복문에서 바뀌지 않는 부분(순회와 결과 모으기)은 메서드에 두고, 바뀌는 판단은 매개변수로 받습니다. 이것이 행동 매개변수화입니다.
  • 자바에서는 동작을 그 동작을 메서드로 가진 객체로 넘깁니다. 추상 메서드가 하나뿐인 함수형 인터페이스 자리에는 람다식으로 구현을 짧게 쓸 수 있습니다.
  • default 메서드로 and, negate 같은 조합을 제공하면 작은 규칙을 묶어 새 규칙을 만들 수 있습니다. and는 왼쪽 규칙이 false이면 오른쪽 규칙을 호출하지 않습니다.
  • 규칙은 부수 효과 없이 만들어야 조합 순서나 호출 횟수가 바뀌어도 결과를 예측할 수 있습니다.
  • JDK의 Predicate<T>는 ProductRule과 같은 모양의 표준 인터페이스이고, Comparator<T>도 정렬 알고리즘에 비교 판단을 넘기는 행동 매개변수화입니다.

연습 문제

1. or 규칙의 호출 횟수 예측하기

ShortCircuitTrace의 ProductRule에 '둘 중 하나라도 true이면 true'인 default 메서드 or를 추가하세요. 그다음 같은 세 상품과 traced 규칙으로 device.or(available)을 상품마다 시험하면 어떤 상품이 선택되고, 두 규칙이 각각 몇 번 호출되는지 실행하기 전에 예측해 보세요.

풀이 보기

and와 같은 모양으로 ||를 쓰면 됩니다.

default ProductRule or(ProductRule other) {
    return product -> test(product) || other.test(product);
}

||는 왼쪽이 true이면 오른쪽을 계산하지 않습니다. Keyboard와 Cable은 device가 true이므로 그대로 선택되고 available은 호출되지 않습니다. Book만 device가 false라서 available까지 확인하고, 재고가 있으므로 선택됩니다. 따라서 세 상품이 모두 선택되고, device는 3번, available은 1번 호출됩니다. main의 마지막 두 줄을 run("device.or(available)", products, device.or(available)); 한 줄로 바꿔 실행하면 다음과 같습니다.

device.or(available)
  device(Keyboard) -> true
  selected Keyboard
  device(Cable) -> true
  selected Cable
  device(Book) -> false
  available(Book) -> true
  selected Book

2. 재시도 여부를 정책으로 넘기기

실패할 수 있는 연산을 다시 시도하는 실행기를 만듭니다. 최대 시도 횟수를 실행기 안의 if 문에 고정하지 말고, RetryPolicy가 '몇 번째 시도가 어떤 예외로 실패했는지'를 받아 다음 시도까지 기다릴 시간(Duration)을 돌려주게 하세요. 음수는 그만 시도하라는 뜻이고, 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());
    }
}

실행 결과:

Execution[value=ok, decisions=[attempt=1, delay=25, attempt=2, delay=50]]
calls=3

실행기 execute는 연산을 호출하고, 성공하면 값을 돌려주고, 실패하면 정책에 다음 행동을 묻는 일만 합니다. 몇 번까지 다시 시도할지, 얼마나 기다릴지는 모두 람다로 넘긴 twoRetries가 정합니다. flaky는 처음 두 번 IllegalStateException을 던지고 세 번째에 "ok"를 돌려주는 연산입니다. 람다 안에서 지역 변수를 바꿀 수 없기 때문에 호출 횟수는 AtomicInteger라는 객체에 담아 셉니다. 이 제약은 다음 글에서 다룹니다.

연산 실패와 재시도 판단 기록

연산 실패 뒤 정책이 지연을 기록하고 음수면 원래의 실패를 던지며 아니면 시도 번호를 올려 다시 호출합니다. 실제 main은 잠들지 않으므로 기록된 지연과 대기를 구분하여 추적합니다.

연산 실패와 재시도 판단 기록

연산 실패와 재시도 판단 기록

재시도 판단

연산 실패와 재시도 판단 기록

실패한 호출만 정책을 평가합니다. 세 번째 호출은 성공하므로 두 판단 기록과 ok를 반환합니다.

성공 반환과 음수 중단을 가진 원 실행기의 루프실패한 호출에서만 정책을 평가해 지연을 기록한다. 음수이면 원 실패를 던져 Execution은 반환하지 않고, 0이상이면 시도 번호를 증가해 다시 연산을 호출한다. 화살표는 원 코드 제어 흐름이며 지연 숫자에 맞춰 sleep한다는 뜻이 아니다. 원 main은 세 번째 성공이므로 중단 경로는 별도 입력이다. 같은 7노드7관계를 모바일에서도 유지한다.연산 호출성공?값·판단 목록 반환재시도 정책지연 기록지연이 음수?원 실패 던짐반환 없음시도 +1예아니요예아니요다시 호출
성공 반환과 음수 중단을 가진 원 실행기의 루프실패한 호출에서만 정책을 평가해 지연을 기록한다. 음수이면 원 실패를 던져 Execution은 반환하지 않고, 0이상이면 시도 번호를 증가해 다시 연산을 호출한다. 화살표는 원 코드 제어 흐름이며 지연 숫자에 맞춰 sleep한다는 뜻이 아니다. 원 main은 세 번째 성공이므로 중단 경로는 별도 입력이다. 같은 7노드7관계를 모바일에서도 유지한다.연산 호출성공?값·판단 목록반환재시도 정책지연 기록지연이음수?원 실패 던짐반환 없음시도 +1예아니요예아니요다시 호출
  1. 1회

    연산 결과
    temporary 1 실패
    정책과 기록
    정책 호출 · delay=25 기록
  2. 2회

    연산 결과
    temporary 2 실패
    정책과 기록
    정책 호출 · delay=50 기록
  3. 3회

    연산 결과
    ok 반환
    정책과 기록
    정책 미호출 · 앞선 두 기록 반환

지연 25·50은 밀리초 값의 기록입니다. 이 실행기는 실제 대기나 sleep을 수행하지 않습니다.

이 예의 조건

  • 지연 숫자를 기록하며 sleep하지 않습니다. 원 main은 세 번째 호출에서 성공합니다.
관계도의 보충 설명
원 도표의 전체 관계·라벨·조건
구분내용
2실패한 호출에서만 정책을 평가해 지연을 기록한다. 음수이면 원 실패를 던져 Execution은 반환하지 않고, 0 이상이면 시도 번호를 증가시켜 다시 연산을 호출한다. 화살표는 원 코드 제어 흐름이며 지연 숫자에 맞춰 sleep한다는 뜻이 아니다. 원 main은 세 번째 성공이므로 중단 경로는 별도 입력이다. 같은 7개 노드와 7개 관계를 모바일에서도 유지한다.
원 도표의 설명과 주석
구분내용
설명실패한 호출에서만 정책을 평가해 지연을 기록한다. 음수이면 원 실패를 던져 Execution은 반환하지 않고, 0 이상이면 시도 번호를 증가시켜 다시 연산을 호출한다. 화살표는 원 코드 제어 흐름이며 지연 숫자에 맞춰 sleep한다는 뜻이 아니다. 원 main은 세 번째 성공이므로 중단 경로는 별도 입력이다. 같은 7개 노드와 7개 관계를 모바일에서도 유지한다.
전체 읽기 정보연산 실패와 재시도 판단 기록 연산 실패와 재시도 판단 기록 연산 실패와 재시도 판단 기록 재시도 판단 연산 실패와 재시도 판단 기록 실패한 호출만 정책을 평가합니다. 세 번째 호출은 성공하므로 두 판단 기록과 ok를 반환합니다. 1회 연산 결과 temporary 1 실패 정책과 기록 정책 호출 · delay=25 기록 2회 연산 결과 temporary 2 실패 정책과 기록 정책 호출 · delay=50 기록 3회 연산 결과 ok 반환 정책과 기록 정책 미호출 · 앞선 두 기록 반환 지연 25·50은 밀리초 값의 기록입니다. 이 실행기는 실제 대기나 sleep을 수행하지 않습니다.
예의 값과 변화
관찰·상황값·보충 정보변화·이유
실패와 정책

첫 실패의 attempt 1에서 정책이 반환한 25 ms를 기록합니다.

  • 연산: 첫 호출 실패
  • 판단: 25
  • 실패한 호출에서만 정책을 평가합니다.
다음 재시도

attempt 2도 실패해 50을 기록하고 attempt 3으로 넘어갑니다.

  • 판단 목록: 25,50
  • 대기 코드: 없음
  • 0 이상이라는 판단이 시도 번호 증가와 재호출을 허용합니다.
성공 또는 중단

원 main의 세 번째 호출은 성공하여 값·판단 목록을 반환합니다.

  • 원 main: calls 3
  • 항상 실패 대안: 25,50,-1 뒤 원 예외, Execution 없음
  • 음수 분기는 메모리 기록이 있어도 결과로 반환하지 않습니다.

연산 실패와 재시도 판단 기록

연산 실패 뒤 정책이 지연을 기록하고 음수면 원래의 실패를 던지며 아니면 시도 번호를 올려 다시 호출합니다. 실제 main은 잠들지 않으므로 기록된 지연과 대기를 구분하여 추적합니다.

연산 실패와 재시도 판단 기록

연산 실패와 재시도 판단 기록

재시도 판단

연산 실패와 재시도 판단 기록

실패한 호출만 정책을 평가합니다. 세 번째 호출은 성공하므로 두 판단 기록과 ok를 반환합니다.

성공 반환과 음수 중단을 가진 원 실행기의 루프실패한 호출에서만 정책을 평가해 지연을 기록한다. 음수이면 원 실패를 던져 Execution은 반환하지 않고, 0이상이면 시도 번호를 증가해 다시 연산을 호출한다. 화살표는 원 코드 제어 흐름이며 지연 숫자에 맞춰 sleep한다는 뜻이 아니다. 원 main은 세 번째 성공이므로 중단 경로는 별도 입력이다. 같은 7노드7관계를 모바일에서도 유지한다.연산 호출성공?값·판단 목록 반환재시도 정책지연 기록지연이 음수?원 실패 던짐반환 없음시도 +1예아니요예아니요다시 호출
성공 반환과 음수 중단을 가진 원 실행기의 루프실패한 호출에서만 정책을 평가해 지연을 기록한다. 음수이면 원 실패를 던져 Execution은 반환하지 않고, 0이상이면 시도 번호를 증가해 다시 연산을 호출한다. 화살표는 원 코드 제어 흐름이며 지연 숫자에 맞춰 sleep한다는 뜻이 아니다. 원 main은 세 번째 성공이므로 중단 경로는 별도 입력이다. 같은 7노드7관계를 모바일에서도 유지한다.연산 호출성공?값·판단 목록반환재시도 정책지연 기록지연이음수?원 실패 던짐반환 없음시도 +1예아니요예아니요다시 호출
  1. 1회

    연산 결과
    temporary 1 실패
    정책과 기록
    정책 호출 · delay=25 기록
  2. 2회

    연산 결과
    temporary 2 실패
    정책과 기록
    정책 호출 · delay=50 기록
  3. 3회

    연산 결과
    ok 반환
    정책과 기록
    정책 미호출 · 앞선 두 기록 반환

지연 25·50은 밀리초 값의 기록입니다. 이 실행기는 실제 대기나 sleep을 수행하지 않습니다.

이 예의 조건

  • 지연 숫자를 기록하며 sleep하지 않습니다. 원 main은 세 번째 호출에서 성공합니다.

관계도의 보충 설명

원 도표의 전체 관계·라벨·조건
구분내용
2실패한 호출에서만 정책을 평가해 지연을 기록한다. 음수이면 원 실패를 던져 Execution은 반환하지 않고, 0 이상이면 시도 번호를 증가시켜 다시 연산을 호출한다. 화살표는 원 코드 제어 흐름이며 지연 숫자에 맞춰 sleep한다는 뜻이 아니다. 원 main은 세 번째 성공이므로 중단 경로는 별도 입력이다. 같은 7개 노드와 7개 관계를 모바일에서도 유지한다.
원 도표의 설명과 주석
구분내용
설명실패한 호출에서만 정책을 평가해 지연을 기록한다. 음수이면 원 실패를 던져 Execution은 반환하지 않고, 0 이상이면 시도 번호를 증가시켜 다시 연산을 호출한다. 화살표는 원 코드 제어 흐름이며 지연 숫자에 맞춰 sleep한다는 뜻이 아니다. 원 main은 세 번째 성공이므로 중단 경로는 별도 입력이다. 같은 7개 노드와 7개 관계를 모바일에서도 유지한다.
전체 읽기 정보연산 실패와 재시도 판단 기록 연산 실패와 재시도 판단 기록 연산 실패와 재시도 판단 기록 재시도 판단 연산 실패와 재시도 판단 기록 실패한 호출만 정책을 평가합니다. 세 번째 호출은 성공하므로 두 판단 기록과 ok를 반환합니다. 1회 연산 결과 temporary 1 실패 정책과 기록 정책 호출 · delay=25 기록 2회 연산 결과 temporary 2 실패 정책과 기록 정책 호출 · delay=50 기록 3회 연산 결과 ok 반환 정책과 기록 정책 미호출 · 앞선 두 기록 반환 지연 25·50은 밀리초 값의 기록입니다. 이 실행기는 실제 대기나 sleep을 수행하지 않습니다.
예의 값과 변화
관찰·상황값·보충 정보변화·이유
실패와 정책

첫 실패의 attempt 1에서 정책이 반환한 25 ms를 기록합니다.

  • 연산: 첫 호출 실패
  • 판단: 25
  • 실패한 호출에서만 정책을 평가합니다.
다음 재시도

attempt 2도 실패해 50을 기록하고 attempt 3으로 넘어갑니다.

  • 판단 목록: 25,50
  • 대기 코드: 없음
  • 0 이상이라는 판단이 시도 번호 증가와 재호출을 허용합니다.
성공 또는 중단

원 main의 세 번째 호출은 성공하여 값·판단 목록을 반환합니다.

  • 원 main: calls 3
  • 항상 실패 대안: 25,50,-1 뒤 원 예외, Execution 없음
  • 음수 분기는 메모리 기록이 있어도 결과로 반환하지 않습니다.

첫 호출이 실패하면 정책이 attempt=1에 25ms를 돌려주고, 실행기는 이를 기록한 뒤 시도 번호를 2로 올립니다. 두 번째 실패에서는 50ms가 기록됩니다. 세 번째 호출은 성공하므로 정책은 더 호출되지 않고, 값 ok와 두 결정이 담긴 Execution이 반환됩니다. 출력의 decisions는 원소가 넷인 것처럼 보이지만, 실제로는 "attempt=1, delay=25"와 "attempt=2, delay=50"이라는 문자열 두 개입니다. 문자열 안의 쉼표와 목록이 원소 사이에 넣는 쉼표가 섞여 보일 뿐입니다.

연산이 매번 실패하면 연산과 정책은 각각 3번 호출됩니다. 기록되는 지연은 25, 50, -1이고, 세 번째 결정이 음수이므로 실행기는 마지막 시도에서 잡은 예외를 그대로 다시 던집니다. 그러면 Execution은 만들어지지 않고, 지역 변수 decisions에 쌓은 기록도 호출한 쪽에 전달되지 않습니다. 또 실행기에는 기다리는 코드가 없으므로 25와 50을 더한 75ms는 기록된 숫자일 뿐 실제로 흐른 시간이 아닙니다. 정책이 '두 번 다시 시도'를 허용하면 첫 시도까지 합쳐 연산은 모두 세 번 실행된다는 점도 구분해 두세요.

실제 서비스에서 재시도를 만든다면 생각할 것이 더 있습니다. 어떤 예외가 다시 시도할 만한 것인지 분류해야 하고, 전체 시간 한도와 여러 클라이언트가 동시에 몰리지 않게 지연에 섞는 무작위 값(jitter)도 정해야 합니다. 기다리는 동안 인터럽트가 오면 인터럽트 상태를 되살려야 하고, 무엇보다 같은 연산을 여러 번 실행해도 안전한지(멱등성)를 먼저 확인해야 합니다.

다음 글

익명 클래스와 람다의 차이에서는 같은 동작을 익명 클래스와 람다로 만들었을 때 무엇이 같고 무엇이 다른지 살펴봅니다. 이 글의 and에서 본 this의 해석, 람다가 바깥 지역 변수를 읽는 규칙, 람다 객체의 동일성을 실제 컴파일 오류와 실행 결과로 확인합니다.