2024. 1. 20. 20:33ㆍJava
1. 람다와 스트림을 제공하게 된 이유?
자바 8은 간결한 코드와 멀티코어 프로세서의 쉬운 활용이라는 요구사항을 기반으로 만들어졌다. 간결한 코드를 제공하고 멀티코어 프로세서에 대한 대안으로 람다와 스트림이라는 것을 도입하였다.
간결한 코드는 람다를 통해서 이루어진다. 자바 8 이전에는 익명 클래스를 이용해서 동작의 파라미터화를 구현해왔다. 하지만 익명 클래스를 이용하는 경우 불필요한 중첩이 늘어나 가독성이 높지 않다. 예를 들어, 쓰레드를 Runnable로 만들었을 대 코드를 다음과 같이 작성할 수 있다. 확실히 익명 클래스를 사용할 때보다 가독성이 높아진 걸 확인할 수 있다.
public void anonymousClass() {
Runnable r = new Runnable() {
@Override
public void run() {
System.out.println("익명클래스를 이용한 방법");
}
};
}
public void lambda() {
Runnable r = () -> System.out.println("람다를 이용한 방법");
}
또한 자바 8에서는 메서드와 람다를 일급 객체로 변환 시켰다. 일급 객체란 아래의 3개의 조건을 만족하는 객체를 의미한다.
○ 변수나 데이터를 할당할 수 있어야 한다.
○ 객체의 인자로 넘길 수 있어야 한다.
○ 객체의 리턴 값으로 리턴할 수 있어야 한다.
기존에는 String, Integer 같은 것들만 해당이 되었었다. 메서드와 클래스는 위의 조건을 만족하지 못했다. 하지만 자바 8에서는 메서드를 파라미터화 시킴으로써 일급 객체로 올려놓았다. 이로 얻는 장점은 동작의 파라미터화에 관해 이야기할 때 함께 이야기하도록 하겠다.
멀티코어 프로세싱을 위해 멀티 스레드 환경에서 병렬 처리를 하는 것은 쉽지 않다. 정확하게 데이터를 나누어 주어야하고 그걸 다시 합칠 수 있게 만들어 주어야 하며, 공유되는 한 구조에 접근해야 하는 경우에는synchronized를 통해 동시성 제어도 고려해야 한다. 하지만, 스트림 API에서는 parallelStream을 통해 개발자가 이에 관한 고려를 하지 않고도 병렬처리를 할 수 있게 돕는다. 이 때 무엇을 주의해야 하는지 또한 이후의 글에서 알아볼 예정이다.
2. 동작의 파라미터화를 통한 코드 작성
모던 자바 인 액션 책을 구입했던 이유는 자바의 정석에 적힌 이 내용이 잘 이해가 되지 않았기 때문이다. 뭔가 설명이 부족했다. 하지만 이제는 이해가 되었다. 간단하게 말하면, 일급 객체가 돼 파라미터화된 메서드에 대한 기본 사용방법을 제공한 것이라고 생각한다. 기본적으로 자바에서 제공하는 Predicate, Consumer, Function이 기능이 충분하다면, 더 필요없고 아니라면 사용자가 직접 인터페이스 정의를 통해 새로 만들어주면 된다.
책의 예시를 약간 바꾸어 보았다. 왜냐하면, 코드가 많기 때문이다. 이제는 사과를 선별할 것인데, 사과에는 색상과 무게 필드가 존재한다. 색상을 String 혹은 Integer로 표현하는 경우, 유연하게 코드를 작성할 수 없으므로 Color로 Enum 타입으로 추상화 시켰다.
// 생성자, getter, setter 생략
public static class Apple {
private int weight = 0;
private Color color;
}
// 색상
public enum Color {
GREEN, RED
}
이 때, 사과를 선별하려고 for문을 돌리면 다음과 같이 코드를 작성할 수 있다. 아래의 코드의 문제점은 가독성이 낮다. 그리고 재사용성이 낮다. 재사용성이 낮다는 것은 새로운 요구사항이 들어올 때마다 계속해서 메서드를 만들어야 한다. 만약에 사과에 새로운 필드가 추가되고 새로운 선별 기준이 추가되면? 메서드가 추가되어야 한다. 또 복합 조건이 있는 경우에도 기존의 메서드를 재활용하지 못하고 파라미터를 추가해서 다시 작성해야 한다.
public static List<Apple> filterApplesByColor(List<Apple> inventory, Color color) {
List<Apple> result = new ArrayList<>();
for (Apple apple: inventory) {
if (apple.getColor().equals(color)) { // 차이가 일어나는 부분
result.add(apple);
}
}
return result;
}
public static List<Apple> filterApplesByWeight(List<Apple> inventory, int weight)
{
List<Apple> result = new ArrayList<>();
for (Apple apple: inventory) {
if (apple.getWeight() > weight) { // 차이가 일어나는 부분
result.add(apple);
}
}
return result;
}
하지만 Predicate라는 추상화된 동작 파라미터를 사용해 추가될 동작을 외부로부터 받아쓸 수 있다. 마치 코드 자체는 프록시 패턴으로 추상 클래스에 외부 기본 동작을 넣어놓고 execute를 작성하면 공통 로직 처리를 할 수 있는 것과 비슷하다. 대신 동작은 마치 전략 패턴처럼 원하는 전략을 선택해서 넣을 수 있게 해준다. 그런데, 상속이나 그런 게 전혀 없다.
public List<Apple> filterApplesByPredicate(List<Apple> inventory, Predicate p) {
List<Apple> result = new ArrayList<>();
for (Apple apple: inventory) {
if (p.test(apple)) {
result.add(apple);
}
}
return result;
}
// 실제 사용코드
Predicate<Apple> redApplePredicate = (apple) -> apple.getColor().equals(RED);
List<Apple> redApples = filterApples(inventory, redApplePredicate);
또한, Predicate에서 제공하는 and나 or 메서드를 통해 결합을 할 수도 있다. 만약의 아래의 코드에서 개별 요소를 결합할 수 있지 않게 만들었다면, 조건이 늘어남에 따라 지수적으로 가짓수가 늘어 유지보수하기 어려운 코드가 되었을 것이다.
List<Apple> inventory = Arrays.asList(
new Apple(80, GREEN),
new Apple(155, GREEN),
new Apple(120, RED)
);
Predicate<Apple> greenApplePredicate = (apple) -> apple.getColor().equals(GREEN);
Predicate<Apple> heavyApplePredicate = (apple) -> apple.getWeight() >= 150;
List<Apple> heavyGreenApples = inventory.stream()
.filter(greenApplePredicate.and(heavyApplePredicate)).toList();
System.out.println("heavyGreenApples = " + heavyGreenApples);
3. 자바에서 지원하는 동작 파라미터를 위한 함수형 인터페이스 종류
자바에서 기본적으로 제공하는 인터페이스가 있는데, 이를 이야기하기 전에 람다 예제에 관해서 조금 알아보면 사례와 인터페이스가 연결이 된다. 모던 자바 인 액션에 있는 람다 예제를 그대로 가져왔다.
사용 사례 | 람다 예제 |
불리언 표현식 | (List<String> list) -> list.isEmpty() |
객체 생성 | () -> new Apple(10) |
객체에서 소비 | (Apple a) -> { System.out.println(a.getWeight()); } |
객체에서 선택/추출 | (String s) -> s.length() |
두 값을 조합 | (int a, int b) -> s.length() |
두 객체 비교 | (Apple a1, Apple a2) -> a1.getWeight().compareTo(a2.getWeight()) |
대충 위의 예시에 대해 이해가 됐다면 인터넷에서 람다와 스트림을 치면 나오는 아래의 예시가 각각 어디에 대응되는 지 알 수 있다.
함수형 인터페이스 | 함수 디스크럽터 | 기본형 특화 |
Predicate<T> | T -> boolean | IntPredicate, LongPredicate, DoublePredicate |
Consumer<T> | T -> void | IntConsumer, LongConsumer, DoubleConsumer |
Function<T, R> | T -> R | IntFunction<R>, IntToDoubleFunction |
Supplier<T> | T => T | BooleanSuppier, IntSupplier |
더 많은 종류들이 있지만 일부만 적은 이유는 모든 내용을 알 필요 없이 찾아보면 된다고 생각했기 때문이다. 단지, 이런 인터페이스가 어떤 역할을 하고 인터페이스를 제공함으로써 사용자가 어떤 이점을 얻을 수 있는지만 이해한다면 충분하다고 생각한다.
[인터페이스를 제공하면서 얻을 수 있는 이점?]
위에서 적은 Predicate, Consumer 같은 함수형 인터페이스를 사용하지 않고도 코드를 작성할 수 있다. 하지만, 개인적인 의견으로 되도록이면 함수형 인터페이스를 하는 게 더 효율적이라고 생각한다. 그 이유는 위에서 이미 언급했던 Predicate 예시와 책의 ApplePredicate 예시를 좀 더 자세하게 보면 이해할 수 있다.
ApplePredicate 인터페이스를 정의한 아래의 코드의 단점은 필터링 조건을 추가하려 할 때 발생한다. greenApple에 무게 조건을 추가하려고 체이닝 비슷하게 흉내를 내보았다. 그 코드를 보면 지금 객체를 다시 filter 내부에 넣고 새로운 필터 조건을 추가하고 있다. 이렇게 구현하면 필터링 조건이 많아질 수록 더 가로로 코드가 훨신 길어질 것이다.
public static List<Apple> filter(List<Apple> inventory, ApplePredicate p){
List<Apple> result = new ArrayList<>();
for(Apple apple : inventory){
if(p.test(apple)){
result.add(apple);
}
}
return result;
}
interface ApplePredicate{
boolean test(Apple a);
}
// 책에서 직접 ApplePredicate 인터페이스를 정의해 필터링하는 부분
List<Apple> greenApples = filter(inventory, (Apple a)-> a.getColor() == Color.GREEN);
System.out.println(greenApples);
// 간단하게 체이닝이 불가능함
List<Apple> heavyGreenApples = filter(filter(inventory, (Apple a)-> a.getColor() == Color.GREEN)
, (Apple a)-> a.getWeight() >= 180);
System.out.println(heavyGreenApples);
하지만 아래처럼 JAVA에서 제공하는 Predicate를 사용해, 메서드 추출 등을 사용하여 적절한 명칭을 골라 and나 or 조건을 체이닝으로 넣는다면 의도가 분명하게 코드를 작성할 수 있다. 그리고 filter 내부에 조건이 추가되어도 불필요하게 가로로 길어지는 현상도 막을 수 있음을 확인할 수 있다.
List<Apple> inventory = Arrays.asList(
new Apple(80, GREEN),
new Apple(155, GREEN),
new Apple(120, RED)
);
Predicate<Apple> greenApplePredicate = (apple) -> apple.getColor().equals(GREEN);
Predicate<Apple> heavyApplePredicate = (apple) -> apple.getWeight() >= 150;
List<Apple> heavyGreenApples = inventory.stream()
.filter(greenApplePredicate
.and(heavyApplePredicate)).toList();
System.out.println("heavyGreenApples = " + heavyGreenApples);
또한, Function의 경우에는 함수의 순서를 정할 수 있는 andThen으로 체이닝이 가능한데, 명칭에 알맞게 and 앞의 함수가 먼저 실행되고 그 다음에 Then 뒤의 함수가 실행된다. 아래의 예시를 직접 돌려보면 쉽게 이해할 수 있을 것이라 생각한다.
Function<Integer, Integer> multiply_3 = x -> 3 * x;
Function<Integer, Integer> add_2 = x -> x + 2;
int result = multiply_3.andThen(add_2).apply(1);
그래서, 나의 결론은 최대한 인터페이스 상에서 추가적으로 제공해주는 기능이 많으니 기본형을 사용하고, 특히나 특화 스트림의 경우에는 박싱과 언박싱 비용을 줄여주니 잘 활용하는 게 중요하다 생각이 된다.
[ 참고 자료 ]
모던자바인액션 - 라울-게이브리얼 우르마, 마리오 푸스코, 앨런 마이크로프트
'Java' 카테고리의 다른 글
자바의 예외 계층 - 체크 예외와 언체크 예외 (0) | 2024.02.26 |
---|---|
Spring JDBC로 알아보는 예외 덩어리 처리 방법 (0) | 2024.02.22 |
static initializer block와 initializer block (0) | 2024.01.19 |
static 키워드 (0) | 2024.01.17 |
자바의 캡슐화 - Private, Protected, Default, Public (0) | 2024.01.10 |