함수형 프로그래밍
왜 Java8부터 함수형 프로그래밍을 지원하게 되었을까?
왜 Spring5 부터 WebFlux를 필두로 리액티브 프로그래밍을 지원하는 것일까?
Java 8 이후로 나타난 '람다', 'Stream API' 등을 깊게 이해하기 위해서는 이들이 왜 생겨났는지를 알면 매우 좋을 것이라고 생각했다.
객체지향의 정수라고 할 수 있는 Java가 람다를 도입하고 함수형 프로그래밍을 지원한다는건 다른 언어들도 점차 함수형으로 바뀔 것을 의미한다고 생각한다.
나중에 개발 전반적으로 함수형 패러다임이 도입되었을 때 잘 사용하려면 미리 배워놓는게 중요하다.
함수형 패러다임의 중요한 포인트는 언어가 점점 발달하고 하드웨어의 스펙이 받쳐주면서 자바의 가비지 컬렉션처럼 메모리 관리를 추상화해주는 언어가 발달하게 되었다는 점이다.
따라서 개발자들이 저수준의 메모리 관리에서 해방되고, 복잡한 비즈니스 문제를 해결하는데 많은 시간을 쏟을 수 있게 되었다.
이렇게 자바가 메모리 문제를 해결해 준 것처럼 함수형 프로그래밍 언어는 세부적인 코드들을 높은 수준의 추상화된 코드로 구현할 수 있게 해준다.
함수형 프로그래밍의 필요성
링크드인과 같은 실리콘밸리 회사에서는 Java Stream을 모르면 자바 개발자로서 하루도 일을 할 수 없을 것이라고 한다.
함수형 프로그래밍 언어의 핵심 원리
1. 일급 및 고차 함수
함수를 변수에 저장할 수 있다.
함수는 함수의 리턴값이 될 수 있다. -> 고차함수
함수는 함수의 파라미터로 전달할 수 있다. -> 동작 파라미터화
위 글에서 파이썬의 일급함수 개념에 대해서 확인할 수 있다.
반면에 Java에서는 대부분의 함수가 일급함수가 아니다.
Java8 이후로 함수형 인터페이스를 지원하는데, 이 속성을 사용하면 Java의 함수를 일급함수, 고차함수로 사용 가능하다.
@FunctionalInterface
public interface Predicate<T> {
boolean test(T t);
}
Predicate<T> 라는 인터페이스가 있다.
이 인터페이스는 test(T t) 라고 불리는 단 하나의 추상 메서드만을 가지고 있다.
이처럼 특정 인터페이스가 단 하나의 추상 메서드를 가지고 있는 경우, 이를 함수형 인터페이스라고 부를 수 있다.
해당 메서드를 구현하면(람다, 익명 클래스 등을 사용해서) 함수를 파라미터로 넘기거나 리턴값 등으로 사용하는 효과를 얻을 수 있다.
결국 자바 인터페이스의 도움을 받긴 하지만 람다 같은 기능을 사용해서 함수형으로 구현할 수 있기 때문에 비즈니스 로직을 간결한 코드로 표현할 수 있다.
함수형 인터페이스를 인자로 넘겨주는 간단한 예제 -> 동작 파라미터화
public static List<Apple> filterApples(List<Apple> inventory, Predicate<Apple> p) {
List<Apple> result = new ArrayList<>();
for (Apple apple : inventory) {
if (p.test(apple)) {
result.add(apple);
}
}
return result;
}
filterApples 라고 불리는 List 를 반환하는 메서드가 있다.
1. Apple 객체에 대한 리스트(inventory)와 함수형 인터페이스(Predicate<Apple>)를 인자로 받는다.
2. inventory를 순회하면서 함수형 인터페이스의 test 메서드(T -> boolean)를 수행한다.
(T -> boolean) : (제네릭 T 타입 객체 하나를 인자로 받아서 boolean을 반환하는 함수)
filterApples의 내부 구현을 살펴보았다.
그럼 Java에서 filterApples 함수를 어떻게 사용할 수 있을까?
filterApples(inventory, apple1 -> RED.equals(apple1.getColor()));
위 처럼 함수형 인터페이스에 대한 값으로 람다식을 전달할 수 있다.
이처럼 filterApples 함수 내부에서 수행할 '동작'을 넘겨줄 수 있다.
위 코드는 apple1의 color가 RED 이면 true를 반환하는 동작을 람다 표현식으로 넘겨준 모습이다.
이를 '동작 파라미터화' 라고 부를 수 있다.
동작 파라미터화를 사용함으로써 얻게 되는 장점은 다음과 같다.
- 아직 어떻게 실행할 것인지 정해지지 않은 코드 블럭을 작성할 수 있다.
- 자주 바뀌는 요구사항에 대해서 효과적으로 대응할 수 있다.
2. 순수 함수
순수 함수는 자신이 받는 인자를 기반으로 값을 반환해야 하며 함수 호출에 따른 부수 효과(side effect)가 없어야 함을 강조한다.
따라서 부수효과가 있는 함수는 순수함수라고 부를 수 없다.
순수함수에 대한 정의는 간단하다.
그렇다면 어떤 함수가 순수함수가 될 수 없는지 몇 가지 예시를 알아보자.
순수함수가 될 수 없는 함수의 특징
1. 입력 파라미터를 수정하는 함수
순수함수는 동일한 입력에 대해서 항상 동일한 결과를 리턴해야 한다.
하지만 동일한 파라미터를 받지만 내부에서 파라미터가 수정되는 코드가 있다면 서로 다른 결과를 리턴할 수 있다.
2. 전역 변수 또는 함수 외부의 어떤 값을 수정하는 함수
함수의 실행으로 인해 외부의 값이 수정되는 상황을 side effect가 발생했다고 할 수 있다.
즉, 파일 입출력, 데이터베이스 수정, 네트워크 통신, 소켓 연결 등의 작업을 수행하는 함수들은 모두 순수함수가 아니다.
3. void를 리턴하는 함수
아무런 결과를 반환하지 않는 함수는 순수함수로서의 자격이 없다.
4. 매개변수를 전혀 받지 않는 함수
입력이 없으면 리턴할 값에 대한 기준이 없다.
3. 불변성
불변성은 엔티티를 인스턴스화한 후에는 수정할 수 없다는 속성을 의미한다.
불변성을 보장하기 위해서 지켜야 하는 규칙
- 불변 데이터 클래스의 모든 필드는 불변이어야 한다.
- 컬렉션에 포함된 모든 요소에 대해 적용되어야 한다.
- 초기화를 위한 하나 이상의 생성자가 있어야 한다.
Java 에서 불변성을 보장하기 위해서 사용하는 키워드는 final 이다.
final 키워드의 장점
- 명시적으로 값을 변경하지 않도록 표시해주는 역할을 할 수 있다.
- 다른 프로그래머에 의해 값이 바뀐 경우 컴파일 타임에서 에러를 잡을 수 있다.
이후 람다를 다루면서 final을 사용하는 중요한 이유에 대해서 알아볼 예정이다.
마지막으로 Java 8 이후로 도입된 Stream에 대해서 간단하게 살펴보고,
함수형 패러다임이 어떻게 사용되는지 감각을 익혀보자.
Stream
Stream은 한 번에 한 개씩 만들어지는 연속적인 데이터의 모음이다.
Java Stream은 컬렉션에서 원하는 값을 얻기 위해서 for문 도배하는 것을 막기 위해서 나왔다.
불필요한 코딩(for, if문법)을 지우고 흐름을 직관적으로 보여주므로, 코드의 양이 줄어들고 간결하게 표현할 수 있다.
스트림의 특징
1. 스트림은 자료구조가 아니며 Collection, Arrays 또는 I/O 채널로부터 입력을 받을 수 있다.
final List<String> stringList = new ArrayList<>();
stringList.add("hi");
stringList.add("ho");
stringList.add("hello");
final Stream<String> stream = stringList.stream();
ArrayList 라는 List 컬렉션 구현체를 통해서 Stream 객체를 생성할 수 있다.
2. 스트림은 데이터의 원본을 바꾸지 않는다. 메서드 파이프라인을 통해 가공된 새로운 결과를 제공할 뿐이다.
final List<String> stringList = new ArrayList<>();
stringList.add("hi");
stringList.add("ho");
stringList.add("hello");
final List<String> res = stringList.stream()
.map(s -> s + " new")
.collect(Collectors.toList());
위 코드에서 stringList는 stream으로 한 번 가공이 된다.
하지만 stringList 내부의 값들은 전혀 변하지 않는다.
원본에 대한 불변성이 적용된다.
즉, 외부의 상태는 바뀌지 않는다.
따라서 Stream의 결과는 순수함수들의 파이프라이닝을 통해 얻어진다고 볼 수 있다.
중간의 .map(s -> s + " new") 부분을 보자.
map 함수가 생소하겠지만 람다식을 넘겨받음이 보인다.
map 함수 내부에서 실행할 동작을 명시해 주었다고 볼 수 있는 것이다.
3. 스트림 객체는 일회용이다.
final List<String> stringList = new ArrayList<>();
stringList.add("hi");
stringList.add("ho");
stringList.add("hello");
Stream<String> stream = stringList.stream();
stream.forEach(System.out::println);
final List<String> res = stream.map(s -> s + " new").collect(Collectors.toList());
res.forEach(System.out::println);
stream 객체는 forEach 메서드를 통해서 한 차례 소비가 된다.
이렇게 소비된 stream 객체는 이후에 재사용할 수 없다.
이미 사용된 stream 객체에 다시 접근하는 경우 IllegalStateException 이 발생하게 되고
stream has already been operated upon or closed 라는 에러 메시지가 발생한다.
4. 내부 반복을 사용하므로 코드가 간결해진다.
strings.forEach(System.out::println);
기존의 for과 while 문이 forEach같은 함수들의 내부에 숨겨져 있기 때문이다.
Stream API로 구성된 파이프라인속 작업들은 Lazy하게 처리될 수 있고, 다양한 작업을 병렬성을 사용해 처리하기 편하다.
filter, map, reduce 와 같은 일반적인 함수형 프로그래밍 언어에서 사용하는 고차함수를 대부분 API로 제공한다.
Ref
'Lang > Java' 카테고리의 다른 글
전략 패턴 (Strategy Pattern) (3) | 2022.10.04 |
---|---|
[Java] Lambda 특징과 활용 (1) | 2022.10.03 |
[디자인 패턴] 싱글톤 패턴 (Creational) (0) | 2022.01.10 |
[Java] Runnable과 Thread의 차이 (0) | 2021.12.11 |
[Java] 추상 클래스와 인터페이스 (abstract class & interface) (0) | 2021.10.13 |