코드의 여백

[이펙티브 자바] 람다의 우아함과 Stream의 주의사항

by rowing0328

※ 책 내용을 바탕으로 제 관점에서 풀어쓴 글입니다. 일부 내용이 다를 수 있습니다.

 

아이템 42 - 익명 클래스보다는 람다를 사용하라

 

자바 8, 그리고 람다

자바 8에서는 함수형 프로그래밍의 도입으로 인해,

작은 함수 객체를 구현하기에 람다(lambda)가 도입되었다.

 

람다는 함수형 인터페이스를 지향하며,

코드의 간결함과 가독성을 높이는데 초점을 맞춘다.

 

특히, 타입을 명시해야 코드가 더 명확해지는 상황을 제외하고는,

람다의 매개변수 타입은 생략하는 것이 좋다.

 

이는 컴파일러가 타입을 추론할 수 있는 환경을 만들어주기 때문이다.

 

익명 함수와 람다의 비교

기존의 익명 클래스와 람다를 비교하여 람다의 장점을 살펴보자.

 

예제 코드

// 익명 클래스
List<String> words = List.of("사과", "배", "바나나");

Collections.sort(words, new Comparator<String>() {
    @Override
    public int compare(String o1, String o2) {
        return Integer.compare(o1.length(), o2.length());
    }
});

// 람다 사용
words.sort(Comparator.comparingInt(String::length));

 

설명

람다를 사용하면 코드의 간결함과 가독성이 크게 향상된다.

특히, 위 예제처럼 Comparator와 같은 함수형 인터페이스를 사용할 때는 람다의 효과가 극대화된다.

 

람다 사용 시 주의점

람다는 간결한 만큼 복잡한 로직을 담아서는 안된다.

 

람다 내부에 여러 줄의 코드가 포함되면 오히려 가독성이 떨어질 수 있으므로,

명확하고 간단한 작업에만 사용해야 한다.

 

언제 익명 클래스를 사용할까?

  • 함수형 인터페이스가 아닌 경우
    람다는 함수형 인터페이스(단일 추상 메서드)를 구현할 때만 사용할 수 있다.
  • 람다로 표현하기 어려운 상태를 가진 구현이 필요한 경우
    예를 들어, 특정 상태를 유지해야 하는 로직이 포함된 경우 익명 클래스가 더 적합하다.

 

 

아이템 43 - 람다보다는 메서드 참조를 사용하라

 

메서드 참조와 람다

자바 8이 도입되면서 함수형 프로그래밍 요소가 추가되어,

개발자는 코드를 더욱 간결하게 작성할 수 있게 되었다.

 

이번에는 메서드 참조(Method Reference)와 람다 표현식(Lambda Expression)을 비교하며,

각각의 장점과 활용 방법을 알아보자.

 

메서드 참조 유형

메서드 참조는 기존 메서드를 직접 참조하여 람다 표현식의 대안으로 사용할 수 있다.

 

아래는 주요 유형과 대응하는 람다 표현식의 예시다 :

메서드 참조 유형 같은 기능을 하는 람다
정적 메서드 참조 Integer::parseInt str -> Integer.parseInt(str)
한정적(인스턴스) 참조 Instant.now()::isAfter t -> then.isAfter(t)
비한정적(인스턴스) 참조 String::toLowerCase str -> str.toLowerCase()
클래스 생성자 참조 TreeMap<K,V>::new () -> new TreeMap<K,V>()
배열 생성자 참조 int[]::new len -> new int[len]
  • 이처럼 메서드 참조는 람다 표현식을 더욱 간단하게 표현할 수 있는 대안이다.

 

메서드 참조의 장점

  • 간결성
    메서드 참조는 간단명료하여, 코드의 가독성을 향상시킨다.
  • 명확성
    코드의 의도가 분명히 드러나며, 함수의 역할을 직관적으로 이해할 수 있다.
  • 재사용성
    기존에 정의된 메서드를 활용하므로 중복 코드를 줄일 수 있다.

 

 

아이템 44 - 표준 함수형 인터페이스를 사용하라

 

@FunctionalInterface란

@FunctionalInterface는 특정 인터페이스가 함수형 인터페이스임을 명시적으로 선언하기 위한 애노테이션이다.

이는 우리가 이전에 다뤘던 @Override 애노테이션과 비슷한 역할을 하며, 컴파일러가 함수형 인터페이스로서의 규칙을 강제하도록 돕는다.

 

주요 규칙

  • 반드시 하나의 추상 메서드만 포함할 수 있다.
  • 이는 해당 인터페이스가 람다식과 호환되도록 보장한다.
  • 다만, 기본 메서드(default method)는 여러 개 포함될 수 있다.

 

