Stream.distinct() vs Set.copyOf() — 중복 제거, 뭐가 더 나을까?

KYUNGPYO LIM·2026년 3월 22일

Java

목록 보기
1/2

궁금해진 이유

코딩을 하던 중, List를 인자로 받아서 내부에서 중복 검사를 해야 하는 경우가 생겼다.

private void validateDuplicatedCards(List<Card> cards) {
    long distinctCount = cards.stream()
        						.distinct()
        						.count();

    if (distinctCount != cards.size()) {
        throw new IllegalArgumentException(DECK_CAN_NOT_DUPLICATED.getMessage());
    }
}
stream을 사용해 중복 제거하는 코드

한창 Stream에 미쳐있던 나는 아무 생각 없이 stream().distinct()를 통해 중복을 제거한 개수를 세고 원본 리스트의 사이즈와 비교하는 코드를 작성했다.

private static void validateDuplicatedCards(List<Card> cards) {
    Set<Card> distinctCards = Set.copyOf(cards);

    if (distinctCards.size() != cards.size()) {
        throw new IllegalArgumentException(DECK_CAN_NOT_DUPLICATED.getMessage());
    }
}
자료 구조 Set을 사용해 중복을 제거하는 코드

그런데 잠시 생각을 해보니 Set을 사용해서 중복을 제거하는 방법도 있는데, Stream을 사용하면 더 느려지지 않을까?? 하는 생각이 순간 뇌리를 스쳐 지나갔다.

Stream의 자세한 동작은 모르지만 어디선가 일반 반복문 보다도 Stream이 더 느리다고 들었던 기억이 있었기 때문이었다.

그래서 이번 기회로 Stream의 distinct()가 어떻게 동작하는지 알아가보고자 한다.


.distinct() 동작 방식 알아보기

Stream 공식 문서 링크 : Stream (Java Platform SE 8)

Stream은 Java 8에서 도입된 기능으로, 컬렉션 데이터를 선언적으로 처리할 수 있도록 도와주는 API이다.

기존에는 반복문을 통해 데이터를 직접 순회하며 처리해야 했다면,
Stream을 사용하면 “무엇을 할지”에 집중해서 데이터를 처리할 수 있다.

Stream 자체에 대해 알아보는 것이 아닌, distinct()에 대해 알아보는 것이 목적이므로 자세한 Stream 설명은 넘어가고 바로 본론으로 가자.

abstract class ReferencePipeline<P_IN, P_OUT>
        extends AbstractPipeline<P_IN, P_OUT, Stream<P_OUT>>
        implements Stream<P_OUT>  {
        
    // 수많은 메서드들...
    
    @Override
    public final Stream<P_OUT> distinct() {
        return DistinctOps.makeRef(this);
    }
}
distinct 메서드

distinct() 메서드로 이동하자 기존 Stream 파이프라인(this)에 DistinctOps를 활용해 중복 제거 연산을 추가한 새로운 Stream 파이프 라인을 만들어서 반환해주는 것을 볼 수 있다.

💡 참고
Stream은 지연 연산을 사용하기 때문에 중복 제거가 즉시 적용되는 것은 아니다.


그럼 이제 DistinctOpsmakeRef() 메서드 내부로 이동해서 코드를 살펴보자.

final class DistinctOps {

    static <T> ReferencePipeline<T, T> makeRef(AbstractPipeline<?, T, ?> upstream) {
        return new ReferencePipeline.StatefulOp<T, T>(
            upstream,
            StreamShape.REFERENCE,
            StreamOpFlag.IS_DISTINCT | StreamOpFlag.NOT_SIZED
        ) {

            <P_IN> Node<T> reduce(PipelineHelper<T> helper, Spliterator<P_IN> spliterator) {
                TerminalOp<T, LinkedHashSet<T>> reduceOp =
                    ReduceOps.<T, LinkedHashSet<T>>makeRef(
                        LinkedHashSet::new,
                        LinkedHashSet::add,
                        LinkedHashSet::addAll
                    );
                return Nodes.node(reduceOp.evaluateParallel(helper, spliterator));
            }

            @Override
            <P_IN> Node<T> opEvaluateParallel(...) {
                // ...
            }

            @Override
            <P_IN> Spliterator<T> opEvaluateParallelLazy(...) {
                // ...
            }

            @Override
            Sink<T> opWrapSink(...) {
                // ...
            }
        };
    }
}
DistinctOps 클래스 내부

