함수형 인터페이스가 뭘까

함수형 인터페이스는 1개의 추상메소드를 가지고 있는데요. 당연하게도 아무런 역할을 하지 않는 인터페이스입니다.

java.util.function

 

이런 함수형 인터페이스는 function 패키지에 담겨져있습니다.

 

그 내용을 열어보면

많은 인터페이스들이 존재합니다. 이 함수형 인터페이스에는 똑같은 특징이 있는데 @FunctionalInterface 어노테이션을 사용하고있다는 겁니다. 이 어노테이션은 해당 인터페이스가 함수형 인터페이스 조건에 맞는지 검사해준다고 하는데요. 그렇지만 이 어노테이션을 사용하지 않더라도 동작하는데에는 문제가 없으니 참고해주시면 될것 같습니다.

 

함수형 인터페이스 정의

  1. 추상메소드가 단 한개인 인터페이스
  2. static, default 메소드가 여러개여도 추상메소드가 단 한개인 인터페이스

 

 

람다표현식

자바 8 이상을 사용하시는 분들은 lambda 에 대해서 익숙하실 겁니다. 함수형 인터페이스의 목적은 람다표현식을 이용해 함수형 프로그래밍을 구현하기 위해서라고 할 수 있습니다.

 

 

 

 

함수형 인터페이스 종류

function 패키지 이외에 Callable, Comparator 등의 함수형 인터페이스가 있지만 이 글에서는 function 패키지 내에서만 설명하겠습니다.

인터페이스 메소드 설명
Runnable void run() 매개변수를 받지 않고, 반환하지 않습니다.
Thread 에서 Runnable 을 사용하고있습니다.
Consumer<T> void accept(T t) 매개변수를 받고, 리턴하지 않을때 사용합니다.
forEach 메소드에서 이 인터페이스를 사용하고있습니다.
Function<T, R> R apply(T t) 제네릭 첫번째 타입을 매개변수로 받고,제네릭 두번째 타입을 반환합니다. Stream 에 map 메소드에서 사용하고있습니다.
Predicate<T> boolean test(T t) 매개변수를 받아 true / false 인지를 반환합니다.
Stream 에 filter에서 사용하고있습니다.
Supplier<T> T get() 매개변수를 받지않고, 어떤 값을 반환합니다.
Optional에 or(),orElseThrow() 등 메소드에서 사용하고있습니다.

 

 

 

 

1. Runnable

함수형 인터페이스 중에서 가장 애매한게 Runnable 이라고 생각합니다. 매개변수를 받지도 않고 반환값도 없는 이 인터페이스는 도대체 어디에 사용되는걸까요?

 

Thread 는 Runnable을 생성자 파라미터로 받아 사용하고 있는 것을 확인할 수 있습니다.

        Thread t = new Thread(() -> {
            for (int i = 0; i < 10; i++) {
                System.out.println(i);
            }
        });
        t.start();

 

Thread 에서 어떤 메소드를 실행할지에 대한 표현을 Runnable 를 사용하여 구현하였습니다. 이 Runnable 내에는 어떠한 함수가 와도 타입으로부터 자유롭습니다. 

 

 

 

2. Consumer

인터페이스 메소드 설명
Consumer<T> void accept(T t) 1개의 타입을 받음
BiConsumer<T, U> void accept(T t, U u) 2개의 타입을 받음
IntConsumer void accept(int value) 1개의 int 값을 받음
LongConsumer void accept(long value) 1개의 long 값을 받음
DoubleConsumer void accept(double value) 1개의 double 값을 받음
ObjIntConsumer<T> void accept(T t, int value) 1개의 타입과 1개의 int 값을 받음
ObjLongConsumer<T> void accept(T t, long value) 1개의 타입과 1개의 long 값을 받음
ObjDoubleConsumer<T> void accept(T t, double value) 1개의 타입과 1개의 double 값을 받음

 

 

 

@FunctionalInterface
public interface Consumer<T> {

    void accept(T t);

    default Consumer<T> andThen(Consumer<? super T> after) {
        Objects.requireNonNull(after);
        return (T t) -> { accept(t); after.accept(t); };
    }
    
}

 

