1. 자바 8 함수형 프로그래밍 도입 배경
CPU가 단일코어에서 멀티코어로 전환되며 병렬처리가 중요해졌음. 병렬처리를 위해 불변성과 순수함수가 요구됨.
javaScript, python, kotlin 등의 언어가 이미 함수형 스타일을 채택하고 있었고 자바 또한 뒤처지지 않기 위해 함수형 프로그래밍을 일부 도입하게 됨.
불변성 - 데이터가 한번 생성되면 변경되지 않음.
함수형 프로그래밍은 데이터를 수정하지 않고 새로운 값을 만들어냄. 이 결과 부작용이 없고 병렬처리, 테스트가 쉬움.
순수함수 - 같은 입력에 대해 항상 같은 출력을 하며 부작용(side effect)이 없음.
함수형 프로그래밍은 순수함수만을 사용하려 하여 코드의 예측 가능성과 테스트 용이성이 크게 향상됨
객체지향과 함수형은 배타적 관계가 아닌 상호보완적 관계를 가짐.
| 비교 항목 | 객체지향(OOP) | 함수형(FP) |
| 중심 개념 | 객체 (데이터+행위) | 함수 (입력 → 출력) |
| 상태 변화 | 상태 변화 허용 (mutable) | 불변성 (immutable) |
| 단위 | 클래스, 객체 | 함수 |
| 장점 | 추상화, 캡슐화, 코드 재사용 | 병렬 처리 용이, 부수 효과 없음 |
| 단점 | 동시성, 상태 관리 어려움 | 추상화, 구조화 어려움 |
예를 들어,
OOP로 시스템의 구조(도메인 모델 등)를 구성하고,
FP로 데이터 흐름이나 이벤트 처리 등을 선언적으로 처리
→ Java 8은 이를 위한 균형점을 제공 (예: Stream API로 컬렉션 처리, Optional로 null 처리 등)
함수형 프로그래밍은 의도가 명확하고 간결한 선언형 스타일과 체이닝 구조로 흐름이 눈에 잘 들어오게 설계 가능.
주의점: 너무 복잡한 람다는 오히려 가독성 저해 → 적절한 함수 분리 필요.
2. 람다식의 개념과 필요성
람다식이란
함수를 하나의 식으로 간단히 표현한 문법으로, 익명 함수를 간결하게 표현하는 데 사용된다.
주로 함수형 인터페이스(메서드가 1개인 인터페이스)와 함께 사용됨.
예전에는 익명 내부 클래스 사용 → 코드가 길고 복잡했음.
람다식으로 간결하게 함수 전달 가능.
람다식 기본 문법
(매개변수) → { 실행문 }
매개변수 : 함수가 받을 인자.
→ : 람다를 구분하기 위한 화살표.
실행문 : 함수 본문으로 여러 줄로 이루어질 수도 있음.
기존 익명클래스 방식
Runnable r = new Runnable() {
public void run() {
System.out.println("Hello");
}
};
람다식으로 변경
Runnable r = () -> System.out.println("Hello");
람다식은 불필요한 요소(타입, 중괄호, return)를 생략할 수 있음.
| 원형 | 축약형 | 설명 |
| (String s) -> { return s.length(); } | s -> s.length() | 타입 생략, 중괄호 생략 |
| (a, b) -> { return a + b; } | (a, b) -> a + b | return 생략 |
| () -> { System.out.println(1); } | () -> System.out.println(1) | 실행문 1개면 중괄호 생략 가능 |
// 1. 매개변수 없는경우
Runnable r = () -> System.out.println("No parameter");
// 2. 매개변수 1개
Consumer<String> c = s -> System.out.println(s.toUpperCase());
// 3. 매개변수 2개, 리턴 있음
BiFunction<Integer, Integer, Integer> sum = (a, b) -> a + b;
3. 함수형 인터페이스
함수형 인터페이스란,
- 단 하나의 추상 메서드만 가지는 인터페이스 (default, static 메서드는 여러 개 포함해도 무관함)
- 자바 8에서 람다식을 사용하기 위한 조건
- @FunctionalInterface 어노테이션으로 명시하면 컴파일러가 체크해 줌
@FunctionalInterface
interface MyFunction {
void run(); // 추상 메서드 1개만 허용
}
위 인터페이스를 다음과 같이 람다식으로 구현 가능함
MyFunction f = () -> System.out.println("실행!");
f.run();
하지만 이처럼 함수형 인터페이스를 직접 만들 일은 크게 없음.
자바에서 기본적으로 다음과 같은 상황들이 잦아서 미리 일반화해 두었음.
- 값을 받아서 처리만 하고 싶을 때 (Consumer)
- 값을 생성해서 리턴 하고 싶을 때 (Supplier)
- 값을 받아서 변환하고 싶을 때 (Function)
- 조건을 검사하고 싶을 때 (Predicate)
함수형 인터페이스를 가져다 쓰기만 하면 됨.
| 인터페이스 | 추상 메서드 | 설명 |
| Runnable | void run() | 매개변수 없음, 리턴 없음 |
| Consumer<T> | void accept(T t) | 인자 1개, 리턴 없음 |
| Supplier<T> | T get() | 인자 없음, 값 리턴 |
| Function<T, R> | R apply(T t) | 인자 1개, 리턴 있음 |
| Predicate<T> | boolean test(T t) | 조건 검사 (true/false 반환) |
| BiFunction<T,U,R> | R apply(T t, U u) | 인자 2개, 리턴 있음 |
아래에서 설명할
stream api를 사용하는데 함수형 인터페이스가 사용됨.
list.stream()
.filter(s -> s.startsWith("A")) // Predicate
.map(s -> s.toUpperCase()) // Function
.forEach(s -> System.out.println(s)); // Consumer
4. 메서드 레퍼런스
메서드 레퍼런스란, 기존에 정의된 메서드를 람다식처럼 전달할 수 있도록 하는 문법.
즉, 람다식을 축약한 표현이며, 메서드 이름만으로 함수의 전달이 가능하다.
람다식 : x -> System.out.println(x)
레퍼런스 : System.out::println
사용하기 위한 기본 문법은
위 예시처럼 메서드 참조연산자 :: 를 사용함.
클래스명 또는 참조변수명 :: 메서드이름
메서드 레퍼런스 종류
| 종류 | 설명 | 예시 | 대상 |
| 정적 메서드 참조 | 클래스의 static 메서드 | Integer::parseInt | Function<String, Integer> |
| 인스턴스 메서드 참조 (특정 객체) | 특정 객체의 메서드 | "hello"::toUpperCase | Supplier<String> |
| 인스턴스 메서드 참조 (클래스명) | 객체를 받아 실행할 인스턴스 메서드 | String::toUpperCase | Function<String, String> |
| 생성자 참조 | new 키워드도 참조 가능 | ArrayList::new | Supplier<List<String>> |
정적 메서드 참조
Function<String, Integer> parse = Integer::parseInt;
// 람다식: s -> Integer.parseInt(s)
System.out.println(parse.apply("123")); // 출력: 123
인스턴스 메서드 참조
Function<String, String> upper = String::toUpperCase;
// 람다식: s -> s.toUpperCase()
System.out.println(upper.apply("hello")); // 출력: HELLO
생성자 참조
Supplier<List<String>> listSupplier = ArrayList::new;
// 람다식: () -> new ArrayList<>()
List<String> list = listSupplier.get();
5. 스트림 개념
스트림이란,
컬렉션(또는 배열)의 요소들을 함수형 방식으로 처리하기 위한 API
- 데이터를 필터링, 변환, 집계하는 과정을 간결하게 표현할 수 있음
- 데이터 소스를 변경하지 않고, 파이프라인 방식으로 처리
사용 예시 1 :
List<String> names = List.of("Alice", "Bob", "Charlie");
names.stream()
.filter(name -> name.startsWith("A"))
.map(String::toUpperCase)
.forEach(System.out::println);
결과 : ALICE
사용 예시 2 :
List<Integer> numbers = List.of(1, 2, 3, 4, 5);
// 짝수만 제곱해서 출력
numbers.stream()
.filter(n -> n % 2 == 0)
.map(n -> n * n)
.forEach(System.out::println);
결과 :
4
16
스트림 특징
| 특징 | 설명 |
| 선언형 | for나 if 대신 무엇을 할지를 선언적으로 표현 |
| 데이터 불변성 | 스트림은 원본 데이터를 변경하지 않음 |
| 파이프라인 처리 | 중간 연산들을 체이닝하여 연결하고 최종 연산으로 실행 |
| 지연 연산(Lazy Evaluation) | 최종 연산이 호출될 때까지 실제 처리는 일어나지 않음 |
| 병렬 처리 지원 | parallelStream() 사용 시 멀티코어 병렬 처리 가능 |
+ 내부반복
names.stream()
.map(String::toUpperCase)
.forEach(System.out::println);
기존에 개발자가 for문을 통해 직접 외부반복으로 제어해 주던 것을
스트림 내부에서 자동으로 처리해 줌. 병렬 처리나 최적화가 내부적으로 가능함.
외부반복 예시 :
List<String> names = List.of("Alice", "Bob", "Charlie");
for (String name : names) {
System.out.println(name.toUpperCase());
}
스트림은 다음 세 단계로 구성됨
1. 스트림 생성
List<String> list = List.of("a", "b", "c");
Stream<String> stream = list.stream();
2. 스트림 중간연산(반환값은 스트림)
| 메서드 | 설명 |
| filter(Predicate) | 조건에 맞는 요소만 통과 |
| map(Function) | 요소 변환 |
| sorted() | 정렬 |
| distinct() | 중복 제거 |
3. 스트림 최종연산(스트림 소비)
| 메서드 | 설명 |
| forEach(Consumer) | 각 요소 처리 |
| collect() | 리스트, 집합 등으로 수집 |
| count() | 요소 개수 반환 |
| anyMatch(), allMatch() | 조건 판별 |
스트림의 생성, 소모는 각 한 번씩만 가능하며, 중간 연산은 여러 번 가능.
List<String> words = List.of("apple", "banana", "apricot", "blueberry");
long count = words.stream() // 스트림 생성
.filter(w -> w.startsWith("a")) // 중간 연산 (a로 시작하는 단어만)
.map(String::toUpperCase) // 중간 연산 (대문자 변환)
.distinct() // 중간 연산 (중복 제거)
.count(); // 최종 연산 (갯수 세기)
System.out.println(count); // 출력: 2 ("APPLE", "APRICOT")
+ 병렬 스트림
'정리' 카테고리의 다른 글
| 순차적 스트림과 병렬 스트림 (0) | 2025.05.12 |
|---|---|
| 소프트웨어 개발 방법론 애자일 (0) | 2025.05.06 |
| 클린 코드를 위한 소프트웨어 설계 원칙: DRY 원칙 (1) | 2025.05.03 |
| 스프링의 @Transactional (0) | 2025.05.02 |
| 스프링 Bean의 scope (0) | 2025.04.30 |