표준 함수형 인터페이스

자바에서는 대표적인 표준 함수형 인터페이스들을 제공한다.

 

  • UnaryOperator
    입력값 하나를 받아 동일한 타입의 결과를 반환한다.
  • BinaryOperaor
    두 입력값을 받아 동일한 타입의 결과를 반환한다.
  • Predicate
    입력값 하나를 받아 boolean을 반환한다.
  • Function<T, R>
    입력값을 받아 다른 타입의 결과를 반환한다.
  • Supplier
    값을 반환하며, 입력값이 필요하지 않다.
  • Consumer
    값을 받아 처리하며 반환값이 없다.

 

이러한 인터페이스들은 다양한 API 설계에서 활용될 수 있으며,

람다식 메서드 참조와 함께 사용하면 더욱 간결한 코드를 작성할 수 있다.

 

언제 함수형 인터페이스를 만들어야 할까?

  • 이름 자체가 용도를 명확히 설명하는 경우
  • 기존 표준 함수형 인터페이스로는 충분하지 않을 때
  • 특정 기능과 동작 방식이 명확히 정의된 경우

 

그러나 필요 이상으로 새로운 함수형 인터페이스를 만드는 것은 권장되지 않는다.

자바가 제공하는 표준 인터페이스를 최대한 활용하는 것이 코드의 유지보수성을 높이는 데 유리하다.

 

 

아이템 45 - 스트림은 주의해서 사용하라

 

스트림을 사용하지 말아야 하거나 사용할 수 없는 상황

  • 컨트롤 흐름이 중요한 경우
    예 : return, break, continue와 같은 제어 흐름을 사용해야 할 때 스트림은 적합하지 않다.
    스트림 내부에서는 이러한 제어 흐름을 구현하기 어렵고, 코드의 가독성이 떨어질 수 있다.
  • 순수 함수형 패러다임을 벗어나는 경우
    스트림 내에서 지역 변수를 변경하거나, 외부 상태를 조작해야 한다면 스트림은 피해야 한다.
int a = 1;
List.of("사과", "배").stream().filter(str -> {
    if (str.equals("배")) {
        a = 2; // 외부 변수 수정 - 금지
    }
    return true;
});

 

스트림을 사용해야 할 때

원소의 변환

  • 예 : List<ItemInfo>를 List<String>으로 변환할 때
list.stream().map(ItemInfo::getName).collect(Collectors.toList());

 

필터링

  • 특정 조건에 맞는 데이터를 필터링 할 때
list.stream().filter(item -> item.isActive()).collect(Collectors.toList());

 

집계 연산

  • 데이터의 합계, 최소값, 최대값 등을 구할 때
int sum = list.stream().mapToInt(Item::getPrice).sum();

 

컬렉션 변환

  • 데이터를 다른 컬렉션으로 반환할 때
list.stream().collect(Collectors.toSet());

 

특정 조건 만족 여부 검사

  • 데이터 중 특정 조건을 만족하는 첫 번째 요소 찾기
list.stream().findFirst();

 

스트림 방식과 반복 방식 비교

반복 방식

반복문을 사용한 데이터 처리 방식은 명시적이고 직관적이지만, 코드가 장황해질 수 있다.

public static List<Integer> minFive(List<Integer> integerList) {
    List<Integer> returnList = new ArrayList<>();
    List<Integer> copiedList = new ArrayList<>(integerList);
    Collections.sort(copiedList);
    
    for (int i = 0; i < copiedList.size(); i++) {
        if (i == 5) break;
        returnList.add(copiedList.get(i));
    }
    
    return returnList;
}

 

스트림 방식

스트림을 활용하면 코드를 간결하게 작성할 수 있다.

public static List<Integer> minFiveExtend(List<Integer> integerList) {
    return integerList.stream()
                      .sorted()
                      .limit(5)
                      .collect(Collectors.toList());
}

 

 

아이템 46 - 스트림에서는 부작용 없는 함수를 사용하라

 

Stream은 순수 함수여야 한다

Java Stream API는 순수 함수형 프로그래밍의 개념을 차용했다.

 

순수 함수란 오직 입력값에만 의존하며,

결과값을 반환할 뿐 다른 외부 상태에 영향을 주지 않는 함수를 말한다.

 

Stream을 사용할 때도 이러한 순수성을 유지해야 코드의 예측 가능성과 유지보수성이 높아진다.

 

순수 함수의 특징
오직 입력값만 결과값에 영향을 미친다.

