-
함수형 인터페이스 정리(Supplier,Consumer,Function,Predicate)카테고리 없음 2025. 12. 23. 11:53728x90반응형
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(); }대표 사용처
- Optional.orElseGet: 값이 없을 때만 기본값 생성
- 지연 초기화: 필요할 때만 객체 생성
- 팩토리/생성 로직 전달: 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로 감싸서 넘김✅ 여기서는 캐시에 있으면 defaultSupplier.get() 자체가 호출되지 않으니까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); }
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) { ... } }대표 사용처
- forEach: 컬렉션 요소 하나씩 처리
- 로깅/출력/전송: 결과를 어디론가 “보내는” 작업
- 체이닝(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) { ... } }대표 사용처
- Stream.map: 요소 변환
- Collectors.toMap(keyMapper, valueMapper): key/value 뽑기
- 함수 합성(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() { ... } }대표 사용처
- Stream.filter: 조건으로 걸러내기
- removeIf: 컬렉션에서 조건에 맞는 것 제거
- 조합(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반응형