본문으로 건너뛰기
안동민 개발노트 아이콘

안동민 개발노트

본문 시작
11장 : 컬렉션

List와 Set 선택

List와 Set은 모두 여러 원소를 담지만 같은 데이터를 관찰하는 방식이 다릅니다.

List는 위치와 삽입 순서를 보존하고 Set은 동등한 원소가 하나만 존재하도록 합니다.

ArrayList의 끝 추가와 인덱스 읽기는 빠르지만 중간 삽입은 뒤 원소 이동이 필요합니다.

HashSet의 평균 검색은 빠르지만 출력 순서를 약속하지 않습니다.

이 절에서는 결과에 필요한 의미가 무엇인지 먼저 결정합니다.

ArrayList와 HashSet은 서로의 “빠른 버전”이 아닙니다. 목록에서 같은 값이 두 번 등장한 사실은 데이터일 수 있지만 집합에서는 제거 대상입니다. 자료구조를 바꾸면 성능뿐 아니라 프로그램의 의미가 바뀐다는 점을 먼저 확인해야 합니다.


ArrayList는 순차 저장과 임의 접근에 적합하다

대부분의 일반 목록에서 좋은 기본 선택입니다.

인덱스가 범위를 벗어나면 예외가 나며 제거 뒤 인덱스가 당겨집니다.


Set은 중복 판단을 equals 계약에 맡긴다

HashSet은 hashCode로 버킷을 좁히고 equals로 최종 비교합니다.

가변 키와 잘못된 동등성 구현을 피합니다.

예제는 사건 목록, 중복을 제거한 종류 집합, 외부에 건넬 변경 불가능한 스냅샷을 같은 입력에서 만듭니다.

ListSetTradeoffs.java
import java.util.ArrayList;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Set;

public class ListSetTradeoffs {
    public static void main(String[] args) {
        List<String> events = new ArrayList<>(List.of("login", "save", "login"));
        Set<String> kinds = new LinkedHashSet<>(events);
        System.out.println(events.get(1));
        System.out.println(events);
        System.out.println(kinds);

        List<String> snapshot = List.copyOf(events);
        events.add("logout");
        System.out.println(snapshot);
        // snapshot.add("fail"); // 실행 시 UnsupportedOperationException
    }
}

events.get(1)은 위치 의미를, LinkedHashSet 출력은 유일성과 첫 등장 순서를 함께 보여 줍니다. 마지막 스냅샷에는 이후 추가한 logout이 없으므로 원본 목록과 구조적으로 분리됐음을 알 수 있습니다.

save
[login, save, login]
[login, save]
[login, save, login]

LinkedHashSet은 중복을 제거하면서 첫 등장 순서를 유지하고 copyOf 결과는 원본 변경과 분리됩니다.

선택 순서는 구현 이름에서 시작하지 않습니다. 같은 값의 반복과 위치가 업무 데이터면 List를 유지하고, 유일성 자체가 규칙이면 Set으로 모델링합니다. 그다음에야 포함 조회 비용, 삽입·정렬 순서, 외부에 넘길 스냅샷 여부를 보고 구현을 결정합니다.


contains 중심 작업의 비용을 비교한다

큰 List에서 반복 contains를 호출하면 선형 탐색이 누적됩니다.

중복 검사나 빠른 포함 여부가 핵심이면 Set을 함께 사용합니다.

Set도 구현마다 관찰 가능한 순서 계약이 다릅니다. 아래 세 구현을 출력 형식에 그대로 노출할 수 있는지 비교해 봅니다.

비교 1
Set<String> hash = new java.util.HashSet<>();       // 순서 보장 없음
Set<String> insertion = new java.util.LinkedHashSet<>(); // 삽입 순서
Set<String> sorted = new java.util.TreeSet<>();           // 정렬 순서
비교 2
List<String> values = new ArrayList<>(List.of("A", "B"));
values.remove(0); // 뒤 원소가 당겨짐
// values.get(2); // 실행 시 IndexOutOfBoundsException

변경 불가능한 복사로 경계를 닫는다

List.copyOf, Set.copyOf는 null을 허용하지 않는 변경 불가능한 결과를 만듭니다.

원본의 이후 변경과 분리됩니다.

단, copyOf는 원소 객체까지 깊게 복사하지 않습니다. 가변 객체 목록을 복사하면 목록의 추가·삭제는 막혀도 원소 내부 상태 변경은 두 목록에서 함께 보입니다. 진정한 값 스냅샷이 필요하면 원소도 불변 값으로 만들거나 별도로 복사합니다.


순서 보장도 구현 계약의 일부다

HashSet은 정렬이나 삽입 순서를 약속하지 않고 LinkedHashSet은 삽입 순서를, TreeSet은 정렬 순서를 제공합니다.

출력 순서가 필요하면 맞는 구현을 선택합니다.

입력을 "login", "save", "login", "logout"으로 늘리고 HashSet, LinkedHashSet, TreeSet 결과를 각각 출력해 보세요. 세 결과의 원소 수는 같아도 순서는 서로 다른 계약으로 결정됩니다. 화면이나 직렬화 결과에 순서가 중요하다면 우연히 현재처럼 보이는 HashSet에 기대면 안 됩니다.

중간 삽입이 있다는 이유만으로 LinkedList를 바로 고르지 마세요. 삽입 위치를 찾는 비용과 메모리 지역성까지 포함하면 일반적인 순회·조회에서는 ArrayList가 더 단순한 기본값입니다. 실제 병목이 확인되고 노드 위치를 이미 가지고 있는 특수한 경우에 구현을 바꿉니다.


위치와 중복이 의미라면 List, 유일성과 포함 여부가 의미라면 Set을 선택하고 필요한 순서 보장은 구현에서 다시 확인합니다.