자바 함수형 프로그래밍의 다섯 가지 기둥
고차 함수부터 Either 패턴까지, 자바 함수형 설계의 핵심 원칙과 각 기법이 공유하는 단 하나의 철학을 추적한다.
- 01 람다는 어떻게 바이트코드가 되는가
- 02 Java Stream은 왜 Terminal 전까지 아무것도 하지 않는가
- 03 parallelStream()은 왜 항상 빠르지 않은가
- 04 Optional은 왜 메서드 반환 타입으로만 써야 하는가
- 05 CompletableFuture는 왜 Future를 버렸는가
- 06 Java 인터페이스는 왜 이렇게 진화했는가
- 07 Java 날짜/시간 API는 왜 이렇게 설계됐을까
- 08 Java는 왜 Record, Sealed, Pattern을 함께 설계했을까
- 09 Virtual Thread는 왜 수백만 개가 가능한가
- 10 자바 함수형 프로그래밍의 다섯 가지 기둥
자바 8이 람다를 도입한 지 10년이 넘었다. 그런데 많은 코드베이스는 여전히 for 루프와 try-catch로 가득하고, 함수형 API는 Stream을 toList()로 끝내는 데만 쓴다. 고차 함수, 커링, 메모이제이션, 영속 자료구조, Either 패턴 — 이 다섯 가지는 별개의 기법처럼 보이지만, 사실 하나의 원칙에서 파생된다. 그 원칙이란 무엇인가?
공통 뿌리: “부작용을 값으로”
다섯 챕터를 관통하는 철학은 하나다 — 부작용(side effect)과 불확실성을 함수의 반환 값 안으로 끌어들여라.
전통적 명령형 코드는 부작용을 밖으로 흘린다. 변경 가능한 컬렉션을 공유하고, 예외를 스택 밖으로 던지고, 계산 결과를 DB에 기록한다. 이 흐름은 직관적이지만 조합이 어렵다. 두 함수를 붙이려 할 때마다 예외 처리, 동기화, 복사 비용이 끼어든다.
함수형 설계는 반대 방향을 택한다. 실패 가능성은 Either<E, T>로, 반복 계산은 memoize(f)로, 컬렉션 변경은 새 버전 반환으로 — 모두 함수 시그니처 안에서 처리한다. 그러면 함수들이 .map(), .flatMap(), .andThen()으로 자유롭게 체인된다.
고차 함수 — 함수를 값처럼 다루기
Function<T, R>은 그 자체로 값이다. Comparator.comparing(Person::getAge)가 Comparator<Person>을 반환하고, Collectors.groupingBy(Person::getDepartment)가 Collector를 반환하는 것도 고차 함수다 — 함수를 받아 함수를 돌려주는 구조.
// 전략 패턴 8개 클래스 → 함수 2개
Function<String, Boolean> emailValidator = s -> s.matches("^[A-Za-z0-9+_.-]+@(.+)$");
Function<String, Boolean> phoneValidator = s -> s.matches("^\\d{10,}$");
// 합성
Function<Integer, Integer> doubleThenAdd = ((Function<Integer, Integer>) (x -> x * 2))
.andThen(x -> x + 1);
andThen과 compose의 차이는 실행 순서다. f.andThen(g)는 f 먼저, f.compose(g)는 g 먼저 실행한다. 혼동하기 쉬운 지점이므로 실험으로 확인하는 것이 가장 확실하다.
커링과 부분 적용 — 인자를 시간차로 채우기
커링은 (A, B) → R을 A → (B → R)로 구조 변환한다. 부분 적용은 첫 인자를 고정해 새 함수를 만드는 구현이다. 둘은 다르다.
// 커링: 구조 변환
public static <A, B, R> Function<A, Function<B, R>> curry(BiFunction<A, B, R> f) {
return a -> b -> f.apply(a, b);
}
// 부분 적용: 설정을 미리 주입
public Function<String, String> createPrefixer(String prefix) {
return str -> prefix + str; // prefix 클로저로 캡처
}
Function<String, String> warnPrefix = createPrefixer("[WARN] ");
실무에서 자주 쓰이는 패턴은 커링보다 부분 적용이다. Spring @Bean 팩토리, Kafka 콜백, 환경별 설정 로더 — 모두 “공통 의존성을 한 번 고정하고, 이후 호출에선 가변 인자만 받는” 구조다. JIT 최적화 덕분에 성능 오버헤드는 무시할 수준이다.
람다가 캡처하는 외부 변수는 반드시 effectively final이어야 한다. 멀티스레드 환경에서 캡처 이후 변수가 바뀌면 경합 상태(race condition)가 생기기 때문이다. 자바는 이를 컴파일 에러로 조기 차단한다.
메모이제이션 — 순수 함수의 결과를 캐싱하기
순수 함수는 같은 입력에 항상 같은 결과를 반환한다. 그 결과를 저장해두면 재계산이 필요 없다.
public static <T, R> Function<T, R> memoize(Function<T, R> f) {
ConcurrentHashMap<T, R> cache = new ConcurrentHashMap<>();
return t -> cache.computeIfAbsent(t, f);
}
computeIfAbsent는 같은 키에 대해 계산 함수를 최대 한 번만 실행하도록 보장한다(버킷 단위 동기화). Fibonacci(35) 기준으로 순진한 재귀 대비 약 5000배 빠르다.
단순 ConcurrentHashMap 메모이제이션의 한계는 크기 제한이 없다는 점이다. 오래 실행되는 서비스에서 서로 다른 키가 계속 들어오면 메모리가 무한 증가한다. 실무에서는 Caffeine을 사용한다.
Cache<String, Object> cache = Caffeine.newBuilder()
.maximumSize(1000)
.expireAfterWrite(10, TimeUnit.MINUTES)
.recordStats()
.build();
LocalDateTime.now(), 난수 생성, DB 조회처럼 같은 입력에 다른 결과를 낼 수 있는 함수에는 메모이제이션을 적용하면 안 된다. 적용하려면 TTL을 포함해 (key, timestamp) 형태로 캐시 유효 범위를 명시적으로 제한해야 한다.
영속 자료구조 — 변경을 새 버전으로
List.of()는 불변 컬렉션이다. 변경이 필요하면 new ArrayList<>()로 복사해야 한다. 이 O(N) 복사가 반복되면 O(N²) 비용이 쌓인다.
영속 자료구조는 다르다. Vavr Vector는 32-ary Trie 구조로 변경된 경로의 노드만 새로 할당하고 나머지는 재사용한다 — 구조 공유(Structural Sharing). 결과적으로 append와 update가 O(log N)이고, 이전 버전이 그대로 메모리에 남는다.
Vector<String> v1 = Vector.of("a", "b", "c");
Vector<String> v2 = v1.update(1, "B"); // v1은 변경 없음
Vector<String> v3 = v1.append("d"); // v1은 변경 없음
이 성질은 이벤트 소싱, 버전 관리, “시간 여행” 디버깅에 직접 연결된다. 1000번 변경 후 모든 버전을 보관할 때, ArrayList 방식은 수십 GB가 필요하지만 Vector는 구조 공유 덕분에 수십 MB로 충분하다.
Either 패턴 — 실패를 타입으로
Checked Exception의 근본 문제는 함수 체인을 끊는다는 것이다. Stream.map()에 throws 람다를 넣을 수 없다.
Either<E, T>는 “성공(Right) 또는 실패(Left) 중 하나”를 타입으로 표현한다. flatMap은 실패가 발생하면 이후 모든 체인을 건너뛰고 실패를 전파한다.
Either<ValidationError, User> result =
validateEmail(email)
.flatMap(e -> validatePassword(password))
.flatMap(pwd -> createUserInDB(email, pwd));
return result.fold(
error -> ResponseEntity.badRequest().body(Map.of("error", error.message)),
user -> ResponseEntity.ok().body(Map.of("userId", user.id))
);
Java 21이라면 sealed interface + record로 외부 의존성 없이 직접 구현할 수 있다. 성능 오버헤드는 약 10% 수준으로 무시할 만하다.
정리
다섯 기법은 결국 같은 원칙의 다른 표현이다.
- 고차 함수: 로직을 값으로 전달하고 합성한다.
- 커링/부분 적용: 의존성을 시간차로 주입하고 재사용한다.
- 메모이제이션: 순수 함수의 결과를 캐싱해 중복 계산을 제거한다.
- 영속 자료구조: 변경을 새 버전으로 표현해 불변성과 성능을 동시에 얻는다.
- Either 패턴: 실패를 예외가 아닌 값으로 표현해 함수 체인에 녹인다.
“부작용을 함수 반환 값 안으로” — 이 원칙을 받아들이면 코드가 더 좁고 더 합성 가능해진다. 다음 글에서는 이 원칙이 Stream의 lazy evaluation 및 병렬 처리와 어떻게 연결되는지 추적한다.