자바 언어·플랫폼 학습 노트 목차

함수형 — 함수를 값처럼 다루기

"코드 조각을 데이터처럼 넘긴다"는 발상에서 모든 게 시작된다. 람다·메서드 참조로 함수를 값으로 만들고, Stream으로 무엇을 할지에 집중하는 선언형 사고로 넘어간다. 그리고 그게 바이트코드에서 어떻게 도는지, 실무에서 어디서 발이 걸리는지까지 본다.


1. "어떻게"에서 "무엇을"로

같은 일을 두 방식으로 써 보면 차이가 분명하다.

// 명령형: 어떻게 (루프를 돌고, 가변 변수에 쌓고)
int sum = 0;
for (int x : nums) if (x % 2 == 0) sum += x * x;

// 선언형(함수형): 무엇을 (짝수를 골라, 제곱해, 더한다)
int sum2 = nums.stream().filter(x -> x % 2 == 0).map(x -> x * x).reduce(0, Integer::sum);

선언형 쪽은 루프 변수와 가변 상태가 사라졌다. 무엇을 하려는지가 그대로 읽히고, 부수효과가 없어 테스트·병렬화가 쉽다. 이걸 가능케 하는 첫 재료가 "함수를 값으로 다루기"다.


2. 함수를 값으로 — 람다와 함수형 인터페이스

자바에서 함수를 값처럼 넘기는 통로가 함수형 인터페이스(추상 메서드가 딱 하나인 인터페이스)다. 그 자리에 람다를 끼운다.

@FunctionalInterface interface Calc { int apply(int a, int b); }
Calc add = (a, b) -> a + b;          // 함수가 값이 되어 변수에 담긴다

표준 인터페이스가 이미 준비돼 있다 — Supplier(()→T), Consumer(T→void), Function(T→R), Predicate(T→boolean) 등. 이미 있는 메서드를 가리킬 땐 메서드 참조가 더 짧다.

Function<String,Integer> f1 = Integer::parseInt;   // 정적 메서드
Function<String,Integer> f2 = String::length;      // 임의 객체의 인스턴스 메서드
Supplier<List<String>> f3 = ArrayList::new;        // 생성자

함수가 값이 되면 함수끼리 조합할 수도 있다(고차 함수).

Function<Integer,Integer> times2 = x -> x*2, plus1 = x -> x+1;
times2.andThen(plus1).apply(10);   // (10*2)+1 = 21

바이트코드에서는 (CS 연결)

람다는 익명 클래스로 컴파일되지 않는다. 그러면 람다마다 .class가 생겨 폭증하니, 컴파일러는 호출 지점에 invokedynamic 명령만 두고, 처음 실행할 때 LambdaMetafactory가 구현을 만들어 연결한다. 그리고 람다가 바깥 지역변수를 쓰면 그 값이 복사되어 들어가는데(스택 프레임은 곧 사라지므로), 그래서 캡처 변수는 effectively final이어야 한다.

실무에서는 — 아무것도 캡처하지 않는 람다는 싱글톤으로 재사용되지만, 변수를 캡처하는 람다는 호출마다 객체가 생길 수 있다. 초당 수만 번 도는 핫 경로에선 이 작은 할당도 쌓이니, 캡처 없는 람다나 상수로 빼는 게 유리할 때가 있다.


3. Stream — 지연 파이프라인

이제 함수를 데이터 흐름에 꽂는다. Stream은 소스 → 중간 연산들 → 종단 연산 구조다.

list.stream()                  // 소스
    .filter(s -> !s.isBlank()) // 중간 (지연)
    .map(String::trim)         // 중간 (지연)
    .toList();                 // 종단 (여기서 비로소 실행)

핵심은 중간 연산이 그 자리에서 실행되지 않는다는 것(지연 평가). filter·map은 "할 일"을 연결만 해 두고, 종단 연산(toList·reduce·findFirst…)이 호출될 때 *한 번의 순회로 전부 융합(fusion)*되어 돈다.

왜 지연인가? 즉시 실행하면 단계마다 중간 컬렉션이 생긴다. 지연하면 중간 컬렉션 없이 한 번에 흐르고, findFirst·limit 같은 *단락(short-circuit)*으로 필요한 만큼만 계산한다(그래서 무한 스트림도 다룬다). 내부적으론 소스가 Spliterator(쪼갤 수 있는 반복자)를 노출해, 이게 병렬 처리(ForkJoin)의 토대가 된다.

가장 흔한 함정 하나 — 스트림은 일회용이다.

Stream<Integer> s = nums.stream();
s.count();
s.forEach(...);   // 💥 IllegalStateException: 이미 소비된 스트림

수집(collect)은 Collectors가 다양하게 받쳐 준다 — 분류는 groupingBy, 참/거짓 이분은 partitioningBy, 두 집계를 한 번에는 teeing, 수집 후 불변화는 collectingAndThen.

그리고 "값이 없을 수 있음"은 **Optional**로 타입에 드러낸다.

repo.findByEmail(email).map(User::name).orElse("(없음)");

실무에서는Optional메서드 반환 타입 전용이다. 필드·파라미터·컬렉션 원소로 쓰지 마라(직렬화 불가·또 다른 null). "빈 결과"는 Optional보다 빈 리스트를 반환하는 게 낫다. 그리고 비싼 기본값은 orElse(항상 평가) 말고 orElseGet(빌 때만)으로(Item 55).


4. 실무에서 발이 걸리는 곳

선언형은 강력하지만 공유 상태예외에서 자주 미끄러진다.

// ✗ 병렬 스트림 + 공유 가변 → 데이터 경쟁, 결과가 매번 다름
int[] total = {0};
nums.parallelStream().forEach(x -> total[0] += x);

int ok = nums.parallelStream().mapToInt(i -> i).sum();   // ✓ 부수효과 없이 reduce/collect

실무에서는 — ① 병렬 스트림은 신중히. 기본적으로 공용 ForkJoinPool을 쓰므로, 한 곳에서 블로킹·장시간 작업을 병렬 스트림에 돌리면 전체 앱의 병렬 작업이 굶을 수 있다. 작은 데이터·비싼 동기화면 순차가 더 빠르다. ② 체크 예외를 던지는 메서드는 람다에서 그대로 못 쓰니 try/catch로 감싸 Unchecked…로 바꾼다. ③ 스트림이 오히려 읽기 어려운 경우(복잡한 상태·다중 조건)면 평범한 루프가 낫다 — 도구는 가독성을 위해 고른다.


한눈에 정리

  • 함수형 = "무엇을"에 집중, 가변 상태·부수효과를 줄인다.
  • 람다는 함수형 인터페이스의 인스턴스 — 바이트코드로는 invokedynamic, 캡처는 값 복사(effectively final).
  • Stream 3박자: 소스 → 중간(지연) → 종단(기동). 지연이라 융합·단락·무한 스트림이 가능. 일회용.
  • Collectors(groupingBy·partitioningBy·teeing)·Optional(반환 전용, orElseGet).
  • 실무: 병렬 스트림은 공용 풀 주의, 체크 예외는 감싸기, 안 읽히면 루프로.
제네릭컬렉션·예외·리소스