[Java ] 개념노트 56 함수형 인터페이스 이해하기

[Java 개념노트 56] 함수형 인터페이스 이해하기

안녕하세요. Java 개념노트 시리즈 쉰여섯 번째 글입니다.

지난 글에서는 메서드처럼 동작하는 코드를 짧게 표현하는 람다식에 대해 정리했습니다. 이번 글에서는 람다식과 함께 자주 사용되는 함수형 인터페이스에 대해 알아보겠습니다.

람다식은 혼자 존재하는 문법이 아니라, 보통 어떤 인터페이스의 추상 메서드를 구현하는 방식으로 사용됩니다. 이때 람다식의 대상이 되는 인터페이스가 바로 함수형 인터페이스입니다.


1. 함수형 인터페이스란?

함수형 인터페이스는 추상 메서드를 하나만 가진 인터페이스입니다.

추상 메서드가 하나뿐이기 때문에 람다식이 어떤 메서드를 구현하는지 명확하게 알 수 있습니다.

interface Hello {
    void say();
}

위 인터페이스에는 추상 메서드가 say() 하나만 있습니다. 따라서 함수형 인터페이스로 사용할 수 있습니다.

쉽게 말하면
함수형 인터페이스는 람다식이 들어갈 수 있는 자리이며, 실행해야 할 메서드가 딱 하나만 정해진 인터페이스입니다.

2. 왜 추상 메서드가 하나여야 할까?

람다식은 메서드 이름을 직접 쓰지 않습니다. 그래서 인터페이스 안에 추상 메서드가 여러 개 있으면 람다식이 어떤 메서드를 구현해야 하는지 알 수 없습니다.

사용 가능한 예

interface Printer {
    void print(String message);
}

추상 메서드가 하나이므로 람다식으로 구현할 수 있습니다.

Printer printer = message -> System.out.println(message);

사용하기 어려운 예

interface Printer {
    void print(String message);
    void clear();
}

추상 메서드가 두 개입니다. 이 경우 람다식이 print()를 구현하는지, clear()를 구현하는지 알 수 없기 때문에 함수형 인터페이스로 사용할 수 없습니다.

3. @FunctionalInterface

함수형 인터페이스에는 @FunctionalInterface를 붙일 수 있습니다.

@FunctionalInterface
interface Printer {
    void print(String message);
}

이 어노테이션을 붙이면 컴파일러가 “이 인터페이스가 정말 추상 메서드를 하나만 가지고 있는지” 확인해줍니다.

만약 추상 메서드를 두 개 작성하면 오류를 알려줍니다.

@FunctionalInterface
interface Printer {
    void print(String message);

    // void clear(); // 오류 발생
}

핵심 포인트
람다식으로 사용할 인터페이스라면 @FunctionalInterface를 붙여두면 실수를 줄일 수 있습니다.

4. 직접 만든 함수형 인터페이스 예제

먼저 직접 함수형 인터페이스를 만들고 람다식으로 사용해보겠습니다.

public class Main {
    public static void main(String[] args) {
        Calculator calculator = (a, b) -> a + b;

        int result = calculator.calculate(10, 20);

        System.out.println(result);
    }
}

@FunctionalInterface
interface Calculator {
    int calculate(int a, int b);
}

실행 결과는 다음과 같습니다.

30

Calculator 인터페이스에는 calculate() 추상 메서드가 하나만 있습니다. 그래서 (a, b) -> a + b 람다식으로 구현할 수 있습니다.

5. 자바가 제공하는 함수형 인터페이스

자바는 자주 사용하는 함수형 인터페이스를 미리 제공합니다. 대표적으로 Predicate, Consumer, Supplier, Function이 있습니다.

인터페이스 역할 대표 메서드
Predicate<T> 조건 검사 test(T t)
Consumer<T> 값을 받아서 소비 accept(T t)
Supplier<T> 값을 공급 get()
Function<T, R> 값을 받아 다른 값으로 변환 apply(T t)

이 인터페이스들은 java.util.function 패키지에 있습니다.

import java.util.function.Predicate;
import java.util.function.Consumer;
import java.util.function.Supplier;
import java.util.function.Function;

6. Predicate란?

Predicate는 값을 하나 받아서 조건을 검사하고, 결과로 true 또는 false를 반환하는 함수형 인터페이스입니다.

Predicate<T>

boolean test(T value)

예를 들어 숫자가 10 이상인지 검사하는 조건을 Predicate로 만들 수 있습니다.

import java.util.function.Predicate;

public class Main {
    public static void main(String[] args) {
        Predicate<Integer> isAdult = age -> age >= 20;

        System.out.println(isAdult.test(25));
        System.out.println(isAdult.test(15));
    }
}

