← all posts
DEV 2026.05.05 · 12 min read Intermediate

자바 함수형 프로그래밍의 다섯 가지 기둥

고차 함수부터 Either 패턴까지, 자바 함수형 설계의 핵심 원칙과 각 기법이 공유하는 단 하나의 철학을 추적한다.


자바 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 제약

람다가 캡처하는 외부 변수는 반드시 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 및 병렬 처리와 어떻게 연결되는지 추적한다.