[JAVA] 함수형 인터페이스란?(Supplier<T>, Predicate<T>를 적용하게 된 이유를 중점으로)

2025. 11. 3. 23:13·Java & Kotlin

들어가며

우테코 프리코스 3주차 문제를 풀다보니 문제는 어찌저찌 풀겠지만 중복되는 코드가 너무 많았다. 나는 이 부분을 해결하기 위해 함수형 인터페이스를 활용하였다! 중복되는 코드를 줄이기 위해 검색하여 급하게 적용했다보니 개념에 대해 정확히 알고 있지 못한다. 그래서 이 기회에 한번 개념적으로 공부해보고 내가 어떤 부분에 적용하였는지 써보고자 한다! 가보자~!

지칠때마다 외쳐보자. 열정!열정!열정!

본론으로

그래서 함수형 인터페이스가 뭐야 🧐

일단 자바에서의 함수형 인터페이스...? 부터 처음에 좀 어색했다. 인터페이스는 알겠는데 함수형이 뭐지..? 함수를 다형성 있게 사용하겠다는 건가?

함수형 인터페이스(functional interface)는 추상메서드가 1개만 정의된 인터페이스를 통칭하여 일컫는다. 
// @FunctionalInterface 어노테이션을 인터페이스에 붙여주면, 
// 두 개 이상의 메소드 선언 시 컴파일 오류를 발생시켜 개발자의 실수를 줄일 수 있다.
@FunctionalInterface
public interface Animal {
		public void method();
}

 

즉 위와 같은 형태의 인터페이스를 뜻한다. 이런 함수형 인터페이스는 자바에서 람다 표현식을 이용해 함수형 인터페이스를 구현하여 사용하기 위해서이다. 아래 코드를 보자. IAdd 함수형 인터페이스의 add()을 람다표현식으로 구현하였다.

@FunctionalInterface
public interface IAdd {
    int add(int x, int y);
}

// 인터페이스 타입으로 람다 정의
IAdd a = (x, y) -> x + y;

// 사용
int result = a.add(2, 3); // 5

그렇다 이렇듯 함수형 인터페이스의 추상메서드를 람다 표현식으로 정의하여 사용할 수 있다.

 

그런데 곰곰히 생각해보면 함수의 리턴 값이 있을수도 없을수도 있고 매개변수 갯수가 1개 혹은 2개일 수도 있다. 이러한 형태의 함수를 일일히 정의해서 사용하기엔 너무 양이 많다.

만약 라이브러리에서 파라미터를 람다 함수로 받는 어떠한 메서드를 설계한다고 하면, 그 파라미터의 타입은 어떻게 할 것인가 🤔


-> 방금 위의 코드에서 (반환형이 int고.. int형 인자 두개를 받는 추상메서드를 갖고있는) 타입(함수형 인터페이스)이 IAdd라고 개발자 마음대로 만들었다.

그래서 자바 개발진들이 미리 함수형 인터페이스 이름을 정해 제공한다!!

🧑‍💻 자바에서 자료구조를 컬렉션 프레임워크로 미리 만들어 제공하듯이, 자주 사용할 것 같은 람다 함수 형태를 함수형 인터페이스 표준 API로 미리 만들어 제공해준다라고 이해해보자!

 

자바 표준 함수형 인터페이스 한눈에 보기
함수형 인터페이스 메서드 형태 API 활용 매개변수 반환값
Runnable void run() 매개변수·반환값 없이 “작업” 실행. 스레드 작업에 자주 사용 X X
Consumer<T> void accept(T t) 입력을 “소비”만 하고 반환 없음 (로그 출력, 저장 등) O X
Supplier<T> T get() 입력 없이 값을 “공급” (지연 계산/팩토리) X O
Function<T,R> R apply(T t) 입력을 변환(매핑)하여 결과 반환 O O
Predicate<T> boolean test(T t) 조건 판정(필터링), and/or/negate 조합 O O
Operator 계열
UnaryOperator<T>, BinaryOperator<T>, IntBinaryOperator …
R apply(…) / R applyAsX(…) 같은 타입을 연산(합계/누적/집계). 원시형 특화는 applyAsInt 등 O O
참고: BiFunction<T,U,R>, BiConsumer<T,U>, BiPredicate<T,U> 등 2개 인자를 받는 변형도 있어요.

