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

람다와 스트림, 여섯 번째 아이템

Posted by SungBeom on April 24, 2020 · 5 mins read

Intro

원소 시퀀스, 즉 일련의 원소를 반환하는 메소드는 수없이 많다. 자바 7까지는 기본적으로 Collection, Set, List 같은 컬렉션 인터페이스를 쓰고, 일부 Collection 메소드를 구현할 수 없을 때는 Iterable 인터페이스를 쓰고, 반환 원소들이 기본 타입이거나 성능에 민감한 상황이라면 배열을 썼다. 그런데 자바 8이 스트림이라는 개념을 들고 오면서 이 선택이 아주 복잡한 일이 되어버렸다.

스트림은 반복(iteration)을 지원하지 않기 때문에, 스트림과 반복을 알맞게 조합해야 좋은 코드가 나온다. Stream 인터페이스는 Iterable 인터페이스가 정의한 추상 메소드를 전부 포함할 뿐만 아니라, Iterable 인터페이스가 정의한 방식대로 동작함에도 for-each로 스트림을 반복할 수 없는 까닭은 바로 Stream이 Iterable을 확장(extend)하지 않아서다.

Stream을 반환하는 구현: Stream을 Iterable로 변환해주는 어댑터

얼핏 보면 Stream의 iterator 메소드에 메소드 참조를 건네면 해결될 것 같지만, 자바 타입 추론의 한계로 컴파일 오류를 낸다. 이 오류를 바로잡으려면 메소드 참조를 매개변수화된 Iterable로 적절히 형변환해줘야 하나, 실전에 쓰기에는 너무 난잡하고 직관성이 떨어진다. 다행히 어댑터 메소드를 사용하면 자바의 타입 추론이 문맥을 잘 파악하여 어댑터 메소드 안에서 따로 형변환하지 않아도 된다. 어댑터를 사용하면 어떤 스트림도 for-each 문으로 반복할 수 있다.

1
2
3
4
// Stream<E>를 Iterable<E>로 중개해주는 어댑터
public static <E> Iterable<E> iterableOf(Stream<E> stream) {
    return stream::iterator;
}
cs

반대로, API가 Iterable만 반환하면 이를 스트림 파이프라인에서 처리하려는 프로그래머가 성을 낼 것이다. 자바는 이를 위한 어댑터도 제공하지 않지만, 역시 손쉽게 구현할 수 있다.

1
2
3
4
// Iterable<E>를 Stream<E>로 중개해주는 어댑터
public static <E> Stream<E> streamOf(Iterable<E> iterable) {
    return StreamSupport.stream(iterable.spliterator(), false);
}
cs

객체 시퀀스를 반환하는 메소드를 작성하는데, 이 메소드가 오직 스트림 파이프라인에서만 쓰일 걸 안다면 마음 놓고 스트림을 반환하게 해주자. 반대로 반환된 객체들이 반복문에서만 쓰일 걸 안다면 Iterable을 반환하자. 하지만 공개 API를 작성할 때는 스트림 파이프라인을 사용하는 사람과 반복문에서 쓰려는 사람 모두를 배려해야 한다.

Stream을 반환하는 구현: 직접 구현한 전용 Collection

Collection 인터페이스는 Iterable의 하위 타입이고 Stream 메소드도 제공하니 반복과 스트림을 동시에 지원한다. 따라서 원소 시퀀스를 반환하는 공개 API의 반환 타입에는 Collection이나 그 하위 타입을 쓰는 게 일반적으로 최선이다. Arrays 역시 반복과 스트림을 지원하므로, 반환하는 시퀀스의 크기가 메모리에 올려도 안전할 만큼 작다면 ArrayList나 HashSet 같은 표준 컬렉션 구현체를 반환하는 게 최선일 수 있다. 하지만 단지 컬렉션을 반환한다는 이유로 덩치 큰 시퀀스를 메모리에 올려서는 안 된다.

반환할 시퀀스가 크지만 표현을 간결하게 할 수 있다면 전용 컬렉션을 구현하는 방안을 검토해보자. 예컨대 주어진 집합의 멱집합(한 집합의 모든 부분집합을 원소로 하는 집합)을 반환한다고 했을 때, 멱집합을 표준 컬렉션 구현체에 저장하려는 생각은 위험하지만, AbstractList를 이용하면 훌륭한 전용 컬렉션을 손쉽게 구현할 수 있다. 비결은 멱집합을 구성하는 각 원소의 인덱스를 비트 벡터로 사용하는 것이다.

AbstractCollection을 활용해서 Collection 구현체를 작성할 때는 Iterable용 메소드 외에 contains와 size란 2개의 메소드만 더 구현하면 된다. 이 메소드들은 손쉽게 효율적으로 구현할 수 있으며, 반복이 시작되기 전에는 시퀀스의 내용을 확정할 수 없는 등의 사유로 contains와 size를 구현하는 게 불가능할 때는 컬렉션보다는 스트림이나 Iterable을 반환하는 편이 낫다.

때로는 단순히 구현하기 쉬운 쪽을 선택하기도 한다. 예컨대 입력 리스트의 연속적인 부분리스트를 모두 반환하는 메소드를 작성한다고 한다면, 필요한 부분리스트를 만들어 표준 컬렉션에 담는 건 어렵지 않다. 입력 리스트의 모든 부분리스트를 스트림으로 구현하기 위해서는 단순히 그 리스트의 프리픽스(prefix, 첫 번째 원소를 포함하는 부분리스트)에 빈 리스트 하나만 추가하면 된다.


핵심 정리

원소 시퀀스를 반환하는 메소드를 작성할 때는, 이를 스트림으로 처리하기를 원하는 사용자와 반복으로 처리하길 원하는 사용자가 모두 있을 수 있음을 떠올리고, 양쪽을 다 만족시키려 노력하자. 컬렉션을 반환할 수 있다면 그렇게 하라. 반환 전부터 이미 원소들을 컬렉션에 담아 관리하고 있거나 컬렉션을 하나 더 만들어도 될 정도로 원소 개수가 적다면 ArrayList 같은 표준 컬렉션에 담아 반환하라. 그렇지 않으면 앞서의 멱집합 예처럼 전용 컬렉션을 구현할지 고민하라. 컬렉션을 반환하는 게 불가능하면 스트림과 Iterable 중 더 자연스러운 것을 반환하라. 만약 나중에 Stream 인터페이스가 Iterable을 지원하도록 자바가 수정된다면, 그때는 안심하고 스트림을 반환하면 될 것이다.