이 Consumer 는 forEach 메소드에서 사용하는 것 외에는 어디다가 이 메소드를 사용해야 유용하지? 라는 의문이 들었습니다. 고민을 하다가 Consumer 인터페이스의 변수명을 이용해보자라는 생각이 들었는데요. 하나의 작업을 Consumer로 묶으면 변수명을 보고 로직의 이유를 바로 유츄가 가능하다는 점을 이용해보았습니다.

 

    public static void main(String[] args) {
        
        List<Order> orders = repository.getOrders();

        Consumer<List<Order>> 사용한쿠폰중만료된쿠폰삭제 = repository.consumeDeleteCoupon();
        Consumer<List<Order>> 금액환불 = repository.consumeRefund();
        Consumer<List<Order>> 영수증생성 = repository.consumeSaveReceipt();
        Consumer<List<Order>> 주문상태변경 = repository.consumeChangeCancelStatus();

        사용한쿠폰중만료된쿠폰삭제.andThen(
        금액환불.andThen(
        영수증생성.andThen(
        주문상태변경)))
        .accept(orders);

    }

 

물론 위에처럼 무턱대고 저렇게 하지는 않겠지만, 적절한 예시가 생각이 나지 않아 일단 이렇게 예시를 만들어봤습니다.

예를들어, 취소요청이 들어온 주문 건에 대한 비즈니스로직을 작성하려고합니다.

1. 사용된 쿠폰은 다시 사용할 수 있게 변경되어야 하고, 현재시간 기준 만료된 쿠폰은 삭제되어야한다.

2. 남은 금액은 환불되어야한다.

3. 환불건에 대해서 기록이 남아야한다.

4. 주문건은 삭제되지않고 기록을 남겨둔다.

 

라는 요청이라고 가정했을 때, 위와같이 Consumer 를 이용해서 각 기능별로 나눈다면 해당 작업에 대한 로직을 변수명을 보고 유추하기 쉽고, 작업의 순서를 파악하기도 쉬워졌습니다. 이렇게 로직을 분리해 결합도를 낮춰 사이드 이펙트에 대한 문제도 조금 해소되는것 같습니다.

 

추가로 andThen의 반환타입은 Consumer<T> 이기 때문에 이어 붙히는것이 가능합니다. 따라서 위의 코드를 더 정리한다면

    public static void main(String[] args) {
        List<Order> orders = service.getOrders();
        Consumer<List<Order>> consumer = service.getConsumeCancelOrder();
        consumer.accept(orders);
    }
    
public class Service {

    private static final Repository repository = new Repository();

    public Consumer<List<Order>> getConsumeCancelOrder() {
        Consumer<List<Order>> 사용한쿠폰중만료된쿠폰삭제 = repository.consumeDeleteCoupon();
        Consumer<List<Order>> 금액환불 = repository.consumeRefund();
        Consumer<List<Order>> 영수증생성 = repository.consumeSaveReceipt();
        Consumer<List<Order>> 주문상태변경 = repository.consumeChangeCancelStatus();

        return 사용한쿠폰중만료된쿠폰삭제.andThen(
            금액환불.andThen(
            영수증생성.andThen(
            주문상태변경)));
    }
}

accept와 Consumer 를 분리하는 것도 괜찮은것같습니다. 물론 getConsumeCancelOrder 를 호출할때마다 새로 생성할 필요가 없으므로 Spring의 Bean이나 static, Singleton 으로 관리하는 것이 옳지만 여기까지만 설명하도록 하겠습니다.

 

 

Consumer이의외 BiConsumer, IntConsumer 등등 사용법은 Consumer와 동일합니다. 다만 ObjXXXConsumer 인터페이스는 andThen 메소드가 없으니 유의바랍니다.

 

 

 

3. Function

인터페이스 메소드 설명
Function<T, R> R apply(T t) T를 받아서 R을 반환
Function<T, U, R> R apply(T t, U u) T와 U를 받아서 R을 반환
IntFunction<R> R apply(int value) int를 받아서 R을 반환
LongFunction<R> R apply(long value) long을 받아서 R을 반환
DoubleFunction<R> R apply(double value) double을 받아서 R을 반환
XXXToYYYFunction Y applyAsXXX(X value) XXX를 받아서 YYY를 반환
ToXXXFunction<T> X applyAsXXX(T t) T를 받아서 XXX를 반환
ToXXXBiFunction<T, U> X applyAsXXX(T t, U u) T와 U를 받아서 XXX를 반환

 

 

public enum Payment {

    KAKAO(i -> (int) (0.95 * i)),
    NAVER(i -> (int) (0.90 * i)),
    APPLE(i -> i),;

    private final Function<Integer, Integer> function;

    public int execute(int amount) {
        return function.apply(amount);
    }

    Payment(Function<Integer, Integer> function) {
        this.function = function;
    }
}

    public static void main(String[] args) {
        int amount = 10000;

        Payment payment = Payment.KAKAO;
        int execute = payment.execute(amount);
        System.out.println("execute = " + execute); // 9500
    }

 