나는 3주차에 Supplier<T>를 활용하여 로직을 구현했다. 😁

자세히 알아보기 전에 간단한 예시를 통해 이해해보자! Supplier(공급자)의 뜻과 걸맞게 아래 코드를 보면 정말 구현한 메서드의 내용을 반환해주는 것을 볼 수 있다.

public static void main(String[] args) {

    // T 객체를 리턴하는 함수 정의
    Supplier<Object> supplier = () -> new Object();
    System.out.println(supplier.get());

    // Boolean 값을 리턴하는 함수 정의
    BooleanSupplier booleanSup =  () -> true;
    System.out.println(booleanSup.getAsBoolean());

    // int 값을 리턴하는 함수 정의
    IntSupplier intSup = () -> {
        int num = (int) (Math.random() * 6) + 1;
        return num;
    };
    System.out.println("주사위 랜덤 숫자 : " + intSup.getAsInt());

    // double 값을 리턴하는 함수 정의
    DoubleSupplier doubleSup = () -> 1.0;
    System.out.println(doubleSup.getAsDouble());

    // long 값을 리턴하는 함수 정의
    LongSupplier longSup = () -> 1L;
    System.out.println(longSup.getAsLong());
}

위의 코드를 보고 어떤 상황이였길래 Supplier를 썻을까?


(아래부터는 3주차 미션의 자세한 내용들과 연관됩니다! 코드의 변수나 설명이 갑작스러울 수 있습니다. 자세한 내용이 궁금하다면 아래 PR 확인바랍니당)

 

https://github.com/woowacourse-precourse/java-lotto-8/pull/236

 

[로또] 노창준 미션 제출합니다. by geniusjun · Pull Request #236 · woowacourse-precourse/java-lotto-8

🧑‍💻 패키지 구조 src └─ main └─ java └─ lotto ├─ Application.java │ ├─ global │ ├─ constants │ │ ├─ ErrorMessage.java │ │ ├─ MessageTyp...

github.com

 

1) 문제 상황: 입력-검증에서 터지는 예외, 그리고 반복되는 try-catch

문제 요구 사항으로 추가된 부분으로, 사용자가 잘못 입력하면 예외를 던지고, 메시지를 보여주고, 다시 같은 입력을 받는 루프가 필요했다.
하지만 매 입력 지점마다 아래처럼 try { … } catch { … }를 반복해서 쓰면 중복 코드가 늘고, 컨트롤러가 지저분해지는 불편함이 있었다. 

// 사용자 입력에 있어 모두 try-catch가 필요하여 메서드가 뚱뚱해진다!
while (true) {
    try {
        System.out.println("구매 금액을 입력해 주세요.");
        return Cost.from(inputView.enterMessage());
    } catch (IllegalArgumentException e) {
        System.out.println(e.getMessage());
    }
}

내가 필요로 했던 것은 예외가 나면 메시지를 찍고 같은 동작을 재시도하는 패턴을 한 곳에 모아두고, 각 입력 지점마다 재사용하는 방법이었다.

요구사항 요약

  • 예외 발생 시 메시지 출력
  • 성공할 때까지 무한 재시도
  • 입력마다 반환 타입이 다름(Cost, Lotto, Bonus…) → 제네릭 필요
  • 호출부는 깔끔해야 함

2) 해결 아이디어: 동작을 값처럼 다루는 함수형 인터페이스 -> Supplier<T>가 딱이다!

