Language/Java

[effective java] 아이템 47. 반환 타입으로는 스트림보다 컬렉션이 낮다.

JOYERIM 2023. 7. 3. 13:34

 

 

 

 

 

 

 

 

핵심 정리

 

자바에서는 Stream 또는 Iterable을 반환하는 API를 설계할 때 특정 패턴을 고려해야 한다. 

1. 어댑터의 사용

 

Stream을 Iterable로, 또는 Iterable을 Stream으로 변환하기 위한 어댑터 메서드가 필요하다. 이 어댑터를 이용하면 개발자는 Stream과 Iterable 간에 변환이 필요한 경우에 유연하게 대응할 수 있다. 그러나 어댑터의 사용은 코드를 복잡하게 만들고 성능을 저하시킬 수 있다. 따라서 불가피한 경우가 아니라면 이러한 변환은 최소화하는 것이 좋다.

2. Collection의 사용

 

원소 시퀀스를 반환하는 메서드를 작성할 때는 Stream과 Iterable을 모두 지원할 수 있도록 작성하는 것이 좋다. 가능하다면 Collection이나 그 하위 타입을 반환 타입으로 사용하는 것이 좋다. Collection 인터페이스는 Iterable을 상속하고 있고, 별도로 Stream을 생성하는 메서드도 제공하므로 이를 사용하면 두 가지 모두를 손쉽게 지원할 수 있다.

3. 전용 컬렉션의 사용

 

원소의 개수가 많은 경우에는 멱집합처럼 전용 컬렉션을 반환하는 방법을 고려해볼 수 있다. 이런 방식은 원소의 수가 많아도 효율적으로 데이터를 처리할 수 있게 해준다.

4. Stream 인터페이스의 변화에 대한 대비

 

만약 Stream 인터페이스가 미래에 Iterable을 구현하도록 변경된다면, 그때는 안심하고 Stream을 반환하도록 변경할 수 있다. 이는 이후의 변경에 대비하여 유연하게 대응할 수 있도록 한다. 

이렇게 몇 가지 패턴을 따르면 API 설계 시 유연하고 효율적인 코드를 작성할 수 있다.

 

 

 

 

 

 

 

 

 

 

 

자바 7 이전

 

컬렉션 인터페이스(Collection, Set, List 등)를 사용하여 일련의 데이터를 반환하거나, 특정 상황에서는 Iterable 인터페이스나 배열을 사용하였다.

 

1. 컬렉션 인터페이스

일반적으로 데이터의 일련을 반환하는 데 가장 적합하다. Iterable 인터페이스를 확장하므로 for-each 루프에서 사용될 수 있다.

 

2. Iterable 인터페이스

컬렉션의 특정 메소드를 구현할 수 없거나, 단순히 for-each 루프에서 사용하기 위해 선택된다.

 

3. 배열

기본 데이터 타입을 반환하거나, 성능이 중요한 경우 사용된다.

 

 

 

 

 

 

 

자바 8 이후
: 스트림 도입

 

스트림은 데이터 시퀀스를 표현하는 데 적합하나 반복(iteration)을 지원하지 않는다.

 

Stream 인터페이스는 Iterable 인터페이스를 확장하지 않는다. 즉, Stream 인터페이스는 Iterable 메소드인 iterable()를 직접적으로 제공하지 않는다. 그러므로 Stream 인스턴스를 직접 for-each 루프에 사용할 수 없다.

 

스트림을 반환하도록 만들면, for-each 루프를 사용하여 스트림을 반복하려는 사용자가 불만을 가질 수 있다. for-each 루프는 반복을 지원하지만 스트림은 지원하지 않기 때문이다.

 

더보기

Iterable은 외부 반복을 Stream은 내부 반복을 지원한다.

외부 반복은 개발자가 직접 컨트롤하는 반복이며, Iterable의 iterable() 메소드를 통해 가능하다.

내부 반복은 스트림이 직접 요소에 대한 연산을 처리하고 개발자는 결과를 제공받는 형태이다.

 

 

 

 

 

 

 

 

어댑터
1. Iterable 인터페이스

 

Iterable 인터페이스는 어떤 컬렉션의 원소들을 순회할 수 있는 iterator 메소드를 제공한다. for-each는 내부적으로 이 iterator 메소드를 사용하여 컬렉션의 원소들을 하나씩 가져온다.

Iterable<Integer> iterable = Arrays.asList(1, 2, 3, 4, 5);
for(Integer i : iterable) {
    System.out.println(i);
}

 

 

