본문 바로가기
Dev Books/Effective Java

[item 47] 반환 타입으로는 스트림보다 컬렉션이 낫다

by Thumper 2025. 7. 15.

Java 8 이전(즉, Java 7까지)

Java 7까지는 Stream API가 존재하지 않았기 때문에, 메서드에서 여러 원소(시퀀스)를 반환해야 할 때 사용할 수 있는 타입은 다음과 같았다.

  • Collection(인터페이스): 여러 값을 담는 일반적인 컬렉션 타입. 예: ArrayList, HashSet
  • Set, List: Collection의 하위 타입
  • Iterable: for-each 문에서 순회할 수 있는 최소 요건을 가진 타입
  • 배열: String[], int[] 같은 고전적인 방식

특별한 이유가 없다면, 보통은 Collection이나 그 하위 타입 (List, Set)을 반환 타입으로 선택했다.
Collection이 다양한 유용한 메서드를 제공하기 때문이다.

예: size(), contains(), isEmpty() 등

Iterable은 for-each 문에서 사용할 수 있도록 최소한의 기능만을 제공하는 인터페이스이다.
메서드에서 반환하는 원소들이 단순히 한 번만 순회하면 되는 경우나,
size()나 contains(Object)처럼 추가적인 컬렉션 연산이 필요하지 않은 경우에는 Collection 대신 Iterable을 반환하는 것이 더 적절하다.

Java 8 이후, Stream 등장

자바 7까지는 반복 가능한 데이터를 다루기 위해 Iterable이나 Collection을 사용했고,
그 이후 자바 8에서는 스트림(Stream)이라는 새로운 접근 방식이 등장했다.
그런데, 스트림은 기존의 for-each 문과는 다르게 동작한다.
스트림은 반복(iteration)을 지원하지 않는다.

왜 스트림은 for-each 문에서 사용할 수 없을까?

스트림(Stream)은 데이터를 반복해서 처리할 수 있지만, for-each 문에서는 직접 사용할 수 없다.
그 이유는 스트림이 Iterable 인터페이스를 상속하지 않았기 때문이다.

for-each 문은 내부적으로 Iterable 타입만 사용할 수 있게 되어 있다.
스트림도 iterator() 메서드를 가지고 있지만, 자바 컴파일러는 이게 Iterable이 아니면 for-each 문에서 쓸 수 없다고 판단한다.
그래서 다음과 같은 코드는 컴파일 오류가 난다.
image

for (ProcessHandle ph : ProcessHandle.allProcesses()::iterator)를 컴파일하려고 하면,
Method reference expression is not expected here라는 오류가 발생할 수 있다.
이 오류는 "여기에서는 메서드 참조 문법(::)을 쓸 수 없다"는 의미이다.

Iterator로 적절히 형변환을 해준다. 하지만 코드 유지보수 측면에서 좋지 않다.

image 이 코드는 문법적으로 동작할 수는 있지만, 아래와 같은 이유로 코드 유지보수 측면에서 좋지 않다.
  • 가독성이 떨어진다.
  • 직관적이지 않다.
  • 메서드 참조를 직접 캐스팅하는 방식이라 난해하게 느껴진다.

타입을 중개해주는 어댑터 메소드

자바에서 타입 간 중개를 할 때 이렇게 어댑터(adapter) 클래스를 만들어서 StreamIterable 간 변환을 명확하게 처리하는 게 좋은 방법이다.

import java.util.stream.Stream;
import java.util.stream.StreamSupport;

public class Adapters {
    // Adapter from  Stream<E> to Iterable<E> (
    public static <E> Iterable<E> iterableOf(Stream<E> stream) {
        return stream::iterator;
    }

    // Adapter from Iterable<E> to Stream<E>
    public static <E> Stream<E> streamOf(Iterable<E> iterable) {
        return StreamSupport.stream(iterable.spliterator(), false);
    }
}
  • iterableOf(Stream<E> stream) : 스트림Iterable로 감싸서 for-each 등에 쓸 수 있게 한다.
  • streamOf(Iterable<E> iterable) : 기존 Iterable을 스트림으로 바꿔서 스트림 API 활용을 가능하게 한다.