실행 결과는 다음과 같습니다.

true
false

age -> age >= 20은 나이가 20 이상인지 검사하는 람다식입니다. test()를 호출하면 조건 검사 결과를 반환합니다.

7. Predicate 사용 상황

Predicate는 조건을 표현할 때 사용하기 좋습니다.

  • 숫자가 특정 값 이상인지 검사할 때
  • 문자열 길이가 조건을 만족하는지 확인할 때
  • 상품 가격이 특정 금액 이하인지 확인할 때
  • 회원 등급이 특정 조건에 맞는지 확인할 때

조건 검사 결과가 true 또는 false라면 Predicate를 떠올리면 됩니다.

8. Predicate 문자열 예제

문자열 길이가 4 이상인지 검사하는 Predicate를 만들어보겠습니다.

import java.util.function.Predicate;

public class Main {
    public static void main(String[] args) {
        Predicate<String> isLongText = text -> text.length() >= 4;

        System.out.println(isLongText.test("Java"));
        System.out.println(isLongText.test("SQL"));
    }
}

실행 결과는 다음과 같습니다.

true
false

"Java"는 길이가 4이므로 true이고, "SQL"은 길이가 3이므로 false입니다.

9. Consumer란?

Consumer는 값을 하나 받아서 사용하고, 반환값은 없는 함수형 인터페이스입니다.

Consumer<T>

void accept(T value)

예를 들어 문자열을 받아 출력하는 작업을 Consumer로 만들 수 있습니다.

import java.util.function.Consumer;

public class Main {
    public static void main(String[] args) {
        Consumer<String> printer = message -> System.out.println(message);

        printer.accept("Java Consumer");
    }
}

실행 결과는 다음과 같습니다.

Java Consumer

accept()는 값을 받아서 처리하지만 반환값은 없습니다.

10. Consumer 사용 상황

Consumer는 값을 받아서 어떤 작업을 수행할 때 사용합니다.

  • 값을 출력할 때
  • 값을 로그처럼 기록할 때
  • 객체 정보를 화면에 보여줄 때
  • 컬렉션의 값을 하나씩 처리할 때

값을 받아 처리만 하고 결과를 반환하지 않는다면 Consumer를 생각하면 됩니다.

11. Consumer와 forEach()

컬렉션의 forEach()는 Consumer를 사용합니다.

import java.util.ArrayList;

public class Main {
    public static void main(String[] args) {
        ArrayList<String> subjects = new ArrayList<>();

        subjects.add("Java");
        subjects.add("Spring");
        subjects.add("SQL");

        subjects.forEach(subject -> System.out.println(subject));
    }
}

실행 결과는 다음과 같습니다.

Java
Spring
SQL

subject -> System.out.println(subject)는 값을 받아 출력만 합니다. 그래서 Consumer 역할을 하는 람다식입니다.

12. Supplier란?

Supplier는 매개변수 없이 값을 공급하는 함수형 인터페이스입니다.

Supplier<T>

T get()

예를 들어 항상 특정 문자열을 반환하는 Supplier를 만들 수 있습니다.

import java.util.function.Supplier;

public class Main {
    public static void main(String[] args) {
        Supplier<String> supplier = () -> "Java Supplier";

        String result = supplier.get();

        System.out.println(result);
    }
}

실행 결과는 다음과 같습니다.

Java Supplier

Supplier는 입력 없이 값을 만들어서 제공합니다.

13. Supplier 사용 상황

Supplier는 값을 만들어 제공할 때 사용합니다.

  • 기본 메시지를 반환할 때
  • 랜덤 값을 만들어 반환할 때
  • 현재 시간을 만들어 반환할 때
  • 새 객체를 만들어 반환할 때

입력은 없고 결과값을 제공한다면 Supplier를 떠올리면 됩니다.

14. Supplier 랜덤 숫자 예제

Supplier를 사용해서 1부터 10까지의 랜덤 숫자를 만들어보겠습니다.

import java.util.function.Supplier;
import java.util.Random;

public class Main {
    public static void main(String[] args) {
        Random random = new Random();

        Supplier<Integer> diceSupplier = () -> random.nextInt(6) + 1;

        System.out.println(diceSupplier.get());
        System.out.println(diceSupplier.get());
    }
}

실행 결과는 매번 달라질 수 있습니다.

4
2

diceSupplier.get()을 호출할 때마다 1부터 6 사이의 숫자를 반환합니다.

15. Function이란?

Function은 값을 하나 받아서 다른 값으로 변환하는 함수형 인터페이스입니다.

Function<T, R>

R apply(T value)