2. Stream 인터페이스 

 

Stream 인터페이스는 원소의 시퀀스를 표현하며, 이 원소들에 대한 계산을 수행하는 데에 주로 사용된다. 스트림 인터페이스는 Iterable 인터페이스를 상속하고 있지 않기 때문에, Iterable 인터페이스가 제공하는 iterator 메소드를 직접 사용할 수 없다. 따라서 for-each문으로 원소들을 순회하는 것이 바로 가능하지 않다.

Stream<Integer> stream = Stream.of(1, 2, 3, 4, 5);
for(Integer i : stream) {    // 컴파일 에러
    System.out.println(i);
}

 

 

이 문제를 해결하려면, 스트림을 iterable로 변환하는 어댑터를 만들어야 한다.

 

 

어댑터 패턴

 

어댑터 패턴은 한 인터페이스를 다른 인터페이스로 변환하는 디자인 패턴이다. 이 경우, 스트림을 iterable로 변환해주는 어댑터 메소드를 만든다. 이 어댑터 메소드는 스트림의 iterator 메소드를 이용해 iterable 객체를 만든다.

public static <E> Iterable<E> iterableOf(Stream<E> stream) {
    return stream::iterator;    // 스트림의 iterator 메소드를 이용한 이터러블 객체 생성
}


이제 이 어댑터 메소드를 이용하면, 원래는 이터러블이 아닌 스트림을 이터러블로 변환하여, for-each문을 이용해 순회할 수 있다.

Stream<Integer> stream = Stream.of(1, 2, 3, 4, 5);
for(Integer i : iterableOf(stream)) {
    System.out.println(i);
}

 

이번에는 반대로, Iterable 인터페이스를 Stream 인터페이스로 변환해야 하는 상황을 가정하자. 마찬가지로 어댑터 메소드를 만들어 해결하자. 이 어댑터 메소드는 Iterable 객체를 Stream 객체로 변환한다. 이를 위해 StreamSupport 클래스의 stream 메소드를 사용한다.

public static <E> Stream<E> streamOf(Iterable<E> iterable) {
       return StreamSupport.stream(iterable.spliterator(), false);
}


이 메소드는 두 가지 매개변수를 받는다: spliterator와 병렬 처리 여부이다.

spliterator 메소드는 데이터를 나눌 수 있는 반복자를 반환한다. 이를 통해 데이터를 여러 파트로 나누고, 각 파트를 독립적으로 처리할 수 있다. 

병렬 처리 여부 매개변수가 true일 경우, 스트림은 병렬 처리를 수행할 수 있다. 여기서는 false를 사용하므로 병렬 처리를 수행하지 않는다.

이렇게 어댑터를 사용하면, Iterable 인터페이스를 구현한 객체를 스트림으로 변환할 수 있다. 이로 인해 스트림이 제공하는 다양한 메소드를 사용할 수 있게 된다.

// Iterable 객체인 List를 Stream으로 변환하고, forEach 메소드를 사용하여 원소들을 출력
Iterable<Integer> iterable = Arrays.asList(1, 2, 3, 4, 5);
Stream<Integer> stream = streamOf(iterable);
stream.forEach(System.out::println);

 

 

 

 

 

 

 

 

 

Collection 인터페이스

 

Collection 인터페이스는 Iterable 인터페이스의 하위 타입이며, 스트림에 대한 메서드도 제공한다. 그래서 원소 시퀀스를 반환하는 공개 API의 반환 타입에는 Collection이나 그 하위 타입을 사용하는 것이 좋다.

Collection 인터페이스는 아래와 같이 정의되어 있습니다.

public interface Collection<E> extends Iterable<E> {
    // 중략
    default Stream<E> stream() {
        return StreamSupport.stream(spliterator(), false);
    }
    
    default Stream<E> parallelStream() {
        return StreamSupport.stream(spliterator(), true);
    }
}

 

Iterable 인터페이스를 확장하므로 for-each 문으로 순회가 가능하며, 또한 stream과 parallelStream 메서드를 통해 Stream 객체를 얻을 수 있다. stream 메서드는 병렬 처리를 하지 않는 스트림을, parallelStream 메서드는 병렬 처리를 하는 스트림을 생성한다.

 

 

