컬렉션 선택 기준 (List, Set, Map)

한 줄 정의

Java 컬렉션 선택은 데이터를 순서대로 다룰지, 중복을 제거할지, key로 빠르게 찾을지에 따라 List, Set, Map 중 적절한 구조를 고르는 일이다.

실무에서 왜 중요한가

백엔드 코드는 DB에서 가져온 데이터를 응답 DTO로 조립하고, 중복을 제거하고, id 기준으로 다시 찾는 일이 많다.

컬렉션을 잘못 고르면 반복 조회가 늘어나고, 응답 순서가 흔들리며, 중복 key나 객체 동등성 문제로 버그가 생긴다.

한눈에 보는 선택 기준

필요우선 고려
순서대로 보여줘야 함List
중복을 제거해야 함Set
id/code로 빠르게 찾아야 함Map
입력 순서를 유지해야 함ArrayList, LinkedHashSet, LinkedHashMap
정렬된 상태가 필요함정렬된 List, TreeSet, TreeMap
여러 스레드가 동시에 수정함concurrent collection 또는 동기화

핵심 질문은 “이 데이터를 나중에 어떻게 다시 사용할 것인가?”이다.

List

List는 순서가 있고 중복을 허용한다.

적합한 곳:

  • API 응답 목록
  • DB 조회 결과
  • 페이지네이션 결과
  • 정렬된 데이터
  • 순서가 의미 있는 처리 대상
List<OrderResponse> responses = orders.stream()
    .map(OrderResponse::from)
    .toList();

주의할 점은 반복 조회다. 반복문 안에서 계속 stream().filter()로 찾으면 데이터가 커질수록 비용이 커진다.

Set

Set은 중복을 허용하지 않는다.

적합한 곳:

  • 요청 id 중복 제거
  • 이미 처리한 항목 기록
  • 권한, 역할, 태그처럼 중복이 의미 없는 값
  • 차집합, 교집합 계산
Set<Long> userIds = orders.stream()
    .map(Order::getUserId)
    .collect(Collectors.toSet());

HashSet은 순서를 보장하지 않는다. 응답 순서가 중요하면 LinkedHashSet이나 정렬된 List를 고려한다.

Map

Map은 key로 value를 찾는다.

적합한 곳:

  • id 기준으로 엔티티 조회
  • code 기준 설정값 조회
  • 여러 목록을 하나의 응답으로 조립
  • 그룹별 데이터 묶기
  • 카운트나 합계 집계
Map<Long, User> userMap = users.stream()
    .collect(Collectors.toMap(User::getId, user -> user));
 
User user = userMap.get(order.getUserId());

toMap()은 key 중복 가능성을 확인해야 한다. 같은 key가 두 번 나오면 기본적으로 예외가 발생한다.

Map<Long, Order> latestOrderMap = orders.stream()
    .collect(Collectors.toMap(
        Order::getUserId,
        order -> order,
        (oldOrder, newOrder) -> newOrder
    ));

merge 전략은 “왜 새 값을 선택하는지”가 코드나 사전 정렬로 설명되어야 한다.

실무 패턴: 응답 조립

주문마다 사용자 목록을 다시 순회하면 비용이 커진다.

List<OrderResponse> responses = orders.stream()
    .map(order -> {
        User user = users.stream()
            .filter(it -> it.getId().equals(order.getUserId()))
            .findFirst()
            .orElseThrow();
 
        return OrderResponse.of(order, user);
    })
    .toList();

반복 조회가 있으면 먼저 Map으로 바꾼다.

Map<Long, User> userMap = users.stream()
    .collect(Collectors.toMap(User::getId, user -> user));
 
List<OrderResponse> responses = orders.stream()
    .map(order -> OrderResponse.of(order, userMap.get(order.getUserId())))
    .toList();

이 패턴은 JPA N+1을 줄인 뒤, 메모리에서 데이터를 조립할 때 자주 사용한다.

자주 나는 실수

  • 데이터가 작다는 이유로 이중 반복을 계속 방치한다.
  • 순서가 필요한 응답에 HashSet이나 HashMap 순서를 기대한다.
  • 중복 key 가능성을 확인하지 않고 Collectors.toMap()을 사용한다.
  • 객체를 Set에 넣으면서 equals/hashCode 기준을 확인하지 않는다.
  • Map으로 바꾸면 무조건 좋은 줄 알고 코드를 불필요하게 복잡하게 만든다.

핵심 요약

순서가 중요하면 List, 중복 제거가 필요하면 Set, key로 반복 조회해야 하면 Map을 우선 고려한다.

실무에서는 응답 조립 과정에서 반복적인 stream().filter()가 보이면 Map 변환을 검토한다. 반대로 데이터가 작고 한 번만 순회한다면 List가 더 읽기 쉽다.

컬렉션 선택은 성능뿐 아니라 순서, 중복, 동등성, key 중복 가능성, 동시성 요구사항을 함께 보고 결정한다.

꼬리 질문

점검 퀴즈

아래 문항은 개념을 실제로 설명할 수 있는지 점검하기 위한 것이다. 선택지를 누르면 정답 여부와 이유가 표시된다.

객관식ID로 반복 조회해야 하는 데이터를 List로 계속 filter하는 코드의 개선 방향은?

OXHashSet의 중복 판단은 equals와 hashCode에 의존한다.

객관식Collectors.toMap()에서 중복 key가 생길 수 있는 경우 가장 안전한 대응은?

관련 문서