제가 가장 즐겨사용하는 것이 Function 함수입니다. 특히 열거형타입과 함께 사용한다면 더욱 편해지는데요.

위의 예시 코드는 결제방식에 따라 결제금액이 할인되는(?) 말도안되는 로직이지만 이렇게 enum 타입을 사용한다면 조금더 직관적으로 사용할 수 있다는 장점이 있는것 같습니다.

 

 

 

4. Predicate

인터페이스 메소드 설명
Predicate<T> boolean test(T t) T를 받아 true/false 를 반환
BiPredicate<T, U> boolean test(T t, U u) T와 U를 받아 true/false 를 반환
IntPredicate boolean test(int value) int를 받아 true/false 를 반환
LongPredicate boolean test(long value) long을 받아 true/false 를 반환
DoublePredicate boolean test(double value) double을 받아 true/false 를 반환

 

 

    public static void main(String[] args) {

        Predicate<User> filterScore = user -> user.getScore() > 50;

        List<User> users = service.getUsers();

        for (User user : users) {
            if (filterScore.test(user)) {
                System.out.println(user.getName());
            }
        }

    }

 

Predicate는 매개변수로 들어온 데이터를 true / false로 반환하는 인터페이스입니다.

Stream을 사용할때 filter에서 이 Predicate 인터페이스를 받고 있습니다.

 

 

5. Supplier

인터페이스 메소드 설명
Supplier<T> T get() T를 반환
IntSupplier int getAsInt() int를 반환
LongSupplier long getAsLong() long을 반환
DoubleSupplier double getAsDouble() double을 반환
BooleanSupplier boolean getAsBoolean() boolean을 반환

 

Supplier도 어떻게 사용한느지 사실 조금 애매한 감이 있습니다. 데이터를 받지 않고 어떤 값을 반환한다는게 사실 감이 잡히지 않습니다.

Optional 에서 or() 이나 orElseThrow() 에서 사용하는것을 보았을때는 딱히 메리트있어보이는 함수는 아니었습니다. 그런데 조금더 찾아보니까 get()을 통해 Lazy Evaluation(지연 연산) 을 할 수 있다는 것을 알아냈습니다.

 

 

    public static void main(String[] args) {
        long start = System.currentTimeMillis();

        printOnlyValid(1, threadSleep());
        printOnlyValid(0, threadSleep());
        printOnlyValid(-1, threadSleep());

        long end = System.currentTimeMillis();
        System.out.println("걸린시간 : " + ((end - start) / 1000) + "초");
    }

    private static String threadSleep() {
        try {
            Thread.sleep(3000);
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        }
        return "Success";
    }

    private static void printOnlyValid(int index, String data) {
        if (index > 0) {
            System.out.println(data);
        } else {
            System.out.println("Invalid");
        }
    }

printOnlyValid 메소드를 실행하기에 앞서 String data 값을 가져오기 위해 threadSleep() 메소드를 실행하는 모습입니다.

threadSleep() 메소드는 3초의 시간이 걸리기때문에 3번의 실행으로 총 9초의 시간이 걸렸습니다.

 

이 메소드를 Supplier 함수를 사용하여 바꿔보겠습니다.

 

    public static void main(String[] args) {
        long start = System.currentTimeMillis();

        printOnlyValid(1, () -> threadSleep());
        printOnlyValid(0, () -> threadSleep());
        printOnlyValid(-1, () -> threadSleep());

        long end = System.currentTimeMillis();
        System.out.println("걸린시간 : " + ((end - start) / 1000) + "초");
    }

    private static String threadSleep() {
        try {
            Thread.sleep(3000);
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        }
        return "Success";
    }

    private static void printOnlyValid(int index, Supplier<String> data) {
        if (index > 0) {
            System.out.println(data.get());
        } else {
            System.out.println("Invalid");
        }
    }

printOnlyValid의 data 타입을 Supplier로 변경했기때문에 index > 0 일 경우에만 thradSleep() 메소드가 실행되어 3번의 메소드 실행 시간이 9초에서 3초로 줄었습니다.

 

 

결론

함수형 인터페이스는 그 종류도 많고 사용방법도 다양해 언제 어디서 사용할지 애매한 경우가 많은 것같습니다. 그래도 이 글을 작성하면서 JAVA 8 에서 함수형 인터페이스, Lambda, Stream 의 연결성에 대해서 공부가 된것같습니다.

 

공부하면서 작성한 글이기때문에 틀린부분이 있을 수 있습니다. 언제든지 지적해주시기 바랍니다.

+ Recent posts