다른 상태를 참조하거나 변경하지 않는다.

 

다음은 순수하지 않은 방식으로 Stream을 사용한 사례

  • 아래 코드는 외부 리스트 returnList를 Stream 내부에서 조작하고 있어 부작용이 발생한다.
public static List<Integer> integerSort(List<Integer> integerList) {
    List<Integer> returnList = new ArrayList<>();
    
    integerList.stream().sorted().forEach(num -> {
        returnList.add(num); // 외부 리스트 상태 변경
    });
    
    return returnList;
}

 

올바른 코드의 예시

  • 아래 코드는 Stream의 결과를 바로 반환하며, 외부 상태를 변경하지 않아 순수성을 유지한다.
public static List<Integer> integerSort(List<Integer> integerList) {
    return integerList.stream()
                      .sorted()
                      .collect(Collectors.toList()); // Collectors 사용
}

 

Stream에서 자주 사용되는 Collector 메서드

Stream API의 다양한 Collector 메서드를 활용하면 데이터를 효율적으로 처리할 수 있다.

 

  • toMap
    데이터를 키-값 쌍으로 매핑할 때 사용한다.
public static Map<Long, String> getHeightGroup(List<User> userList) {
    return userList.stream()
                   .collect(Collectors.toMap(User::getId, User::getName));
}
  • groupingBy
    데이터를 특정 기준으로 그룹화할 때 유용하다.
public static Map<Integer, Long> getHeightGroup(List<User> userList) {
    return userList.stream()
                   .collect(Collectors.groupingBy(
                       user -> (int) user.getHeight() / 5 * 5, 
                       Collectors.counting()
                   ));
}

 

 

아이템 47 - 반환 타입으로는 스트림과 컬렉션이 낫다.

 

과거 Iterator를 사용했던 시절

과거에는 데이터를 반복해서 처리할 때 Iterator를 활용하는 방식이 일반적이었다.

 

Iterator는 데이터를 순회하기에 적합한 도구이지만,

Stream의 등장으로 코드의 가독성과 유지보수성이 크게 향상되었다.

 

예를 들어, 아래와 같은 Iterator 사용 방식은 반복적인 작업에서는 적합하지만, 코드가 장황해질 수 있다.

List<Integer> list = List.of(1, 2, 3);
Iterator<Integer> iterator = list.iterator();
while (iterator.hasNext()) {
    System.out.println("value = " + iterator.next());
}

 

Stream과 Iterable의 관계

Stream은 Iterable을 확장하지 않기 때문에,

기본적으로 for-each 문으로 반복할 수 없다.

 

이를 해결하기 위해 Stream과 Iterable 사이의 어댑터를 정의할 수 있다.

 

Stream -> Iterable 변환

  • Stream을 Iterable로 변환하면 for-each 문에서 사용할 수 있다.
public static <E> Iterable<E> iterableOf(Stream<E> stream) {
    return stream::iterator;
}

 

Iterable -> Stream 변환

  • 반대로, Iterable을 Stream으로 변환하면 Stream의 장점을 활용할 수 있다.
public static <E> Stream<E> streamOf(Iterable<E> iterable) {
    return StreamSupport.stream(iterable.spliterator(), false);
}

 

반환 타입으로 스트림과 컬렉션 중 무엇을 선택하는가

  • Stream
    Stream Pipeline에서 데이터를 처리할 때 적합합니다.
    복잡한 연산이 많을 경우 Stream을 반환하는 것이 유용합니다.
  • Iterable
    단순히 반복문에서 사용할 경우 Iterable 반환이 적합합니다.

 

권장 사항

  • 컬렉션으로 반환할 수 있다면, 컬렉션을 반환하라.
  • 불가능한 경우에만 Stream 또는 Iterable 중 적합한 타입을 반환하라.

 

 

아이템 48 - 스트림 병렬화는 주의해서 적용하라

병렬 스트림(paraller stream)은 여러 코어를 활용하여 작업을 병렬적으로 처리할 수 있게 설계된 기능이지만,

모든 상황에서 성능을 보장하거나 효율적이지 않다.

 

병렬 스트림의 제한점

단순히 빠르다고 생각하지 말자

병렬 스트림이라고 무조건 단일 스트림보다 빠른 것은 아니다.

병렬 처리에는 스레드 간 통신과 작업 분배, 병합 비용이 추가로 발생한다.

특히, 데이터 양이 적거나, 연산이 간단한 경우 병렬 처리가 오히려 단일 스트림보다 느릴 수 있다.

 

데이터 분배와 처리 균형

