카테고리 없음

함수형 인터페이스 정리(Supplier,Consumer,Function,Predicate)

디찌s 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
반응형