왜 Supplier<T>가 딱 맞았을까?

  • “입력 받기 → 파싱/검증 → 도메인 생성”은 호출 시점마다 같은 동작을 실행하면 된다.
  • 각 입력 지점은 매번 다른 타입을 반환한다(Cost, Lotto, Bonus…), 그러니 제네릭 T가 필요하다.
  • 입력값은 InputView가 알아서 읽게 하면, Supplier는 인자 없이도 동작한다.
  • 실패하면 예외를 던져 루프 바깥에서 처리하게 만들 수 있다(= 공통 루프가 catch).

그래서 아래와 같이 설계했다.

public class InputLoop {
    private final OutputView out;

    public InputLoop(OutputView out) {
        this.out = out;
    }

    public <T> T ask(String prompt, Supplier<T> attempt) {
        while (true) {
            try {
                out.printlnMessage(prompt);
                return attempt.get();
            } catch (IllegalArgumentException e) {
                out.printlnMessage(e.getMessage());
            }
        }
    }
}

 

  • 프롬프트 출력과 재시도 루프는 InputLoop가 책임진다.
  • 실제 입력/파싱/검증/도메인 생성은 호출부가 전달한 Supplier<T>가 수행한다.
  • IllegalArgumentException을 잡아 메시지를 보여주고 같은 시도를 반복한다.
  • 제네릭 <T> 덕분에 어떤 타입이든 동일한 방식으로 재사용 가능.

아래는 Controller의 코드이다. 직접적인 view의 메서드 호출과 재시도 로직을 loop.ask()가 해주니 컨트롤러의 책임이 매우 줄어들고 지저분(?)한 try-catch문이 싹 사라지는 효과를 보았다! 여기서 Supplier<T>가 람다 표현식으로 도메인의 로직을 실행하여 반환하게끔 설계되어 로직이 매우 간단해지고 있다.

private Cost askCost() {
    return loop.ask(COST_REQUEST_MESSAGE.getMessage(),
            () -> Cost.from(inputView.enterMessage()));
}

private Lotto askWinningLotto() {
    return loop.ask(WINNING_REQUEST_MESSAGE.getMessage(),
            () -> Lotto.from(Parser.stringToNumbers(inputView.enterMessage())));
}

private Bonus askBonus(Lotto winning) {
    return loop.ask(BONUS_REQUEST_MESSAGE.getMessage(), () -> {
        Number number = Number.valueOf(Parser.stringToInt(inputView.enterMessage()));
        return Bonus.of(number, winning);
    });
}​

🧠 정리하자면!

“입력 → 검증 → 생성”을 하나의 시도로 보고, 실패 시 같은 시도를 다시 수행하는 공통 패턴을 InputLoop.ask(prompt, Supplier<T>)로 일반화했다. Supplier<T>는 “인자 없이 값을 공급”한다는 점과 제네릭 반환 덕분에, 컨트롤러에서 중복 try-catch 없이 Cost, Lotto, Bonus 등 다양한 타입 입력을 동일한 문장으로 처리하게 도와준다.


3주차 코드에 Predicate<T> 함수형 인터페이스도 적용해봤다! 자세히 알아보기 전의 아래 간단한 코드로 해당 인터페이스를 이해해보자!

// 간단한 학생 클래스 예제
class Student {
    String name;
    int score;

    public Student(String name, int score) {
        this.name = name;
        this.score = score;
    }
}


public static void main(String[] args) {

    List<Student> list = List.of(
            new Student("홍길동", 99),
            new Student("임꺽정", 76),
            new Student("고담덕", 36),
            new Student("김좌진", 77)
    );

    // int형 매개값을 받아 특정 점수 이상이면 true 아니면 false 를 반환하는 함수 정의
    IntPredicate scoring = (t) -> {
        return t >= 60;
    };

    for (Student student : list) {
        String name = student.name;
        int score = student.score;
		
        // 함수 실행하여 참 / 거짓 값 얻기
        boolean pass = scoring.test(score);
        
        if(pass) {
            System.out.println(name + "님 " + score + "점은 국어 합격입니다.");
        } else {
            System.out.println(name + "님 " + score + "점은 국어 불합격입니다.");
        }
    }
}

 

중간에 IntPredicate로 간단하게 조건을 구현하는 것이 보이는가?!