원소의 크기가 큰 경우, 즉 반환할 시퀀스가 큰 경우에는 전용 컬렉션(표준 컬렉션 클래스를 직접 확장하지 않고, 컬렉션 인터페이스를 직접 구현한 클래스)을 구현할 수 있다.

 

예제 1 - 전용 컬렉션을 구현하여 멱집합을 구하는 프로그램


멱집합을 표현하는 방법으로 비트 벡터를 사용하는 방식이 있다. 비트 벡터란 0과 1로 이루어진 벡터로, 여기서는 각 원소가 원래 집합의 특정 원소를 포함하는지를 표현하는데 사용된다. 예를 들어, {a, b, c}의 멱집합에서 {a, c}는 101로 표현할 수 있다.

public class PowerSet {
    public static final <E> Collection<Set<E>> of(Set<E> s) {
        List<E> src = new ArrayList<>(s);
        if (src.size() > 30)
            throw new IllegalArgumentException(
                "집합에 원소가 너무 많습니다(최대 30개).: " + s);
                
        return new AbstractList<Set<E>>() {
            @Override public int size() {
                // 멱집합의 크기는 2를 원래 집합의 원소 수만큼 거듭제곱 것과 같다.
                return 1 << src.size();
            }

            @Override public boolean contains(Object o) {
                return o instanceof Set && src.containsAll((Set)o);
            }

			// 인덱스 n 번째 비트 값 : 해당 원소가 원래 집합의 n 번째 원소를 포함하는지 여부
            @Override public Set<E> get(int index) {
                Set<E> result = new HashSet<>();
                for (int i = 0; index != 0; i++, index >>= 1)
                    if ((index & 1) == 1)
                        result.add(src.get(i));
                return result;
            }
        };
    }
}


위 코드에서는 AbstractList 클래스를 활용하여 멱집합을 표현한다. size 메서드는 멱집합의 크기를, get 메서드는 주어진 인덱스에 해당하는 부분 집합을 반환한다. 


그런데 이 방법은 원래 집합의 크기가 30을 넘어가면, 반환할 컬렉션의 크기가 너무 커지게 된다. 그래서 집합의 크기가 30을 넘는 경우 IllegalArgumentException을 발생시킨다. 

 

 

예제 2 - 입력 리스트의 모든 부분 리스트를 스트림으로 변환

 

for (int start = 0; start < src.size(); start++) {
	for (int end = start + 1; end <= src.size(); end++) {
    	System.out.println(src.subList(start, end));
    }
}

 

위 코드는 이중 반복문을 이용한 방식이다. 이렇게 되면 부분 리스트의 수가 입력 리스트의 크기의 제곱에 비례하여 증가하기 때문에, 리스트의 크기가 커질수록 메모리 사용량이 크게 증가하게 된다.


이를 스트림을 사용하여 구현한 코드는 아래와 같다.

public class SubLists {
    public static <E> Stream<List<E>> of(List<E> list) {
        return Stream.concat(Stream.of(Collections.emptyList()),
                prefixes(list).flatMap(SubLists::suffixes));
    }

    private static <E> Stream<List<E>> prefixes(List<E> list) { // (a), (a,b), (a,b,c)
        return IntStream.rangeClosed(1, list.size())
                .mapToObj(end -> list.subList(0, end));
    }

    private static <E> Stream<List<E>> suffixes(List<E> list) { // (a,b,c), (b,c), (c)
        return IntStream.range(0, list.size())
                .mapToObj(start -> list.subList(start, list.size()));
    }
}

 

 

of 메소드는 입력 리스트의 모든 부분 리스트를 포함하는 스트림을 반환한다. 이 때 Stream.concat 메소드를 사용하여 빈 리스트를 포함한 부분 리스트 스트림과 각 prefix에 대한 suffix 스트림을 합친다.

prefixes 메소드는 리스트의 모든 prefix를 포함하는 스트림을 반환한다. IntStream.rangeClosed를 사용하여 1부터 리스트의 크기까지 범위의 스트림을 생성하고, mapToObj를 사용하여 각 원소에 대해 해당 인덱스까지의 부분 리스트를 생성한다.

suffixes 메소드는 주어진 리스트의 모든 suffix를 포함하는 스트림을 반환한다. IntStream.range를 사용하여 0부터 리스트의 크기까지 범위의 스트림을 생성하고, mapToObj를 사용하여 각 원소에 대해 해당 인덱스부터 리스트의 끝까지의 부분 리스트를 생성한다.

이렇게 스트림을 사용하면 메모리 사용량을 줄일 수 있다.