병렬 처리 시 데이터가 균등하게 나뉘지 않으면, 특정 스레드가 과도하게 부하를 받게 된다.

이는 병렬 처리의 효율성을 낮추는 주요 원인이다.

 

외부 API 호출 시 주의

병렬 스트림 내부에서 외부 시스템 호출(예: 네트워크 I/O)을 사용하는 경우,

예상치 못한 지연이나 병목현상이 발생할 수 있다.

 

데이터 구조의 선택이 중요

ArrayList와 같은 데이터 구조는 병렬 처리에서 좋은 성능을 내지만, LinkedList는 그렇지 않다.

데이터 구조의 특성을 고려하여 병렬 스트림을 적용해야 한다.

 

ForkJoinPool과 병렬 스트림

병렬 스트림은 기본적으로 ForkJoinPool을 사용하여 작업을 병렬 처리한다.

ForkJoinPool은 divide-and-conquer 방식으로 작업을 분할하고 병합하여 병렬 작업을 수행한다.

그러나 ForkJoinPool을 과도하게 활용하면 전반적인 시스템 성능에 영향을 줄 수 있다.

 

ForkJoinPool 커스터마이징

기본 ForkJoinPool을 커스터마이징하는 방법도 존재하지만, 추천되지 않는다.

ForkJoinPool의 동작을 변경하면 예기치 못한 부작용(Side Effects)을 유발할 수 있다.

예를 들어, 시스템 전체의 병렬 작업 성능이 저하될 수 있다.

 

병렬 스트림 사용 시 체크리스트

  • 측정과 검증 필수
    병렬 스트림이 실제로 성능을 향상시키는지 반드시 벤치마킹하고 검증해야 한다.
  • 병렬 작업에 적합한 작업인지 점검
    병렬 작업에 적합하지 않은 경우 오히려 성능 저하를 초래할 수 있다.
    예를 들어, 정렬, 그룹화와 같은 작업은 단일 스트림이 더 효율적일 수 있다.
  • 적합한 데이터 구조 선택
    병렬 스트림을 활용한 데이터 구조(ArrayList vs LinkedList)을 신중히 선택한다.
  • 작업의 비대칭성
    작업 분할이 비대칭적인 경우 병렬 스트림은 기대한 성능을 내지 못할 수 있다.
  • Side Effect 없는 코드 작성
    병렬 스트림 내부에서 외부 상태를 수정하거나 의존하는 코드는 피해야 한다.

 

병렬 스트림은 강력한 도구지만,

잘못 사용하면 오히려 성능을 저하시킬 수 있다.

 

병렬 스트림을 적용하기 전,

위의 체크리스트를 참고하여 신중히 결정한다.

 

관련 예제 코드

public Integer parallelExpSum(List<Integer> inputList, int minValue) {
    return inputList.stream()
                    .parallel()
                    .filter(input -> input >= minValue)
                    .mapToInt(input -> input * input)
                    .sum();
}

// ForkJoinPool을 활용한 병렬 처리
public Integer parallelExpSumCustomPool(List<Integer> inputList, int minValue) throws ExecutionException, InterruptedException {
    ForkJoinPool forkJoinPool = new ForkJoinPool(5);
    return forkJoinPool.submit(() ->
            inputList.stream()
                     .parallel()
                     .filter(input -> input >= minValue)
                     .mapToInt(input -> input * input)
                     .sum()
    ).get();
}

 

 

마무리

이번 아이템을 통해 스트림의 강력한 기능과 그에 따른 주의점들을 다시 한번 되새기게 되었다.

 

스트림은 데이터가 많을 때 유용하다는 일반적인 인식을 가지고 있었지만,

실제로는 그 활용 상황에 따라 장단점이 극명히 갈린다는 점을 깨달았다.

 

데이터를 가공하거나 집계할 때는 스트림이 명확한 장점을 제공하지만,

단순히 데이터를 소비하거나 출력하는 경우에는 forEach와 같은 반복문이 더 적합할 수 있다는 점을 경험하며 반성하게 되었다.

 

모든 도구에는 적합한 용도가 있다는 사실을 간과했던 것 같다.

 

결국 중요한 것은 도구를 상황에 맞게 사용하고,

그것이 실제로 가져올 성능이나 가독성을 꼼꼼히 검증하는 자세라는 점을 이번 학습을 통해 배울 수 있었다.

 

스트림의 장점을 극대화하면서도 단점에 휘둘리지 않는,

균형 잡힌 개발자로 성장하고 싶다는 생각이 든다.

블로그의 정보

코드의 여백

rowing0328

활동하기