함수형 프로그래밍 심화
함수형 프로그래밍 (Functional Programming)
왜 쓰는지
객체지향 프로그래밍(OOP)은 상태(state)를 객체에 담아 변경하며 동작한다. 이 방식은 시스템이 커질수록 어디서 상태가 바뀌는지 추적하기 어려워지고, 멀티스레드 환경에서 버그가 생기기 쉽다.
==함수형 프로그래밍==은 불변 데이터와 순수 함수를 중심으로, 상태 변경 없이 값을 변환하는 방식으로 이 문제를 해결한다.
핵심 개념
순수 함수 (Pure Function)
같은 입력이면 항상 같은 출력. 외부 상태를 읽거나 변경하지 않는다.
// 순수 함수
int add(int a, int b) {
return a + b; // 외부 의존 없음, 사이드 이펙트 없음
}
// 순수 함수가 아닌 예
int count = 0;
void increment() {
count++; // 외부 상태 변경 → 부작용(side effect)
}
불변성 (Immutability)
데이터를 변경하지 않고, 변환된 새 값을 반환한다.
// 가변 방식
List<String> list = new ArrayList<>();
list.add("a"); // 원본 변경
// 불변 방식 (스트림)
List<String> newList = original.stream()
.filter(s -> !s.isEmpty())
.toList(); // 원본 그대로, 새 리스트 반환
1급 함수 (First-class Function)
함수를 값처럼 다룰 수 있다. 변수에 저장하고, 파라미터로 전달하고, 반환값으로 돌려줄 수 있다.
// 함수를 변수에 저장
Function<Integer, Integer> square = x -> x * x;
// 함수를 파라미터로 전달
List<Integer> result = numbers.stream()
.map(square) // 함수 전달
.toList();
// 함수를 반환하는 함수 (고차 함수)
Function<Integer, Function<Integer, Integer>> adder = a -> b -> a + b;
Function<Integer, Integer> add5 = adder.apply(5);
add5.apply(3); // 8
고차 함수 (Higher-order Function)
함수를 인자로 받거나 함수를 반환하는 함수.
// map, filter, reduce 모두 고차 함수
list.stream()
.filter(x -> x > 0) // 조건 함수를 받음
.map(x -> x * 2) // 변환 함수를 받음
.reduce(0, Integer::sum); // 누산 함수를 받음
Java에서 함수형 프로그래밍
Java 8부터 람다, Stream API, 함수형 인터페이스로 함수형 스타일을 지원한다.
주요 함수형 인터페이스
Function<T, R> // T 받아 R 반환: map에 사용
Predicate<T> // T 받아 boolean: filter에 사용
Consumer<T> // T 받아 반환 없음: forEach에 사용
Supplier<T> // 인자 없이 T 반환: 지연 생성에 사용
UnaryOperator<T> // T 받아 T 반환: 같은 타입 변환
BinaryOperator<T> // T 두 개 받아 T 반환: reduce에 사용
함수 합성
Function<Integer, Integer> times2 = x -> x * 2;
Function<Integer, Integer> plus3 = x -> x + 3;
// andThen: times2 실행 후 plus3
Function<Integer, Integer> times2ThenPlus3 = times2.andThen(plus3);
times2ThenPlus3.apply(5); // (5 * 2) + 3 = 13
// compose: plus3 먼저, 그 다음 times2
Function<Integer, Integer> plus3ThenTimes2 = times2.compose(plus3);
plus3ThenTimes2.apply(5); // (5 + 3) * 2 = 16
Predicate 합성
Predicate<Integer> isPositive = x -> x > 0;
Predicate<Integer> isEven = x -> x % 2 == 0;
Predicate<Integer> isPositiveAndEven = isPositive.and(isEven);
Predicate<Integer> isPositiveOrEven = isPositive.or(isEven);
Predicate<Integer> isNotPositive = isPositive.negate();
list.stream()
.filter(isPositiveAndEven)
.toList();
OOP vs 함수형 비교
| 항목 | OOP | 함수형 |
|---|---|---|
| 중심 개념 | 객체와 상태 | 함수와 값 변환 |
| 상태 | 가변 (mutable) | 불변 (immutable) |
| 부작용 | 허용 | 최소화 |
| 코드 재사용 | 상속, 다형성 | 함수 합성 |
| 멀티스레드 | 동기화 필요 | 상태 없으므로 안전 |
장점
- 사이드 이펙트가 없어 버그 추적이 쉬움
- 순수 함수는 테스트가 간단 (입력→출력만 검증)
- 함수 합성으로 복잡한 로직을 작은 단위로 분리
- 불변 데이터로 멀티스레드 안전성 확보
단점
- 익숙하지 않으면 가독성이 오히려 낮아짐
- 불변 데이터는 매번 새 객체 생성 → 메모리 사용량 증가 가능
- 완전한 불변성은 Java에서 강제되지 않아 규율 필요
- 재귀 중심 코드는 스택 오버플로우 위험 (Java는 꼬리재귀 최적화 없음)
주의할 점
람다 안에서 외부 변수 변경 불가
int count = 0;
list.forEach(s -> count++); // 컴파일 오류 - effectively final 위반
// 대신 스트림으로 집계
long count = list.stream().count();
사이드 이펙트를 forEach에 몰아넣기
순수 함수 파이프라인 끝에만 부작용을 허용하는 패턴을 권장한다.