여기서 T는 입력 타입이고, R은 반환 타입입니다.

Function<String, Integer>

String  → 입력 타입
Integer → 반환 타입

16. Function 기본 예제

문자열을 받아 문자열 길이를 반환하는 Function을 만들어보겠습니다.

import java.util.function.Function;

public class Main {
    public static void main(String[] args) {
        Function<String, Integer> lengthFunction = text -> text.length();

        Integer length = lengthFunction.apply("Java");

        System.out.println(length);
    }
}

실행 결과는 다음과 같습니다.

4

입력값은 String이고 반환값은 Integer입니다. apply()를 호출하면 변환 결과가 반환됩니다.

17. Function 사용 상황

Function은 값을 다른 형태로 바꿀 때 사용합니다.

  • 문자열을 숫자로 변환할 때
  • 객체에서 특정 필드만 꺼낼 때
  • 숫자를 문자열로 바꿀 때
  • 문자열 길이를 구할 때
  • 입력값을 계산 결과로 바꿀 때

입력값과 반환값이 모두 있는 변환 작업이라면 Function을 떠올리면 됩니다.

18. Function 문자열 숫자 변환 예제

문자열 숫자를 정수로 바꾸는 Function을 만들어보겠습니다.

import java.util.function.Function;

public class Main {
    public static void main(String[] args) {
        Function<String, Integer> parser = text -> Integer.parseInt(text);

        Integer number = parser.apply("100");

        System.out.println(number + 20);
    }
}

실행 결과는 다음과 같습니다.

120

"100"이라는 문자열이 정수 100으로 변환되었기 때문에 숫자 연산이 가능합니다.

19. 네 가지 함수형 인터페이스 비교

Predicate, Consumer, Supplier, Function을 한눈에 비교하면 다음과 같습니다.

인터페이스 입력 출력 대표 메서드 핵심 느낌
Predicate<T> 있음 boolean test() 조건 검사
Consumer<T> 있음 없음 accept() 소비, 처리
Supplier<T> 없음 있음 get() 공급, 생성
Function<T, R> 있음 있음 apply() 변환

20. 외우기 쉬운 기준

처음에는 아래 기준으로 구분하면 쉽습니다.

Predicate  → 조건인가?
Consumer   → 받아서 처리만 하는가?
Supplier   → 입력 없이 만들어 주는가?
Function   → 받아서 다른 값으로 바꾸는가?

람다식을 작성할 때 입력과 출력이 무엇인지 먼저 보면 어떤 함수형 인터페이스를 사용할지 판단하기 쉬워집니다.

21. BiFunction 맛보기

입력값이 두 개이고 결과값이 하나인 경우에는 BiFunction을 사용할 수 있습니다.

import java.util.function.BiFunction;

public class Main {
    public static void main(String[] args) {
        BiFunction<Integer, Integer, Integer> add = (a, b) -> a + b;

        Integer result = add.apply(10, 20);

        System.out.println(result);
    }
}

실행 결과는 다음과 같습니다.

30

BiFunction<Integer, Integer, Integer>는 첫 번째 입력이 Integer, 두 번째 입력도 Integer, 반환값도 Integer라는 뜻입니다.

22. UnaryOperator와 BinaryOperator 맛보기

입력 타입과 반환 타입이 같을 때는 UnaryOperatorBinaryOperator를 사용할 수도 있습니다.

인터페이스 역할 예시
UnaryOperator<T> T를 받아 T로 반환 정수 하나를 받아 두 배로 만들기
BinaryOperator<T> T 두 개를 받아 T로 반환 정수 두 개를 더하기
import java.util.function.UnaryOperator;
import java.util.function.BinaryOperator;

public class Main {
    public static void main(String[] args) {
        UnaryOperator<Integer> doubleValue = number -> number * 2;
        BinaryOperator<Integer> add = (a, b) -> a + b;

        System.out.println(doubleValue.apply(10));
        System.out.println(add.apply(10, 20));
    }
}

실행 결과는 다음과 같습니다.

20
30

처음에는 Predicate, Consumer, Supplier, Function 네 가지를 먼저 익히고, 그다음 BiFunction, UnaryOperator, BinaryOperator를 보면 됩니다.

23. 함수형 인터페이스 전체 예제

이번에는 네 가지 대표 함수형 인터페이스를 함께 사용하는 예제를 보겠습니다.

import java.util.function.Predicate;
import java.util.function.Consumer;
import java.util.function.Supplier;
import java.util.function.Function;

