1. 함수 합성의 개념
약간의 이론 (지루하지 않게)
수학에서 함수의 합성은 한 함수의 결과가 다른 함수의 입력이 되는 것을 의미합니다. f와 g라는 함수가 있다면, 합성 g(f(x))는 먼저 f를 x에 적용하고, 그 결과를 g에 넣는다는 뜻입니다.
프로그래밍에서도 마찬가지입니다. 복잡한 변환을 단순한 것들로 조립해, 모든 것을 하나의 거대한 함수로 작성하지 않도록 합니다. 이렇게 하면 코드가 유연하고, 재사용 가능하며, 읽기 쉬워집니다.
케이크 생산 라인을 떠올려 보세요: 먼저 반죽(f), 그다음 크림(g), 그다음 토핑(h). 전체 과정은 h(g(f(재료)))입니다.
왜 합성이 중요할까요?
- 합성은 프로그램을 작은 “블록” 함수들로 조립할 수 있게 해 줍니다.
- 재사용이 쉬워집니다: 하나의 “블록”을 중복 없이 여러 곳에서 활용할 수 있습니다.
- 유연성: 한 단계를 바꾸고 싶다면 해당 함수만 교체하면 되고, 나머지는 건드릴 필요가 없습니다.
- 가독성과 테스트 용이성: 작은 함수는 읽기, 검증, 유지보수가 쉽습니다.
2. 인터페이스 Function의 compose와 andThen 메서드
Function 인터페이스: 복습
@FunctionalInterface
public interface Function<T, R> {
R apply(T t);
// 합성을 위한 메서드:
default <V> Function<V, R> compose(Function<? super V, ? extends T> before)
default <V> Function<T, V> andThen(Function<? super R, ? extends V> after)
}
- compose: compose에 전달한 함수가 먼저 실행되고, 그다음 현재 함수가 실행됩니다.
- andThen: 현재 함수가 먼저 실행되고, 그다음 andThen에 전달된 함수가 실행됩니다.
시각적 개요
// 두 함수가 있다고 하자:
Function<String, Integer> parse = s -> Integer.parseInt(s);
Function<Integer, Integer> square = x -> x * x;
// compose: square.compose(parse) == x -> square.apply(parse.apply(x))
"5" --parse--> 5 --square--> 25
// andThen: parse.andThen(square) == x -> square.apply(parse.apply(x))
"5" --parse--> 5 --square--> 25
// 하지만 타입이 다르면 순서가 중요합니다!
예시: 문자열을 숫자로 변환한 뒤 제곱
import java.util.function.Function;
public class ComposeAndThenDemo {
public static void main(String[] args) {
// 함수: 문자열을 숫자로 변환
Function<String, Integer> parse = s -> Integer.parseInt(s);
// 함수: 숫자를 제곱
Function<Integer, Integer> square = x -> x * x;
// 결합: 먼저 파싱하고, 그다음 제곱
Function<String, Integer> parseThenSquare = parse.andThen(square);
System.out.println(parseThenSquare.apply("7")); // 49
// 순서를 바꾸면?
// square.compose(parse) — 이 경우(이 함수들) 결과는 동일
Function<String, Integer> squareOfParsed = square.compose(parse);
System.out.println(squareOfParsed.apply("8")); // 64
}
}
언제 순서가 중요할까요?
함수들의 타입이 맞지 않으면 순서가 매우 중요해집니다. 예를 들어:
Function<String, String> addPrefix = s -> "User: " + s;
Function<String, Integer> length = s -> s.length();
Function<String, Integer> composed = addPrefix.andThen(length);
System.out.println(composed.apply("Alice")); // "User: Alice" -> 11
// 그럼 이렇게 하면:
/// length.andThen(addPrefix) — 컴파일 오류!
// length는 Integer를 반환하지만, addPrefix는 String을 받습니다.
표: compose와 andThen의 차이
|
|
|
|
|---|---|---|---|
|
|
|
|
3. Predicate와 다른 인터페이스의 합성
Predicate<T>: and, or, negate
함수형 인터페이스 Predicate<T>는 boolean을 반환하는 함수입니다. Predicate를 결합하기 위한 전용 메서드가 있습니다:
- and: 논리 AND (&&)
- or: 논리 OR (||)
- negate: 논리 NOT (!)
예시: 복잡한 필터링 조건
사용자 클래스를 하나 가지고 있다고 가정해 봅시다:
public class User {
String name;
int age;
public User(String name, int age) {
this.name = name;
this.age = age;
}
}
이제 다양한 Predicate를 작성해 보겠습니다:
import java.util.function.Predicate;
Predicate<User> isAdult = user -> user.age >= 18;
Predicate<User> nameStartsWithA = user -> user.name.startsWith("A");
// 결합: 성인이고 이름이 "A"로 시작
Predicate<User> adultAndA = isAdult.and(nameStartsWithA);
// 성인이거나 이름이 "A"로 시작
Predicate<User> adultOrA = isAdult.or(nameStartsWithA);
// 미성년자
Predicate<User> notAdult = isAdult.negate();
이제 이러한 Predicate를 Stream API의 필터링에 사용할 수 있습니다:
import java.util.List;
import java.util.stream.Collectors;
List<User> users = List.of(
new User("Alice", 20),
new User("Bob", 17),
new User("Anna", 15),
new User("Mike", 22)
);
List<User> filtered = users.stream()
.filter(adultAndA)
.collect(Collectors.toList());
// filtered에는 Alice만 포함됩니다(성인이며 이름이 "A"로 시작)
Consumer, Function, Supplier의 합성
- Consumer<T>: andThen 메서드 — 두 연산을 순차적으로 실행합니다.
- Function<T, R>: 합성은 위에서 살펴보았습니다.
- Supplier<T>: 직접 결합 메서드는 없지만 다른 함수 안에서 사용할 수 있습니다.
예시: Consumer<T>.andThen
import java.util.function.Consumer;
Consumer<String> print = s -> System.out.println("수신됨: " + s);
Consumer<String> printUpper = s -> System.out.println("대문자: " + s.toUpperCase());
Consumer<String> combined = print.andThen(printUpper);
combined.accept("hello");
// 출력:
// 수신됨: hello
// 대문자: HELLO
4. 실습: 변환과 필터링 체이닝
과제 1: 변환 Function 체인 구성
우리 애플리케이션에서 사용자를 "이름,나이" 형태의 문자열로 저장한다고 가정해 봅시다. 예: "Alice,20". 해야 할 일:
- 문자열을 User 객체로 변환
- 나이를 얻기
- 해당 사용자가 성인인지 확인
import java.util.function.Function;
import java.util.function.Predicate;
Function<String, User> stringToUser = str -> {
String[] parts = str.split(",");
return new User(parts[0], Integer.parseInt(parts[1]));
};
Function<User, Integer> getAge = user -> user.age;
Predicate<Integer> isAdultAge = age -> age >= 18;
// 결합: 문자열 -> User -> 나이 -> Predicate
Function<String, Integer> stringToAge = stringToUser.andThen(getAge);
String input = "Bob,19";
int age = stringToAge.apply(input);
System.out.println("나이: " + age); // 19
System.out.println("성인인가? " + isAdultAge.test(age)); // true
과제 2: 여러 Predicate를 조합해 필터링하기
18세 초과이며 이름이 "A" 또는 "M"으로 시작하는 사용자를 골라야 한다고 하겠습니다.
Predicate<User> isAdult = user -> user.age > 18;
Predicate<User> nameStartsWithA = user -> user.name.startsWith("A");
Predicate<User> nameStartsWithM = user -> user.name.startsWith("M");
Predicate<User> filter = isAdult.and(nameStartsWithA.or(nameStartsWithM));
List<User> filtered = users.stream()
.filter(filter)
.collect(Collectors.toList());
과제 3: 다단계 Function 변환
해야 할 일: 문자열을 받고, 앞뒤 공백을 자르고, 대문자로 바꾼 다음, 접두사 "USER: "를 추가합니다.
Function<String, String> trim = String::trim;
Function<String, String> toUpper = String::toUpperCase;
Function<String, String> addPrefix = s -> "USER: " + s;
// 체인 구성
Function<String, String> pipeline = trim.andThen(toUpper).andThen(addPrefix);
System.out.println(pipeline.apply(" vasya ")); // USER: VASYA
5. 함수 합성에서 흔한 실수
오류 1: compose/andThen 순서를 혼동.
초보자는 무엇이 먼저 실행되는지 자주 헷갈립니다. 기억하세요: f.compose(g) — 먼저 g, 그다음 f; f.andThen(g) — 먼저 f, 그다음 g.
오류 2: 타입 불일치.
한 함수의 결과 타입이 다른 함수의 매개변수 타입과 일치하지 않으면 컴파일러가 결합을 허용하지 않습니다. 예를 들어 Function<Integer, String>.andThen(Function<Double, Boolean>)는 불가능합니다.
오류 3: 지나치게 복잡한 체인.
가끔 모든 비즈니스 로직을 하나의 체인에 욱여넣어 “스파게티”를 만들고 싶어질 수 있습니다. 작은 함수로 나누고, 이해하기 쉬운 이름을 붙이세요.
오류 4: 함수의 부작용.
함수와 Predicate는 가능하면 “순수하게”(부작용 없이) 유지하세요. 그렇지 않으면 합성이 위험하고 예측하기 어려워집니다.
GO TO FULL VERSION