실제론 굉장히 긴 양의 코드가 있는데, 지금 내가 궁금한 주제에서 주의깊게 봐야할 곳은 딱 2곳이다.
1. makeRef() 메서드의 반환값이 StatefulOp<T, T>인 것.
2. reduce() 메서드 내부에서 LinkedHashSet을 사용하는 것.


distinct()는 왜 상태를 가질까??

Stream은 흐름이라는 말 그대로 데이터를 담은 파이프 라인으로써 사용된다.
그래서 이전 요소들이 어떻게 되었는지, 다음 요소가 어떨게 될지는 상관하지 않고 지금 당장의 요소만을 처리하는 것이 기본 동작이다.

하지만 distinct()로 인해 중복을 제거하기 위해서는 필연적으로 이전의 상태를 기억해야 한다.
이전의 상태를 기억하지 못하면 현재 처리 중인 이 요소가 이미 처리되었는지(중복) 유무를 알 수가 없기 때문이다.

그래서 DistinctOps.makeRef()StatefulOp를 반환해서 상태를 유지하게 하는 것이다.


공식 문서에서는 이를 두고 다음과 같이 말한다.

for duplicated elements, the element appearing first in the encounter order is preserved.

This is a stateful intermediate operation.

이는 distinct()가 중복 여부를 판단하기 위해 이전에 등장한 요소들을 기억해야 하므로,
상태를 가지는 중간 연산이라는 의미이다.

그리고 내부적으로 LinkedHashSet을 사용하여 상태를 유지하면서 중복을 제거한다.
이는 순서를 유지한 채 Set을 생성하고(중복 제거), 그 결과를 Down Stream으로 전달한 뒤,
최종적으로 요소의 개수를 세는 과정으로 이어진다.

지금과 같이 단순한 중복 제거를 위해 사용하기에는 비용이 다소 크다.

  1. Stream을 생성하고,
  2. 내부적으로 순서 유지를 위해 LinkedHashSet을 사용하고,
  3. 다시 전체 개수를 센다.

따라서 지금의 의도와 같이 단순 중복 제거만이 목적이라면 Set.copyOf()를 통해 중복 유무를 확인하는 과정이 더 의도에 맞고 효율적임을 알 수 있다.


공식 문서의 권장 사항???

Preserving stability for distinct() in parallel pipelines is relatively expensive.

Using an unordered stream source or removing the ordering constraint with BaseStream.unordered() may result in significantly more efficient execution

공식 문서에는 위와 같이 단순 중복 제거만이 목적이라면 순서를 고려하지 않는 unordered()를 넣는 것도 하나의 방법이 될 수 있다고 한다.

long distinctCount = cards.stream()
  							.unordered()
  							.distinct()
  							.count();

하지만 원문에도 나와있듯이 이는 싱글 스레드 환경이 아닌 병렬 스트림 파이프 라인(parallel stream)에서만 유의미한 이점을 가진다.

따라서 일반적인 상황에서는 unordered()를 추가하더라도 체감할 수 있는 성능 차이는 크지 않고 사용되는 비용은 동일하다.


마무리

처음에는 단순히 중복을 제거하기 위해 Stream.distinct()를 사용했지만,
내부 동작을 살펴보면서 이 연산이 단순한 필터링이 아니라
상태를 유지하며 동작하는 비용이 있는 연산이라는 것을 알게 되었다.

특히 내부적으로 LinkedHashSet을 사용하여
중복을 제거하고 순서를 유지한다는 점에서,
결국 Set 기반의 동작을 추상화한 것에 가깝다는 것을 이해할 수 있었다.

따라서 단순히 중복 여부를 확인하는 것이 목적이라면
Stream.distinct()보다는 Set.copyOf()를 사용하는 것이
더 의도에 맞고 효율적인 선택이 될 수 있다.

결국 중요한 것은 어떤 방식이 더 “좋다”가 아니라,
상황에 맞는 적절한 선택을 하는 것이라는 점을 다시 한 번 느낄 수 있었다.

profile
개발을 잘하고 싶은 사람

0개의 댓글