아래 사진은 Predicate<T>를 들어가 본 것이다. 보면 인자가 하나이고 반환형이 boolean인 추상메서드(test())가 떡하니 하나 있다. 이것을 우리는 자유롭게 구현하여 조건을 만들어보자!!

그렇다면 나는 Predicate<T>를 왜 썻을까?(이것도 3주차 미션 내용과 깊게 연관됩니다~)


1) 문제 상황: 등수별로 다른 "판정 규칙"을 깔끔하게 넣고 싶다.

로또 결과를 등급으로 매핑하려면 다음과 같은 규칙 집합이 필요했다.

  • 일치 개수가 3 미만이면 NONE
  • 3개면 FIFTH, 4개면 FOURTH
  • 5개인데 보너스 불일치면 THIRD, 보너스 일치면 SECOND
  • 6개면 FIRST

이 규칙을 if-else 나 switch로 늘어놓으면 가독성이 떨어지고(미션 요구사항으로는 쓰면 안된다), 새 규칙을 추가할 때마다 분기 문장을 수정해야 했다. 나는 규칙 자체를 '값'으로 보관해서, “어떤 등수인지”를 데이터 주도적으로 판정하고 하는 것이 낫다고 생각했다. 

요구사항 요약

  • 등수별 ‘판정 로직’을 등수 정의 옆에 붙여두고 싶다.
  • 선언적으로 읽히는 구조(규칙 목록 → 처음 성립하는 규칙 반환).
  • 규칙 추가/수정 시 분기문 수정 최소화.

 2) 해결 아이디어: 조건을 값으로 담으려면? -> 함수형 인터페이스 Predicate<T>가 딱이다!

"판정 규칙"을 메서드가 아니라 값(람다)으로 들고 다닐 수 있다는 점이 큰 매력포인트이다!

-> 규칙 목록을 컬렉션/열거형에 담고, 순서대로 평가하면 되지 않을까?

 아래와 같이 열거형 ENUM으로 조건을 담아보았다. 읽는 흐름이 등수 목록에 붙은 규칙을 순서대로 검사 → 처음 맞는 등수 반환이라 매우 자연스럽다고 생각한다.

public enum WinningType {
    NONE(0, "", result -> result.matchedCount() < 3),
    FIFTH(5_000, "3개 일치 (5,000원)", exactly(3)),
    FOURTH(50_000, "4개 일치 (50,000원)", exactly(4)),
    THIRD(1_500_000, "5개 일치 (1,500,000원)", r -> r.matchedCount() == 5 && !r.bonusMatched()),
    SECOND(30_000_000, "5개 일치, 보너스 볼 일치 (30,000,000원)", r -> r.matchedCount() == 5 && r.bonusMatched()),
    FIRST(2_000_000_000, "6개 일치 (2,000,000,000원)", exactly(6));

    private final int prizeAmount;
    private final String label;
    private final Predicate<MatchResult> rule;

    WinningType(int prizeAmount, String label, Predicate<MatchResult> rule) {
        this.prizeAmount = prizeAmount;
        this.label = label;
        this.rule = rule;
    }

    public static WinningType from(MatchResult result) {
        for (WinningType type : values()) {
            if (type.rule.test(result)) return type; // 규칙을 값처럼 사용
        }
        return NONE;
    }

    private static Predicate<MatchResult> exactly(int n) {
        return r -> r.matchedCount() == n;
    }
}

그래서 아래와 같이 4가지 이유를 말하고 싶다!

  • 선언적: 규칙이 코드 흐름(if/else) 속에 숨지 않고, 데이터로 드러난다.
  • 응집/캡슐화: 등수와 그 등수의 규칙이 물리적으로 붙어 있다.
  • 확장 용이: 새 규칙 추가/변경이 분기문 편집 없이 상수 한 줄 추가로 끝난다!
  • 조합 가능: and, or, negate로 복잡한 규칙들도 조립 가능.

🧠 정리하자면!

