ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • 함수형 인터페이스 정리(Supplier,Consumer,Function,Predicate)
    카테고리 없음 2025. 12. 23. 11:53
    728x90
    반응형

    Java 함수형 인터페이스 4종 정리

    Supplier<T>, Consumer<T>, Function<T,R>, Predicate<T>

     

    Java의 java.util.function 패키지는 람다/메서드 레퍼런스로 “함수”를 값처럼 전달할 수 있게 해주는 표준 함수형 인터페이스들을 제공한다. 그 중 가장 많이 쓰는 4개가 아래다.

    • Supplier: 입력 없음 → 값 제공
    • Consumer: 값 받기 → 소비(부작용)만 함
    • Function: 값 변환 → 다른 값으로 매핑
    • Predicate: 조건 검사 → true/false

     

    1) Supplier<T> — “입력 없이 T를 만든다/제공한다”

    의미

    • 매개변수(입력)가 없고
    • 호출 시점에 T 타입 값을 반환한다.
    • “지연 생성(lazy)”, “기본값 생성”, “팩토리”에 많이 사용된다.

    시그니처

    @FunctionalInterface public interface Supplier<T> { T get(); }
     

    대표 사용처

    1. Optional.orElseGet: 값이 없을 때만 기본값 생성
    2. 지연 초기화: 필요할 때만 객체 생성
    3. 팩토리/생성 로직 전달: User::new

    예시 1: 기본값을 ‘필요할 때만’ 만들기

    Optional<String> opt = Optional.empty(); 
    String v1 = opt.orElse(expensiveDefault()); // (주의) expensiveDefault()가 미리 실행될 수 있음
    String v2 = opt.orElseGet(() -> expensiveDefault()); // 비어있을 때만 실행
     

    예시 2: 팩토리처럼 쓰기

    public static <T> T create(Supplier<T> factory) { return factory.get(); } 
    List<String> list = create(ArrayList::new); String now = create(() -> java.time.LocalDateTime.now().toString());

     

    1) Supplier가 함수형 인터페이스가 없을 때 생기는 대표 문제: “기본값이 항상 계산됨”

    • 1) 문제 상황: 호출부에서 이미 DB를 때려버리는 코드
      Product cached = cache.get(productId); // 여기서 findProduct()가 getOrDefault 호출 전에 이미 실행될 수 있음 
      Product product = getOrDefault(cached, findProduct(productId)); 
      public Product getOrDefault(Product cached, Product defaultValue) { 
      	return cached != null ? cached : defaultValue; 
      }
      
      public Product findProduct(String productId) { 
      	return productRepository.findById(productId); 
      }
       
      왜 문제냐면
      • findProduct(productId)가 먼저 실행되어 결과(Product 객체)가 만들어진 다음에
      • getOrDefault(...)로 들어가거든
        → 캐시에 있어도 DB는 이미 호출된 상태

      2) ✅ 해결: DB 호출 “로직” 자체를 Supplier로 감싸서 넘김
      Product cached = cache.get(productId); // findProduct를 "호출"하지 않고, "호출 방법"을 넘김 
      Product product = getOrDefault(cached, () -> findProduct(productId)); 
      
      public Product getOrDefault(Product cached, Supplier<Product> defaultSupplier) { 
      
      	return cached != null ? cached : defaultSupplier.get(); // 여기서만 DB 호출 가능
          
      } 
      public Product findProduct(String productId) { 
      
      	return productRepository.findById(productId); 
      
      }
       
      ✅ 여기서는 캐시에 있으면 defaultSupplier.get() 자체가 호출되지 않으니까
      findProduct(productId)도 실행되지 않아.
    • 개선 후: Supplier로 “필요할 때만”
    • ❌ 개선 전: default 값을 “값”으로 넘김 (이미 실행됨)

     

    2) Consumer<T> — “T를 받아서 소비(처리)만 한다 (반환 없음)”

    의미

    • 입력은 T 하나
    • 출력(리턴)은 없다(void)
    • 보통 부작용(side effect) 을 수행한다: 출력, 저장, 로깅, 전송 등

    시그니처

    @FunctionalInterface public interface Consumer<T> { 
    void accept(T t); default Consumer<T> andThen(Consumer<? super T> after) { ... } 
    }

    대표 사용처

    1. forEach: 컬렉션 요소 하나씩 처리
    2. 로깅/출력/전송: 결과를 어디론가 “보내는” 작업
    3. 체이닝(andThen): 처리 파이프라인 구성

    예시 1: forEach에서 소비

    List<String> names = List.of("kim", "lee", "park"); 
    names.forEach(n -> System.out.println(n));
     

    예시 2: andThen으로 처리 연결

    Consumer<String> print = s -> System.out.println("print: " + s);
    Consumer<String> save = s -> System.out.println("save: " + s); // 실제론 DB 저장 등 Consumer<String> pipeline = print.andThen(save); 
    pipeline.accept("hello");

     

    1) Consumer가 없을 때 생기는 문제: “처리 방식이 바뀔 때마다 메서드가 늘거나, 재사용이 안 됨”

    ✅ 실무 상황 예시 (쿠팡/카카오급에서 흔함)

    어떤 공통 흐름이 있어:

    “주문 생성”이라는 공통 흐름은 동일한데,
    팀/서비스마다 추가로 하고 싶은 작업이 다르다.

    예:

    • A 서비스: 주문 생성 후 Kafka 발행
    • B 서비스: 주문 생성 후 감사 로그 저장
    • C 서비스: 주문 생성 후 슬랙 알림
    • D 서비스: 주문 생성 후 메트릭 기록

    ❌ 개선 전 1: 기능별 메서드가 계속 늘어남(중복 폭발)

    public Order createOrderAndPublish(OrderRequest req) { ... } 
    public Order createOrderAndAudit(OrderRequest req) { ... } 
    public Order createOrderAndNotify(OrderRequest req) { ... } 
    public Order createOrderAndMetric(OrderRequest req) { ... }
     
    • 주문 생성 로직은 거의 같은데
    • 뒤에 “추가 행동”만 달라서 메서드가 계속 늘어남
    • 조합(발행+감사+메트릭) 요구 나오면 또 폭발

    ❌ 개선 전 2: “추가 행동”을 하드코딩 if로 박아버림

    public Order createOrder(OrderRequest req, boolean publish, boolean audit, boolean metric) { 
        Order order = doCreate(req); 
        if (publish) publishKafka(order); 
        if (audit) saveAudit(order); 
        if (metric) recordMetric(order);
        return order;
    }
    

    문제:

    • boolean 플래그가 늘어날수록 지옥
    • 순서/예외 처리/조합이 복잡해짐
    • 테스트도 어려워짐

    ✅ 개선 후: Consumer로 “추가 행동”을 콜백으로 주입

    핵심 아이디어

    “주문 생성”이라는 공통 흐름을 만들고,
    그 뒤에 붙는 “추가 행동”을 Consumer<Order>로 전달한다.

    개선 후 코드

    public Order createOrder(OrderRequest req, Consumer<Order> afterCreate) { 
        Order order = doCreate(req); // 핵심 로직은 공통 
        afterCreate.accept(order); // 추가 행동은 호출자가 결정 
        return order; 
    }

    호출부(필요한 곳에서만):

    Order order = createOrder(req, o -> publishKafka(o));
     

    또는 메서드 레퍼런스로:

    Order order = createOrder(req, this::publishKafka);
     

    ✅Consumer를 쓰면 뭐가 좋아지나? (효능)

     1) 중복 제거

    주문 생성 로직이 한 곳에 모이고, 추가 행동만 외부에서 꽂음.

     2) 기능 조합이 쉬움 (andThen)

    Consumer<Order> publish = this::publishKafka;
    Consumer<Order> audit = this::saveAudit; 
    Consumer<Order> metric = this::recordMetric; 
    Consumer<Order> pipeline = publish.andThen(audit).andThen(metric); 
    Order order = createOrder(req, pipeline);
     

    이게 진짜 실무에서 강력해.
    “추가로 하고 싶은 후처리”들이 계속 늘어날 때, 조합이 폭발하지 않음.

    3) 테스트 쉬움

    테스트에서는 afterCreate를 빈 Consumer로 넣어버리면 됨:

    Order order = createOrder(req, o -> {});
     

    또는 mock/spy로 accept 호출 여부 검증도 쉬움.

    3) Function<T, R> — “T를 받아 R로 변환한다 (매핑/변환)”

    의미

    • 입력 T를 받아서
    • 출력 R로 바꿔서 반환한다.
    • “변환”, “매핑”, “추출(key 뽑기)”에 핵심적으로 쓰인다.

    시그니처

    @FunctionalInterface public interface Function<T, R> { R apply(T t); 
    default <V> Function<T, V> andThen(Function<? super R, ? extends V> after) { ... } default <V> Function<V, R> compose(Function<? super V, ? extends T> before) { ... } }
     

    대표 사용처

    1. Stream.map: 요소 변환
    2. Collectors.toMap(keyMapper, valueMapper): key/value 뽑기
    3. 함수 합성(compose/andThen): 변환 단계 연결

    예시 1: map으로 변환

    List<String> words = List.of("a", "bb", "ccc"); 
    List<Integer> lengths = words.stream() .map(s -> s.length()) // Function<String, Integer> .toList();

    예시 2: toMap에서 key/value 매퍼

    class User { 
        long id; 
        String name; 
        User(long id, String name) { 
        	this.id = id; this.name = name; 
        } 
        long getId() {
        	return id; 
        } 
        String getName() {
            return name; 
        } 
    } 
    List<User> users = List.of(new User(1, "kim"), new User(2, "lee")); 
    Map<Long, String> idToName = users.stream() .collect(java.util.stream.Collectors.toMap( User::getId, // Function<User, Long> User::getName // Function<User, String> ));
     

    예시 3: compose / andThen 차이

    Function<String, Integer> len = String::length; 
    Function<Integer, Integer> square = x -> x * x; 
    Function<String, Integer> lenThenSquare = len.andThen(square); // square(len(s)) System.out.println(lenThenSquare.apply("abcd")); // 16 Function<String, Integer> squareAfterLen = square.compose(len); // 동일: square(len(s)) System.out.println(squareAfterLen.apply("abcd")); // 16
     
    • andThen: 먼저 this, 나중 after
    • compose: 먼저 before, 나중 this

     

    1) Function이 없을 때 생기는 문제: “변환 종류마다 메서드가 늘고, 공통 흐름 재사용이 어려움”

    실무 상황 (쿠팡/카카오급)

    예를 들어 “주문 목록 조회”를 했는데, API 응답은 OrderEntity → OrderResponseDto로 바꿔서 내려야 해.

    또는 어떤 화면/서비스는

    • “간단 DTO”
    • 어떤 곳은 “상세 DTO”
    • 어떤 곳은 “정산용 DTO”
      처럼 변환 정책이 다름

    ❌ 개선 전 1: 변환 종류마다 메서드 폭발

    List<OrderSimpleDto> toSimpleDtos(List<Order> orders) { ... } 
    List<OrderDetailDto> toDetailDtos(List<Order> orders) { ... } 
    List<OrderSettlementDto> toSettlementDtos(List<Order> orders) { ... }
     

    문제:

    • “리스트를 돌면서 변환한다”는 흐름은 똑같은데
    • DTO 종류가 늘 때마다 코드가 늘어남
    • 변환 로직 재사용/조합이 어려움

    ❌ 개선 전 2: 공통 로직이 있는데도 매번 복붙

    List<OrderSimpleDto> result = new ArrayList<>(); 
    for (Order o : orders) { 
        result.add(new OrderSimpleDto(o.getId(), o.getPrice())); 
    }
    List<OrderDetailDto> result2 = new ArrayList<>();
    for (Order o : orders) {
        result2.add(new OrderDetailDto(o.getId(), o.getItems(), o.getAddress()));
    }
     
    • 반복문 구조가 똑같이 복사됨
    • 수정(널처리/예외처리/성능최적화)할 때 여러 군데를 다 바꿔야 함

    2) ✅ 개선 후: “변환 정책”만 Function으로 넘기기

    핵심 아이디어는 이거야:

    “반복/공통 흐름”은 공통화하고
    “변환(정책)”만 바꿔 끼운다

    ✅ 공통 map 유틸을 만든다면

    public static <T, R> List<R> map(List<T> list, Function<T, R> mapper) { 
    	List<R> result = new ArrayList<>(list.size()); 
        for (T t : list) {
       	 result.add(mapper.apply(t)); 
        } 
    return result; }
     

    호출부에서 변환만 주입:

    List<OrderSimpleDto> simple = map(orders, o -> new OrderSimpleDto(o.getId(), o.getPrice()));
    List<OrderDetailDto> detail = map(orders, o -> new OrderDetailDto(o.getId(), o.getItems(), o.getAddress()));
     

    ✅ 반복문/공통 로직은 한 번만 작성
    ✅ 변환 로직만 바꿔 끼움


    4) Predicate<T> — “T를 받아 조건을 검사한다 (boolean 리턴)”

    의미

    • 입력 T를 받아서
    • true/false를 리턴한다.
    • “필터링”, “조건 검사”, “검증 로직”에 사용된다.

    시그니처

     
    @FunctionalInterface public interface Predicate<T> { boolean test(T t); default Predicate<T> and(Predicate<? super T> other) { ... } default Predicate<T> or(Predicate<? super T> other) { ... } default Predicate<T> negate() { ... } }

    대표 사용처

    1. Stream.filter: 조건으로 걸러내기
    2. removeIf: 컬렉션에서 조건에 맞는 것 제거
    3. 조합(and/or/negate): 조건식 재사용/합성

    예시 1: filter로 필터링

     
    List<Integer> nums = List.of(1, 2, 3, 4, 5, 6); List<Integer> evens = nums.stream() .filter(n -> n % 2 == 0) // Predicate<Integer> .toList();

    예시 2: 조건 조합(and/or/negate)

     
    Predicate<String> isLong = s -> s.length() >= 5; Predicate<String> startsWithA = s -> s.startsWith("a"); Predicate<String> rule = isLong.and(startsWithA); System.out.println(rule.test("apple")); // true (길이>=5, a로 시작) System.out.println(rule.test("angle")); // true System.out.println(rule.test("abc")); // false Predicate<String> notLong = isLong.negate(); System.out.println(notLong.test("abc")); // true

    예시 3: removeIf

     
    List<String> list = new ArrayList<>(List.of("a", "bb", "ccc", "dddd")); list.removeIf(s -> s.length() <= 2); System.out.println(list); // [ccc, dddd]

     

     

    1) Predicate가 없을 때 생기는 문제: “조건이 바뀔 때마다 코드가 폭발한다”

    ✅ 실무 상황 (쿠팡/카카오급에서 흔함)

    “주문 목록 검색 API” 같은 게 있다고 해보자.

    검색 조건이 계속 늘어나:

    • 결제완료만
    • 취소 제외
    • 특정 카테고리만
    • VIP 유저만
    • 금액 10만원 이상만
    • “위 조건들을 조합”

    이때 Predicate가 없으면 보통 아래 둘 중 하나로 망가져.


    ❌ 개선 전 1: 조건별 메서드가 계속 늘어남

     
    List<Order> findPaidOrders(...) List<Order> findNotCanceledOrders(...)
    List<Order> findVipOrders(...) List<Order> findPaidAndVipOrders(...) // 조합이 생기면 폭발 List<Order> findPaidAndNotCanceledAndVipOrders(...) // 더 폭발
    • 조건이 n개면 조합은 거의 2^n 수준으로 늘 수 있음
    • “필터만 다른데” 메서드/코드가 계속 증가

    ❌ 개선 전 2: if/else 플래그 지옥

     
    List<Order> search(boolean paidOnly, boolean excludeCanceled, boolean vipOnly, int minPrice) { List<Order> result = new ArrayList<>(); for (Order o : orders) { if (paidOnly && !o.isPaid()) continue; if (excludeCanceled && o.isCanceled()) continue; if (vipOnly && !o.isVipUser()) continue; if (o.getPrice() < minPrice) continue; result.add(o); } return result; }

    문제점:

    • 조건이 늘수록 가독성/유지보수 난이도 급상승
    • “조건 조합” 로직을 테스트하기 어려워짐
    • 조건을 다른 곳에서 재사용하기 힘듦

    2) ✅ 개선 후: 조건을 Predicate로 “분리”하고 “조합”한다

    핵심 아이디어는 이거야:

    공통 흐름(리스트 순회)은 그대로 두고,
    “조건”만 Predicate<Order>로 바꿔끼운다.

    ✅ 공통 필터 함수(직접 만든다고 가정)

     
    public static <T> List<T> filter(List<T> list, Predicate<T> cond) { List<T> result = new ArrayList<>(); for (T t : list) { if (cond.test(t)) result.add(t); } return result; }

    조건만 전달:

     
    Predicate<Order> paidOnly = Order::isPaid; Predicate<Order> notCanceled = o -> !o.isCanceled(); Predicate<Order> minPrice = o -> o.getPrice() >= 100000; List<Order> result = filter(orders, paidOnly.and(notCanceled).and(minPrice));

    ✅ 여기서 포인트는:

    • 조건들이 “값”이 됨 → 변수에 담아서 재사용 가능
    • .and() / .or() / .negate()로 조합 가능

     

    5) “쿠팡/카카오급” 검색 필터 예시 (개선 전/후 비교)

    ❌ 개선 전: if 지옥

     
    public List<Order> search(SearchCond cond) { return orders.stream() .filter(o -> { if (cond.paidOnly && !o.isPaid()) return false; if (cond.excludeCanceled && o.isCanceled()) return false; if (cond.vipOnly && !o.isVipUser()) return false; if (cond.minPrice != null && o.getPrice() < cond.minPrice) return false; return true; }) .toList(); }

    ✅ 개선 후: “조건 빌더” + Predicate 조합

     
    public Predicate<Order> buildFilter(SearchCond cond) { Predicate<Order> p = o -> true; // 초기값(항상 true) if (cond.paidOnly) p = p.and(Order::isPaid); if (cond.excludeCanceled) p = p.and(o -> !o.isCanceled()); if (cond.vipOnly) p = p.and(Order::isVipUser); if (cond.minPrice != null) p = p.and(o -> o.getPrice() >= cond.minPrice); return p; } public List<Order> search(SearchCond cond) { Predicate<Order> filter = buildFilter(cond); return orders.stream().filter(filter).toList(); }

    🚀 효능

    • 검색 조건이 늘어나도 “정책 빌더”만 늘어남
    • 조건들을 재사용 가능 (다른 API에서도 사용)
    • 조합이 명확해짐 (읽기 쉬움)
    • 개별 Predicate 테스트 가능
    728x90
    반응형
Designed by Tistory.