📚 5장. 자바 함수형 프로그래밍 (Functional Programming)
5.1 함수형 프로그래밍이란?
함수형 프로그래밍(Functional Programming)은 순수 함수와 불변성을 중시하는 선언형 프로그래밍 스타일입니다. 자바 8부터는 람다식
과 Stream API
, 함수형 인터페이스
등을 통해 함수형 프로그래밍 스타일을 지원합니다.
🧠 핵심 개념 요약
개념 | 설명 |
---|---|
명령형 프로그래밍 | 어떻게 할 것인가에 집중 (반복문, 변수 조작) |
선언형 프로그래밍 | 무엇을 할 것인가에 집중 (ex. SQL, Stream API) |
순수 함수 | 입력이 같으면 항상 같은 출력을 반환, 부작용 없음 |
불변성 | 데이터 상태를 변경하지 않음 |
일급 함수 | 함수를 값처럼 취급하여 전달하거나 반환 가능 |
차이 설명:
명령형 프로그래밍은 "어떻게 처리할지 절차 중심으로 설명"합니다. 즉, 작업을 수행하기 위해 반복문과 조건문 등을 사용하여 모든 처리 흐름을 직접 작성합니다.
반면 선언형 프로그래밍은 "무엇을 할 것인지 선언"하고, 내부 구현 세부 사항은 시스템이나 언어의 라이브러리가 처리합니다. 개발자는 결과를 설명할 뿐, 처리 방법은 직접 지정하지 않아도 됩니다.
예를 들어, SQL
은 "SELECT * FROM ..." 형태로 원하는 결과만 선언하고, 실제 데이터 검색 알고리즘은 데이터베이스 시스템이 처리합니다. 마찬가지로 자바의 Stream API
도 무엇을 하고 싶은가에 집중하게 해줍니다.
// 명령형 스타일
List<String> result = new ArrayList<>();
for (String s : list) {
if (s.startsWith("A")) {
result.add(s.toLowerCase());
}
}
코드 분석:
new ArrayList<>()
: 결과를 담을 리스트 생성for (String s : list)
: 리스트의 모든 요소 반복if (s.startsWith("A"))
: 'A'로 시작하는 항목만 필터링s.toLowerCase()
: 소문자로 변환result.add()
: 결과 리스트에 추가
// 선언형 스타일 (함수형)
List<String> result = list.stream()
.filter(s -> s.startsWith("A"))
.map(String::toLowerCase)
.collect(Collectors.toList());
코드 분석:
filter()
: 'A'로 시작하는 항목만 남김map(String::toLowerCase)
: 각 요소에 대해 String 클래스의toLowerCase()
메서드를 적용해 소문자로 변환collect()
: 리스트로 수집
5.2 함수형 인터페이스란?
함수형 인터페이스(Functional Interface)는 추상 메서드가 하나만 존재하는 인터페이스
를 말합니다. 자바 8부터 도입되었으며, 람다식과 함께 자주 사용됩니다.
자바 표준 라이브러리에는 이미 다양한 함수형 인터페이스가 제공되며, 대표적으로 아래 4가지를 자주 사용합니다.
✅ 주요 함수형 인터페이스
이름 | 타입 | 설명 | 예시 |
---|---|---|---|
Function<T, R> | 입력 → 출력 | T 타입을 받아 R 타입으로 반환 | str -> str.length() |
Consumer<T> | 입력 → 소비 | T 타입을 받아 처리하지만 반환은 없음 | System.out::println |
Supplier<T> | 출력만 있음 | T 타입 값을 반환 (입력 없음) | () -> "Hello" |
Predicate<T> | 입력 → boolean | T 타입을 받아 true/false 반환 | n -> n > 0 |
🧪 Function 예제
Function<String, Integer> getLength = str -> str.length();
int len = getLength.apply("Stream");
System.out.println(len);
코드 분석:
Function<String, Integer>
: 입력은 String, 반환은 Integerstr -> str.length()
: 람다식으로 문자열 길이 반환apply("Stream")
: Function을 실행하여 6 반환
🧪 Consumer 예제
Consumer<String> printUpper = s -> System.out.println(s.toUpperCase());
printUpper.accept("java");
코드 분석:
Consumer<String>
: 입력은 String, 반환 없음s -> System.out.println(...)
: 입력값을 대문자로 출력accept("java")
: 실행 결과 JAVA 출력
🧪 Supplier 예제
Supplier<Double> random = () -> Math.random();
System.out.println(random.get());
코드 분석:
Supplier<Double>
: 입력 없이 Double 값 생성() -> Math.random()
: Math 클래스에서 난수 생성get()
: 호출 시 새로운 난수 반환
🧪 Predicate 예제
Predicate<Integer> isEven = n -> n % 2 == 0;
System.out.println(isEven.test(10));
코드 분석:
Predicate<Integer>
: 입력은 Integer, 결과는 booleann -> n % 2 == 0
: 짝수인지 검사test(10)
: true 반환
이러한 함수형 인터페이스들은 람다와 함께 사용될 때 코드의 간결성, 유연성, 재사용성을 크게 향상시킵니다.
5.3 람다와 함수형 인터페이스 실습
람다는 익명 함수를 표현하는 방식으로, 함수형 인터페이스와 함께 사용됩니다. 아래는 각 인터페이스와 람다식이 실제 코드에 어떻게 적용되는지를 보여주는 예시입니다.
🎯 실습 예제: Function + 람다
Function<Integer, String> gradeMapper = score -> {
if (score >= 90) return "A";
else if (score >= 80) return "B";
else if (score >= 70) return "C";
else return "F";
};
System.out.println(gradeMapper.apply(85));
코드 분석:
Function<Integer, String>
: 정수를 받아 등급 문자열 반환score -> { ... }
: 조건에 따라 등급 매핑apply(85)
: "B" 반환
🎯 실습 예제: Predicate + 람다
Predicate<String> isEmail = s -> s.contains("@");
System.out.println(isEmail.test("user@example.com"));
코드 분석:
Predicate<String>
: 문자열 입력 받아 boolean 결과 반환s.contains("@")
: 이메일 형식인지 단순 확인
🎯 실습 예제: Consumer + 람다
Consumer<List<String>> printer = list -> list.forEach(System.out::println);
printer.accept(Arrays.asList("apple", "banana"));
코드 분석:
Consumer<List<String>>
: 리스트를 받아 출력 작업 수행list.forEach(...)
: 리스트의 모든 요소 출력
🎯 실습 예제: Supplier + 람다
Supplier<LocalDate> today = () -> LocalDate.now();
System.out.println(today.get());
코드 분석:
Supplier<LocalDate>
: 현재 날짜 반환() -> LocalDate.now()
: 입력 없이 실행 시점 날짜 제공
5.4 메서드 참조 vs 익명 클래스
람다식과 함께 자주 사용되는 문법이 메서드 참조(Method Reference)입니다. 이는 메서드를 직접 호출하는 것이 아닌, 이름으로 간단히 표현하는 방식입니다. 또한 람다가 도입되기 전 자바에서는 익명 클래스(Anonymous Class)를 사용해 함수형 스타일을 흉내냈습니다.
🔁 예제: 메서드 참조 vs 람다 vs 익명 클래스
// 메서드 참조
Consumer<String> printer1 = System.out::println;
printer1.accept("Hello Method Reference");
// 람다식
Consumer<String> printer2 = s -> System.out.println(s);
printer2.accept("Hello Lambda");
// 익명 클래스
Consumer<String> printer3 = new Consumer<String>() {
@Override
public void accept(String s) {
System.out.println(s);
}
};
printer3.accept("Hello Anonymous Class");
코드 분석:
System.out::println
: 메서드 참조 방식, 가장 간결함s -> System.out.println(s)
: 람다식, 명시적으로 표현new Consumer<>() { ... }
: 익명 클래스, 문법이 복잡하고 장황함
📌 정리
스타일 | 장점 | 단점 |
---|---|---|
메서드 참조 | 가장 간결하고 읽기 쉬움 | 직관성이 떨어질 수 있음 |
람다식 | 가독성 + 표현력 좋음 | 중첩되면 가독성 떨어질 수 있음 |
익명 클래스 | 클래스 구조를 명확히 표현 가능 | 코드가 길고 복잡함 |
5.5 실무 활용 예제
실제 개발에서는 람다와 함수형 인터페이스를 함께 사용하여 코드의 복잡도를 줄이고 가독성을 높이는 다양한 패턴을 활용합니다. 아래는 리스트 필터링과 정렬, 데이터 수집을 함수형 스타일로 처리하는 실전 예제입니다.
🧩 예제: 조건 필터링 + 정렬 + 출력
List<String> names = Arrays.asList("alice", "bob", "alex", "brad", "anna");
names.stream()
.filter(name -> name.startsWith("a"))
.sorted()
.map(String::toUpperCase)
.forEach(System.out::println);
코드 분석:
filter(name -> name.startsWith("a"))
: 'a'로 시작하는 이름만 추출sorted()
: 알파벳 순 정렬map(String::toUpperCase)
: 대문자로 변환forEach(System.out::println)
: 최종 출력
🧩 예제: 객체 리스트에서 조건 필터 후 평균 구하기
class User {
String name;
int age;
User(String name, int age) {
this.name = name;
this.age = age;
}
public int getAge() { return age; }
}
List<User> users = Arrays.asList(
new User("Tom", 28),
new User("Jane", 35),
new User("Sara", 24)
);
double avg = users.stream()
.filter(u -> u.getAge() >= 30)
.mapToInt(User::getAge)
.average()
.orElse(0);
System.out.println("평균 나이: " + avg);
코드 분석:
filter()
: 나이가 30 이상인 사용자 필터링mapToInt(User::getAge)
: 나이만 추출average()
: 평균값 계산orElse(0)
: 값이 없을 경우 0 반환
🧩 예제: Map에서 값 조건 필터 + key 수집
Map<String, Integer> scores = Map.of(
"Alice", 95,
"Bob", 82,
"Charlie", 67,
"Diana", 88
);
List<String> passed = scores.entrySet().stream()
.filter(entry -> entry.getValue() >= 80)
.map(Map.Entry::getKey)
.collect(Collectors.toList());
System.out.println(passed);
코드 분석:
entrySet().stream()
: Map을 스트림으로 변환filter()
: 점수가 80 이상인 항목만 필터링map(Map.Entry::getKey)
: key만 추출collect(Collectors.toList())
: 리스트로 수집
🧩 예제: List 그룹핑 후 갯수 세기
List<String> items = List.of("apple", "banana", "apple", "orange", "banana");
Map<String, Long> counted = items.stream()
.collect(Collectors.groupingBy(Function.identity(), Collectors.counting()));
System.out.println(counted);
코드 분석:
groupingBy(Function.identity())
: 값 자체를 기준으로 그룹화Collectors.counting()
: 각 항목의 개수 세기
🧩 예제: 문자열 리스트를 길이순으로 정렬
List<String> words = Arrays.asList("banana", "fig", "apple", "kiwi");
words.stream()
.sorted(Comparator.comparingInt(String::length))
.forEach(System.out::println);
코드 분석:
sorted(Comparator.comparingInt(...))
: 길이를 기준으로 정렬forEach()
: 정렬된 단어 출력
🧩 예제: null 값 제거 후 정렬
List<String> cities = Arrays.asList("Seoul", null, "Busan", "Incheon", null);
cities.stream()
.filter(Objects::nonNull)
.sorted()
.forEach(System.out::println);
코드 분석:
filter(Objects::nonNull)
: null 값 제거sorted()
: 알파벳 순 정렬
🧩 예제: 특정 접두사로 시작하는 문자열 개수 세기
List<String> items = Arrays.asList("car", "bus", "bike", "boat", "cab");
long count = items.stream()
.filter(s -> s.startsWith("b"))
.count();
System.out.println("'b'로 시작하는 단어 수: " + count);
코드 분석:
filter(...)
: 'b'로 시작하는 문자열만 통과count()
: 통과한 항목의 개수 반환
0 댓글