Collection이 반복과 스트림을 모두 지원하는 이유

Collection 인터페이스는 Iterable의 하위 타입이기 때문에, for-each 문을 사용한 반복(iteration)도 지원하고, stream() 메서드를 통해 스트림(stream) 처리도 지원한다.
즉, Collection은 "반복 + 스트림 처리"를 모두 지원하는 가장 범용적인 반환 타입입니다.

따라서 어떤 메서드가 여러 개의 원소(시퀀스)를 반환해야 한다면, 특별한 이유가 없다면
그 반환 타입을 Collection 또는 List, Set 등 그 하위 타입으로 선언하는 것이 일반적으로 가장 좋다.

또한 배열도 마찬가지로, Arrays.asList()나 Stream.of() 같은 도구를 통해 쉽게 반복과 스트림 처리 모두에 사용할 수 있다.

그러나, 단순히 Collection을 반환하는 것이 항상 좋은 것은 아니다.

단지 컬렉션을 반환한다는 이유로 덩치 큰 시퀀스를 메모리에 올려서는 안 된다.

반환 대상이 매우 크거나, 메모리에 한꺼번에 올리기 어려운 시퀀스라면,
굳이 모든 원소를 메모리에 로딩하여 Collection 형태로 반환하는 것은 비효율적이다.
이럴 땐 오히려 Stream이나 Iterable을 반환하는 것이 낫다.

  • Stream: 지연 처리(lazy evaluation)로 필요할 때마다 데이터를 흘려보낸다.
  • Iterable: 메모리를 덜 쓰면서도 for-each 문에서 사용할 수 있다.

반환하는 컬렉션이 너무 크다면 전용 컬렉션도 고려하라.

반환할 시퀀스가 크지만 표현을 간결하게 할 수 있다면 전용 컬렉션을 구현하는 방안을 검토해보자.

예제 : Set의 멱집합(power set)을 구하는 전용 컬렉션

package effectivejava.chapter7.item47;

import java.util.*;

public class PowerSet {
    // Returns the power set of an input set as custom collection (Page 218)
    public static final <E> Collection<Set<E>> of(Set<E> s) {
        List<E> src = new ArrayList<>(s);
        if (src.size() > 30)
            throw new IllegalArgumentException("Set too big " + s);
        return new AbstractList<Set<E>>() {
            @Override public int size() {
                return 1 << src.size(); // 2 to the power srcSize
            }

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

            @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;
            }
        };
    }

    public static void main(String[] args) {
        Set s = new HashSet(Arrays.asList(args));
        System.out.println(PowerSet.of(s));
    }
}

멱집합 : 한 집합의 모든 부분집합을 원소로 하는 집합.

{a,b,c}의 멱집합은 {{}, {a}, {b}, {c}, {a,b}, {a,c}, {b,c}, {a,b,c}}다.
원소 개수가 n개면 멱집합 원소 개수는 2ⁿ개가 된다. (생각보다 덩치가 커진다.)

get(int index)를 통해 해당 인덱스에 해당하는 부분집합을 반환한다.

하지만 전용 컬렉션을 활용하면, 모든 부분집합을 한 번에 생성하지 않고,
요청이 들어올 때마다 get(int index)를 통해 해당 인덱스에 해당하는 부분집합을 지연 계산(lazy evaluation)하여 반환한다.

AbstractionCollection을 활용해서 Collection 구현체를 작성할 때 Iterable용 메서드 외에 contains, size를 더 구현하면 된다.

또한 이 전용 컬렉션은 AbstractList를 상속받아 필요한 최소한의 메서드만 구현했고, size()contains() 같은 메서드도 지원한다.