public class Main {
    public static void main(String[] args) {
        Predicate<Integer> isPass = score -> score >= 60;
        Consumer<String> printer = message -> System.out.println(message);
        Supplier<String> defaultMessage = () -> "기본 메시지입니다.";
        Function<String, Integer> lengthFunction = text -> text.length();

        int score = 80;

        if (isPass.test(score)) {
            printer.accept("합격입니다.");
        } else {
            printer.accept("불합격입니다.");
        }

        printer.accept(defaultMessage.get());

        int length = lengthFunction.apply("Java");

        printer.accept("문자열 길이: " + length);
    }
}

실행 결과는 다음과 같습니다.

합격입니다.
기본 메시지입니다.
문자열 길이: 4

Predicate로 조건을 검사하고, Consumer로 메시지를 출력하고, Supplier로 기본 메시지를 공급하고, Function으로 문자열 길이를 구했습니다.

24. 함수형 인터페이스 사용 시 자주 하는 실수

함수형 인터페이스를 처음 사용할 때는 아래와 같은 실수를 자주 합니다.

실수 문제점 해결 방법
추상 메서드가 두 개인 인터페이스를 함수형 인터페이스로 생각 람다식으로 구현할 수 없음 추상 메서드가 하나인지 확인
Predicate에서 boolean이 아닌 값을 반환 test()는 boolean을 반환해야 함 조건식 형태로 작성
Consumer에서 값을 반환하려고 함 accept()는 반환값이 없음 반환이 필요하면 Function 사용
Supplier에 매개변수를 넣으려 함 get()은 매개변수가 없음 입력이 필요하면 Function 사용
Function<T, R>의 타입 순서 혼동 입력 타입과 반환 타입을 반대로 이해할 수 있음 T는 입력, R은 반환으로 기억

25. 직접 연습해보기

아래 코드를 직접 작성하고 실행해보세요.

import java.util.ArrayList;
import java.util.function.Predicate;
import java.util.function.Consumer;
import java.util.function.Supplier;
import java.util.function.Function;

public class Main {
    public static void main(String[] args) {
        ArrayList<String> menus = new ArrayList<>();

        menus.add("김밥");
        menus.add("라면");
        menus.add("돈가스");

        Predicate<String> isLongName = menu -> menu.length() >= 3;
        Consumer<String> printer = menu -> System.out.println("메뉴: " + menu);
        Supplier<String> defaultMenu = () -> "기본 메뉴";
        Function<String, Integer> lengthFunction = menu -> menu.length();

        for (String menu : menus) {
            if (isLongName.test(menu)) {
                printer.accept(menu);
                System.out.println("글자 수: " + lengthFunction.apply(menu));
            }
        }

        System.out.println(defaultMenu.get());
    }
}

실행 결과는 다음과 같습니다.

메뉴: 돈가스
글자 수: 3
기본 메뉴

isLongName은 조건을 검사하고, printer는 값을 출력하고, defaultMenu는 기본 값을 제공하고, lengthFunction은 문자열을 길이로 변환합니다.

26. 이번 글 정리

이번 글에서는 람다식과 함께 자주 사용하는 함수형 인터페이스에 대해 정리했습니다. 핵심 내용은 다음과 같습니다.

  • 함수형 인터페이스는 추상 메서드를 하나만 가진 인터페이스이다.
  • 람다식은 함수형 인터페이스의 추상 메서드를 구현할 때 사용한다.
  • @FunctionalInterface를 붙이면 함수형 인터페이스 규칙을 확인할 수 있다.
  • 자바는 java.util.function 패키지에 기본 함수형 인터페이스를 제공한다.
  • Predicate<T>는 값을 받아 조건을 검사하고 boolean을 반환한다.
  • Consumer<T>는 값을 받아 처리하고 반환값은 없다.
  • Supplier<T>는 입력 없이 값을 공급한다.
  • Function<T, R>은 값을 받아 다른 값으로 변환한다.
  • BiFunction<T, U, R>은 입력 두 개를 받아 결과 하나를 반환한다.
  • UnaryOperator<T>는 T를 받아 T를 반환한다.
  • BinaryOperator<T>는 T 두 개를 받아 T를 반환한다.
  • 입력과 출력이 무엇인지 먼저 보면 어떤 함수형 인터페이스를 쓸지 판단하기 쉽다.

한 줄 요약
함수형 인터페이스는 람다식의 대상이 되는 인터페이스이며, Predicate, Consumer, Supplier, Function은 조건 검사, 처리, 공급, 변환을 표현하는 대표 함수형 인터페이스입니다.

다음 글 예고
다음 글에서는 [Java 개념노트 57] 메서드 참조 이해하기라는 주제로 람다식을 더 간단하게 줄여 쓰는 클래스명::메서드명 형태를 정리해보겠습니다.

GWDEVELBlog Java 개념노트 시리즈