당첨 판정은 “조건의 집합”이므로, Predicate<MatchResult>로 규칙을 값처럼 보관하고 열거형 상수에 바로 붙여 선언적으로 관리했다. 결과적으로 분기문이 사라지고, 읽기/변경/확장이 모두 쉬워졌다.

 

마무리하며

이번 함수형 인터페이스를 도입하는 일련의 과정에서 얻은 가장 큰 배움은 "행위를 값으로 다룬다"는 감각을 제대로 체득한 것이다.
그동안은 객체의 속성이나 상태만 데이터를 담는다고 생각했지만, Supplier<T>와 Predicate<T>를 사용하면서 메서드 자체도 얼마든지 데이터처럼 넘길 수 있다는 사실을 몸으로 배웠다. 여태껏 함수형 인터페이스는 외부의 리소스를 주입할때 구현체가 아닌 인터페이스를 의존게끔 하려고 만드는 객체(DIP, 의존 역전 원칙) 인줄 알았는데 행위를 명확히 이름 붙이고, 재사용 가능한 형태로 캡슐화하는 도구라고 표현하고 싶다.

우테코 프리코스 진행하면서 느끼는 것은 자바는 정말 대단한 언어이다. 이런게 필요하지 않을까? 싶으면 다 있다... 항상 생각을 깊게 하게끔 만들어주는 우테코에게 무한감사하며 남은 프리코스도 파이팅해보자!!🔥

 

'Java & Kotlin' 카테고리의 다른 글

[JAVA] String은 바쁘다! 속도가 중요할 시 StringBuilder를 이용하자  (0) 2026.01.30
[Kotlin] 자바 개발자를 위한 코틀린 정리(차이점을 위주로)  (0) 2025.11.06
[JAVA] Collection의 복사 방법에 대해 알아보자!(방어적 복사, 얕은 복사, 깊은 복사) feat. 내가 List.copyOf와 Arrays.asList를 쓴 이유  (0) 2025.10.18
[JAVA] split() 메서드 정복하기 (나는 왜 split(delimiter, -1)을 썻을까?)  (1) 2025.10.17
[JAVA] 정규 표현식, Pattern 클래스로 쉽게 이용해보자! (Pattern.quote()를 중점적으로)  (0) 2025.10.17
'Java & Kotlin' 카테고리의 다른 글
  • [JAVA] String은 바쁘다! 속도가 중요할 시 StringBuilder를 이용하자
  • [Kotlin] 자바 개발자를 위한 코틀린 정리(차이점을 위주로)
  • [JAVA] Collection의 복사 방법에 대해 알아보자!(방어적 복사, 얕은 복사, 깊은 복사) feat. 내가 List.copyOf와 Arrays.asList를 쓴 이유
  • [JAVA] split() 메서드 정복하기 (나는 왜 split(delimiter, -1)을 썻을까?)
노을을
노을을
진인사대천명
  • 노을을
    노을의 개발일기장
    노을을
  • 전체
    오늘
    어제
    • All (61) N
      • Java & Kotlin (16)
      • Spring (3) N
      • Problem Solve (13) N
      • Computer Science (0)
      • Infra (1)
      • DB (2)
      • Various Dev (23)
        • 우아한테크코스 (9)
        • Git&Github (2)
        • Unity (12)
      • Book (1)
      • Writing (2)
  • 블로그 메뉴

    • 홈
    • 태그
    • 방명록
  • 링크

  • 공지사항

  • 인기 글

  • 태그

    java
    우아한테크코스
    백준
    유니티
    스프링
    개발자
    코딩테스트
    개발
    자바
    알고리즘
    우테코
    오픈미션
    합격
    8기
    티스토리챌린지
    코테
    github
    코딩
    게임개발
    프리코스
  • 최근 댓글

  • 최근 글

  • hELLO· Designed By정상우.v4.10.2
노을을
[JAVA] 함수형 인터페이스란?(Supplier<T>, Predicate<T>를 적용하게 된 이유를 중점으로)
상단으로

티스토리툴바