입력 집합의 원소 수가 30을 넘으면 PowerSet.of가 예외를 던진다.

Collection의 size() 메서드가 int 값을 반환하므로 PowerSet.of가 반환되는 시퀀스의 최대 길이는 Integer.MAX_VALUE 혹은 2³¹-1로 제환된다.
입력 집합의 원소 수가 31 이상이면 이 한계를 넘어서게 된다.
따라서 안전하게 30개까지만 허용하고, 그 초과 시 예외를 던지는 방식입니다.

Integer.MAX_VALUE = 2,147,483,647
2³¹ = 2,147,483,648


예를 들어 {a,b,c}의 멱집합은 원소가 8이므로 유효한 인덱스는 0~7이며, 이진수로는 000 ~ 111이다.
인덱스를 이진수로 나타내면, 각 n번째 자리의 값이 각각 원소 a,b,c를 포함하는지 여부를 알려준다.
즉, 000번째 원소는 {}, 001번째는 {a}, ..111번째 원소는 {a, b, c}가 된다.

Stream이 Collection보다 나을 때도 있다.

결과 시퀀스가 매우 크고, 그 중 일부만 처리하거나 순차적으로 처리하고자 할 때는 Stream이 유리하다.

예제 : Stream을 사용하여 필요한 순간에만 부분 리스트를 생성한다.

image

예제에서 등장한 주요 Stream API 메서드들을 살펴보자.

1. suffixes(List list)

private static <E> Stream<List<E>> suffixes(List<E> list) {
    return IntStream.range(0, list.size())
            .mapToObj(start -> list.subList(start, list.size()));
}
  • 리스트의 모든 sublist 리스트를 생성한다.
  • IntStream.range(0, list.size()) : 0부터 size - 1까지 반복한다.
  • 각 start 인덱스에서 시작해 끝까지 잘라낸 list.subList(start, list.size())를 반환한다.

예: 입력 리스트가 [a, b, c]이면

start = 0 → [a, b, c]

start = 1 → [b, c]

start = 2 → [c]

결과: [a, b, c], [b, c], [c]

2. flatMap

prefixes(list).flatMap(SubLists::suffixes)
  • flatMap()은 스트림의 형태가 배열과 같을 때, 모든 원소를 단일 원소 스트림으로 반환할 수 있다.
  • prefixes(list).flatMap(SubLists::suffixes)은 리스트에서 만들 수 있는 모든 "연속된 부분리스트(sublist)"를 생성하는 과정이다.
# 가정:
입력 리스트: [a, b, c]이 주어졌을 때

prefixes:
[a]
[a, b]
[a, b, c]


각각의 prefix에 대해 suffixes 실행:
[a] → [a]
[a, b] → [a, b], [b]
[a, b, c] → [a, b, c], [b, c], [c]



최종 결과 (flatten):
[a], [a, b], [b], [a, b, c], [b, c], [c]

3. Stream.concat(Stream a, Stream b)

return Stream.concat(Stream.of(Collections.emptyList()),
                     prefixes(list).flatMap(SubLists::suffixes));

두 개의 스트림을 앞뒤로 이어 붙인다.
여기서는 가장 앞에 빈 리스트 []를 붙이고, 뒤에 모든 sublist들을 이어붙인다.

Stream.of([]) + Stream.of([a], [a,b], ..., [c])

4. IntStream.range(int startInclusive, int endExclusive)

IntStream.range(0, list.size())

startInclusive부터 endExclusive - 1까지의 반복한다.
suffixes()에서 사용됨 → 인덱스 0부터 list.size() - 1까지 순회


결론

공개 API에서 원소 시퀀스를 반환할 때는 특별한 이유가 없는 한 Collection 또는 그 하위 타입(List, Set 등)을 사용하는 것이 최선이다.

하지만 데이터가 너무 크거나 지연 계산이 필요한 경우, Stream이나 전용 컬렉션 구현체가 더 나은 선택일 수 있다.

댓글