PHpullh

JAVA · 심층 가이드

Java 컬렉션 완전 정리

ArrayList의 확장 비용, HashMap의 버킷과 트리화, ConcurrentHashMap의 동시성 특성을 근거로 어떤 구현체를 고를지 판단하는 18개 주제입니다.

주제 18개 · 예제 코드 포함 · 최종 수정 2026-08-30 · 작성 pullh 편집팀

컬렉션 선택은 ListMap이냐의 문제가 아니라 어떤 구현체를 고르느냐의 문제입니다. 같은 인터페이스 뒤에서 ArrayList는 연속된 배열을 쓰고 LinkedList는 노드를 이어 붙이며, HashMap은 해시 버킷을, TreeMap은 정렬된 트리를 씁니다. 인덱스 접근, 중간 삽입, 키 조회, 범위 검색 중 어느 연산이 뜨거운 경로인지 먼저 정해야 선택이 결정됩니다. 인터페이스 타입으로 변수를 선언하되 구현체는 의식적으로 고르는 습관이 핵심입니다.

ArrayList 내부 구조에서 용량 확장이 배열 복사를 동반한다는 사실을 확인하면 LinkedList vs ArrayList 비교의 결론이 왜 대부분 ArrayList 쪽인지 납득이 됩니다. 이어서 HashMap 내부 구조에서 해시 충돌과 트리 변환, 로드 팩터를 보고 ConcurrentHashMap으로 넘어가면 동시성 버전이 무엇을 바꿨는지 대비로 드러납니다. 정렬이 필요해지면 Comparator 심화TreeMap과 TreeSet이 이어집니다.

불변 컬렉션에는 두 가지 서로 다른 개념이 섞여 있어 자주 헷갈립니다. Arrays.asList가 돌려주는 건 배열을 감싼 고정 크기 뷰라서 set은 되지만 add는 예외가 나고, 원본 배열을 고치면 리스트도 바뀝니다. 반면 List.of는 진짜 불변이고 요소로 null을 허용하지 않습니다. 조회 결과처럼 null이 섞일 수 있는 값을 넣으면 그 자리에서 NPE가 납니다. 그리고 HashMap 키로 쓴 객체의 필드를 나중에 바꾸면 해시가 달라져 그 항목을 다시는 찾지 못합니다.

01Collections Framework 핵심

ArrayList, LinkedList, HashMap, LinkedHashMap, TreeMap, HashSet의 선택 기준과 사용법.

Java code

import java.util.*;

public class CollectionsDemo {
    public static void main(String[] args) {
        // List — 순서 있는 중복 허용
        List<String> arrayList = new ArrayList<>();    // 조회 O(1)
        List<String> linkedList = new LinkedList<>();  // 삽입/삭제 O(1)
        List<String> immutable = List.of("a", "b", "c"); // 불변 (Java 9+)

        arrayList.addAll(List.of("banana", "apple", "cherry"));
        Collections.sort(arrayList);
        System.out.println(arrayList); // [apple, banana, cherry]

        // Map — 키-값, 키 중복 불가
        Map<String, Integer> hashMap    = new HashMap<>();   // 순서 없음
        Map<String, Integer> linkedMap  = new LinkedHashMap<>(); // 삽입 순서
        Map<String, Integer> treeMap    = new TreeMap<>();   // 키 정렬

        hashMap.put("banana", 2);
        hashMap.put("apple", 1);
        hashMap.put("cherry", 3);

        // getOrDefault, putIfAbsent, computeIfAbsent
        hashMap.getOrDefault("durian", 0);
        hashMap.putIfAbsent("apple", 99);     // apple 이미 있어 무시
        hashMap.computeIfAbsent("elderberry", k -> k.length());
        hashMap.merge("apple", 5, Integer::sum); // apple = 1+5 = 6

        // 순회
        hashMap.forEach((k, v) ->
            System.out.println(k + ": " + v));

        // Set — 중복 없음
        Set<String> hashSet  = new HashSet<>(arrayList);  // 순서 없음
        Set<String> treeSet  = new TreeSet<>(arrayList);  // 정렬
        Set<String> immSet   = Set.of("x", "y", "z");     // 불변

        // Collections 유틸리티
        System.out.println(Collections.max(arrayList));
        System.out.println(Collections.frequency(arrayList, "apple"));
        Collections.shuffle(arrayList);
        Collections.reverse(arrayList);
        Collections.unmodifiableList(arrayList);  // 불변 뷰
    }
}
알아두면 좋은 점

List.of(), Set.of(), Map.of()(Java 9+)는 null을 허용하지 않고 크기가 고정된 불변 컬렉션을 반환합니다.

자주 하는 실수

ArrayListfor(int i=0; i로 순회하며 중간에 삭제하면 ConcurrentModificationException이 납니다. Iterator.remove()removeIf()를 사용하세요.

02Optional — Null 안전성

Optional로 null을 명시적으로 처리하고 NPE를 방지합니다.

Java code

import java.util.Optional;

public class OptionalDemo {

    record User(String name, String email) {}
    record Address(String city) {}

    static Optional<User> findUser(int id) {
        if (id == 1) return Optional.of(new User("Alice", "alice@test.com"));
        if (id == 2) return Optional.of(new User("Bob", null));
        return Optional.empty();
    }

    static Optional<Address> getAddress(User user) {
        return user.name().equals("Alice")
            ? Optional.of(new Address("Seoul"))
            : Optional.empty();
    }

    public static void main(String[] args) {
        // 기본 사용
        Optional<User> opt = findUser(1);
        opt.ifPresent(u -> System.out.println("있음: " + u.name()));

        // orElse / orElseGet / orElseThrow
        User user = findUser(99)
            .orElse(new User("Guest", ""));
        User user2 = findUser(99)
            .orElseGet(() -> new User("Guest", ""));
        User user3 = findUser(1)
            .orElseThrow(() -> new RuntimeException("없음"));

        // map + flatMap — 체이닝
        String city = findUser(1)
            .flatMap(OptionalDemo::getAddress)
            .map(Address::city)
            .orElse("도시 모름");
        System.out.println("도시: " + city);

        // filter
        findUser(1)
            .filter(u -> u.email() != null)
            .ifPresent(u -> System.out.println("이메일: " + u.email()));

        // ifPresentOrElse (Java 9+)
        findUser(99).ifPresentOrElse(
            u  -> System.out.println("찾음: " + u.name()),
            () -> System.out.println("못 찾음")
        );

        // or (Java 9+) — 다른 Optional 제공
        Optional<User> result = findUser(99)
            .or(() -> findUser(1));
        System.out.println(result.map(User::name).orElse(""));
    }
}
알아두면 좋은 점

Optional.orElse()는 항상 값을 생성합니다. 비용이 큰 기본값은 orElseGet()을 사용해 실제로 필요할 때만 생성되도록 하세요.

자주 하는 실수

Optional을 인스턴스 필드, 컬렉션 원소, 메서드 파라미터로 사용하는 것은 안티패턴입니다. 반환 타입으로만 사용하는 것이 권장 사용법입니다.

03ZipFile API 개선 (Java 11+)

Files.zip() 없이도 ZIP 처리하는 현대적 방법

Java code

<span class="cm">// ZipFile API 개선 (Java 11+) 예제
// data/prompts.js의 생성 프롬프트로 상세 코드 생성 가능</span>
fun main() { println("ZipFile API 개선 (Java 11+)") }
알아두면 좋은 점

JAVA 공식 문서를 함께 참고하세요.

자주 하는 실수

자주 발생하는 실수에 주의하세요.

04ArrayList 내부 구조

ArrayList의 동적 배열 구현, 용량 관리, 성능 특성을 알아봅니다.

Java code

import java.util.*;

public class ArrayListInternal {
    public static void main(String[] args) {
        // 기본 용량: 10
        ArrayList<String> list = new ArrayList<>();

        // 초기 용량 지정 (재할당 방지)
        ArrayList<String> sized = new ArrayList<>(100);

        // 추가: O(1) 평균, O(n) 최악 (배열 확장 시)
        for (int i = 0; i < 15; i++) {
            list.add("item-" + i);
        }
        // 내부 배열 크기: 10 -> 15 (1.5배 증가)

        // 인덱스 접근: O(1)
        String item = list.get(5);

        // 중간 삽입/삭제: O(n) (요소 이동)
        list.add(0, "first"); // 모든 요소 오른쪽으로 이동
        list.remove(0);       // 모든 요소 왼쪽으로 이동

        // 메모리 최적화
        list.trimToSize(); // 배열 크기를 size에 맞춤

        // subList — 원본의 뷰 (복사 아님)
        List<String> sub = list.subList(0, 3);
        // sub 변경 시 원본도 변경됨!

        // 불변 리스트
        List<String> immutable = List.of("a", "b", "c");
        List<String> copy = List.copyOf(list);
    }
}
알아두면 좋은 점

대량 데이터를 담을 것을 알면 초기 용량을 지정하세요. new ArrayList(expectedSize)로 불필요한 배열 재할당을 피할 수 있습니다.

자주 하는 실수

subList()는 뷰입니다. 원본 리스트를 구조적으로 변경하면 ConcurrentModificationException이 발생합니다.

05LinkedList vs ArrayList 비교

두 리스트 구현체의 성능 특성을 비교하고 적절한 선택 기준을 제시합니다.

Java code

import java.util.*;

public class ListComparison {
    public static void main(String[] args) {
        // ArrayList: 연속 메모리, 인덱스 접근 O(1)
        ArrayList<Integer> arrayList = new ArrayList<>();
        // LinkedList: 노드 연결, 인덱스 접근 O(n)
        LinkedList<Integer> linkedList = new LinkedList<>();

        int size = 100_000;
        for (int i = 0; i < size; i++) {
            arrayList.add(i);
            linkedList.add(i);
        }

        // 랜덤 접근 — ArrayList 압도적 유리
        long start = System.nanoTime();
        arrayList.get(size / 2);       // O(1)
        long arrayTime = System.nanoTime() - start;

        start = System.nanoTime();
        linkedList.get(size / 2);      // O(n)
        long linkedTime = System.nanoTime() - start;

        System.out.printf("랜덤접근 — Array: %dns, Linked: %dns%n",
            arrayTime, linkedTime);

        // LinkedList의 장점: Deque 기능
        linkedList.addFirst(999);   // O(1)
        linkedList.addLast(888);    // O(1)
        linkedList.removeFirst();   // O(1)

        // 결론: 대부분 ArrayList가 유리
        // LinkedList는 Queue/Deque로만 사용 권장
    }
}
알아두면 좋은 점

실무에서는 거의 항상 ArrayList가 유리합니다. CPU 캐시 친화적이고 메모리 오버헤드가 적습니다.

자주 하는 실수

LinkedList는 요소당 24바이트 추가 메모리(prev/next 포인터)가 필요합니다. 메모리 효율이 중요하면 피하세요.

06HashMap 내부 구조

HashMap의 해시 버킷, 트리 변환, 로드 팩터를 이해합니다.

Java code

import java.util.*;

public class HashMapInternal {
    public static void main(String[] args) {
        // 기본: 초기 용량 16, 로드팩터 0.75
        HashMap<String, Integer> map = new HashMap<>();
        // 용량 지정 (2의 거듭제곱으로 반올림)
        HashMap<String, Integer> sized = new HashMap<>(32, 0.75f);

        // put: O(1) 평균
        map.put("Alice", 90);
        map.put("Bob", 85);

        // 해시 충돌 시: LinkedList -> TreeMap (8개 초과)
        // 내부 동작:
        // 1. key.hashCode() 계산
        // 2. hash를 버킷 인덱스로 변환
        // 3. 같은 버킷이면 equals()로 비교
        // 4. 충돌 8개 초과 -> Red-Black Tree로 변환

        // 중요: hashCode()와 equals() 계약
        // equals()가 true면 hashCode()도 같아야 함

        // Java 9+ 팩토리
        Map<String, Integer> immutable = Map.of("a", 1, "b", 2);
        Map<String, Integer> copied = Map.copyOf(map);

        // 유용한 메서드
        map.getOrDefault("없는키", 0);
        map.putIfAbsent("Alice", 100); // 이미 있으면 무시
        map.computeIfAbsent("Charlie", k -> k.length());
        map.merge("Alice", 10, Integer::sum); // 90 + 10 = 100
    }
}
알아두면 좋은 점

예상 원소 수를 알면 new HashMap(expectedSize / 0.75 + 1)로 초기화하면 리해싱을 방지합니다.

자주 하는 실수

가변 객체를 키로 사용하면 해시값이 변경되어 데이터를 찾을 수 없게 됩니다. 키는 반드시 불변이어야 합니다.

07ConcurrentHashMap

멀티스레드 환경에서 안전한 ConcurrentHashMap의 원리와 사용법입니다.

Java code

import java.util.concurrent.*;
import java.util.*;

public class ConcurrentHashMapDemo {
    public static void main(String[] args) throws Exception {
        ConcurrentHashMap<String, Integer> map = new ConcurrentHashMap<>();

        // 원자적 연산 (스레드 안전)
        map.put("counter", 0);
        map.compute("counter", (k, v) -> v + 1); // 원자적 갱신
        map.merge("counter", 1, Integer::sum);    // 원자적 병합
        map.putIfAbsent("new", 100);

        // 병렬 벌크 연산 (Java 8+)
        // parallelismThreshold: 이 크기 이상이면 병렬 처리
        map.put("Alice", 90);
        map.put("Bob", 85);
        map.put("Charlie", 95);

        // forEach 병렬
        map.forEach(1, (key, val) ->
            System.out.println(key + "=" + val));

        // search 병렬 — null이 아닌 첫 결과 반환
        String found = map.search(1, (key, val) ->
            val > 90 ? key : null);
        System.out.println("90점 초과: " + found);

        // reduce 병렬
        int total = map.reduce(1,
            (key, val) -> val,
            Integer::sum);
        System.out.println("총점: " + total);

        // 주의: size()는 근사값!
        // mappingCount()가 더 정확
        System.out.println("크기: " + map.mappingCount());
    }
}
알아두면 좋은 점

ConcurrentHashMap은 세그먼트 단위 잠금으로 높은 동시성을 제공합니다. Collections.synchronizedMap()보다 훨씬 빠릅니다.

자주 하는 실수

ConcurrentHashMap은 key와 value에 null을 허용하지 않습니다. HashMap과 다르니 주의하세요.

08TreeMap과 TreeSet

정렬된 컬렉션 TreeMap/TreeSet의 Red-Black Tree 기반 동작입니다.

Java code

import java.util.*;

public class TreeCollections {
    public static void main(String[] args) {
        // TreeMap — 키 기준 자동 정렬 (Red-Black Tree)
        TreeMap<String, Integer> scores = new TreeMap<>();
        scores.put("Charlie", 85);
        scores.put("Alice", 95);
        scores.put("Bob", 90);
        System.out.println(scores); // {Alice=95, Bob=90, Charlie=85}

        // 범위 조회 O(log n)
        System.out.println(scores.firstKey());          // Alice
        System.out.println(scores.lastKey());           // Charlie
        System.out.println(scores.headMap("C"));        // {Alice, Bob}
        System.out.println(scores.subMap("B", "D"));    // {Bob, Charlie}

        // 가장 가까운 키
        System.out.println(scores.ceilingKey("Bc"));    // Bob
        System.out.println(scores.floorKey("Bc"));      // Bob

        // TreeSet — 정렬된 집합
        TreeSet<Integer> nums = new TreeSet<>(List.of(5, 1, 8, 3, 7));
        System.out.println(nums);               // [1, 3, 5, 7, 8]
        System.out.println(nums.headSet(5));     // [1, 3]
        System.out.println(nums.tailSet(5));     // [5, 7, 8]

        // 커스텀 정렬
        TreeSet<String> reverse = new TreeSet<>(Comparator.reverseOrder());
        reverse.addAll(List.of("A", "C", "B"));
        System.out.println(reverse); // [C, B, A]
    }
}
알아두면 좋은 점

범위 검색, 정렬된 순회, 최대/최소값 조회가 빈번하면 TreeMap/TreeSet이 적합합니다. 모든 연산이 O(log n)입니다.

자주 하는 실수

TreeMap의 키는 Comparable이거나 Comparator를 제공해야 합니다. 그렇지 않으면 ClassCastException이 발생합니다.

09PriorityQueue

우선순위 큐의 힙(Heap) 기반 동작과 활용법입니다.

Java code

import java.util.*;

public class PriorityQueueDemo {
    record Task(String name, int priority) implements Comparable<Task> {
        @Override
        public int compareTo(Task other) {
            return Integer.compare(this.priority, other.priority);
        }
    }

    public static void main(String[] args) {
        // 최소 힙 (기본)
        PriorityQueue<Integer> minHeap = new PriorityQueue<>();
        minHeap.addAll(List.of(5, 1, 3, 2, 4));
        while (!minHeap.isEmpty()) {
            System.out.print(minHeap.poll() + " "); // 1 2 3 4 5
        }
        System.out.println();

        // 최대 힙
        PriorityQueue<Integer> maxHeap =
            new PriorityQueue<>(Comparator.reverseOrder());
        maxHeap.addAll(List.of(5, 1, 3, 2, 4));
        System.out.println(maxHeap.poll()); // 5

        // 커스텀 객체
        PriorityQueue<Task> taskQueue = new PriorityQueue<>();
        taskQueue.add(new Task("낮은 우선순위", 3));
        taskQueue.add(new Task("긴급!", 1));
        taskQueue.add(new Task("보통", 2));

        while (!taskQueue.isEmpty()) {
            Task t = taskQueue.poll();
            System.out.printf("[%d] %s%n", t.priority(), t.name());
        }
        // [1] 긴급! → [2] 보통 → [3] 낮은 우선순위
    }
}
알아두면 좋은 점

PriorityQueue를 순회하면 정렬 순서가 보장되지 않습니다. 정렬 순서로 꺼내려면 poll()을 반복하세요.

자주 하는 실수

PriorityQueue는 스레드 안전하지 않습니다. 멀티스레드에서는 PriorityBlockingQueue를 사용하세요.

10Deque 인터페이스 활용

Deque를 스택과 큐로 활용하는 방법입니다.

Java code

import java.util.*;

public class DequeDemo {
    public static void main(String[] args) {
        // ArrayDeque — Stack과 Queue 대체
        Deque<String> deque = new ArrayDeque<>();

        // 스택으로 사용 (LIFO)
        deque.push("A");        // addFirst
        deque.push("B");
        deque.push("C");
        System.out.println(deque.pop());  // C (removeFirst)
        System.out.println(deque.peek()); // B (peekFirst)

        // 큐로 사용 (FIFO)
        Deque<String> queue = new ArrayDeque<>();
        queue.offer("1번");     // addLast
        queue.offer("2번");
        queue.offer("3번");
        System.out.println(queue.poll()); // 1번 (removeFirst)

        // 양쪽 모두 접근
        deque.addFirst("앞");
        deque.addLast("뒤");
        System.out.println(deque.peekFirst()); // 앞
        System.out.println(deque.peekLast());  // 뒤

        // 실전: 괄호 검사
        String expr = "({[]})";
        boolean valid = isBalanced(expr);
        System.out.println(expr + " 유효: " + valid);
    }

    static boolean isBalanced(String expr) {
        Deque<Character> stack = new ArrayDeque<>();
        for (char c : expr.toCharArray()) {
            if ("({[".indexOf(c) >= 0) stack.push(c);
            else if (")}]".indexOf(c) >= 0) {
                if (stack.isEmpty()) return false;
                char open = stack.pop();
                if ((c == ')' && open != '(') ||
                    (c == '}' && open != '{') ||
                    (c == ']' && open != '[')) return false;
            }
        }
        return stack.isEmpty();
    }
}
알아두면 좋은 점

ArrayDequeStack 클래스보다 빠르고, LinkedList보다 메모리 효율적입니다. 스택/큐가 필요하면 ArrayDeque를 쓰세요.

자주 하는 실수

ArrayDequenull을 허용하지 않습니다. null이 필요하면 LinkedList를 사용하세요.

11Collections 유틸리티

Collections 클래스의 유용한 정적 메서드를 활용합니다.

Java code

import java.util.*;

public class CollectionsUtil {
    public static void main(String[] args) {
        List<Integer> list = new ArrayList<>(List.of(3, 1, 4, 1, 5, 9));

        // 정렬
        Collections.sort(list);
        System.out.println(list); // [1, 1, 3, 4, 5, 9]

        // 이진 검색 (정렬 후)
        int idx = Collections.binarySearch(list, 4);
        System.out.println("4의 인덱스: " + idx);

        // 최대/최소
        System.out.println("최대: " + Collections.max(list));
        System.out.println("최소: " + Collections.min(list));

        // 셔플
        Collections.shuffle(list);
        System.out.println("셔플: " + list);

        // 빈도
        System.out.println("1의 빈도: " + Collections.frequency(list, 1));

        // 불변 래퍼 (읽기 전용 뷰)
        List<Integer> readOnly = Collections.unmodifiableList(list);
        // readOnly.add(10); // UnsupportedOperationException

        // 동기화 래퍼
        List<Integer> syncList = Collections.synchronizedList(
            new ArrayList<>());

        // 싱글톤 / 빈 컬렉션
        List<String> single = Collections.singletonList("only");
        List<String> empty = Collections.emptyList();
        // Java 9+에서는 List.of("only"), List.of() 권장
    }
}
알아두면 좋은 점

Java 9+ 팩토리 메서드(List.of(), Map.of())가 Collections.unmodifiable*()보다 간결하고 메모리 효율적입니다.

자주 하는 실수

Collections.unmodifiableList()는 원본을 래핑한 뷰입니다. 원본 리스트를 변경하면 뷰에도 반영됩니다.

12Comparator 심화

Comparator의 팩토리 메서드와 체이닝으로 복잡한 정렬을 구현합니다.

Java code

import java.util.*;

public class ComparatorAdvanced {
    record Student(String name, int grade, double score) {}

    public static void main(String[] args) {
        List<Student> students = new ArrayList<>(List.of(
            new Student("홍길동", 3, 85.5),
            new Student("김철수", 1, 92.0),
            new Student("이영희", 2, 85.5),
            new Student("박민수", 1, 88.0)
        ));

        // 단일 기준 정렬
        students.sort(Comparator.comparing(Student::name));

        // 다중 기준: 점수 내림차순 -> 학년 오름차순 -> 이름
        Comparator<Student> comp = Comparator
            .comparingDouble(Student::score).reversed()
            .thenComparingInt(Student::grade)
            .thenComparing(Student::name);

        students.sort(comp);
        students.forEach(s -> System.out.printf(
            "%s (학년:%d, 점수:%.1f)%n", s.name(), s.grade(), s.score()));

        // null 안전 정렬
        List<String> withNull = new ArrayList<>(
            Arrays.asList("B", null, "A", null, "C"));
        withNull.sort(Comparator.nullsLast(Comparator.naturalOrder()));
        System.out.println(withNull); // [A, B, C, null, null]
    }
}
알아두면 좋은 점

Comparator.comparing()에 원시 타입 특화 버전(comparingInt, comparingDouble)을 사용하면 오토박싱을 방지합니다.

자주 하는 실수

Comparator에서 뺄셈(a - b)으로 비교하면 오버플로우가 발생할 수 있습니다. Integer.compare()를 사용하세요.

13Stream 심화 — flatMap, groupingBy

flatMap으로 중첩 구조를 평탄화하고 groupingBy로 그룹화합니다.

Java code

import java.util.*;
import java.util.stream.*;

public class StreamAdvanced {
    record Order(String customer, List<String> items) {}

    public static void main(String[] args) {
        List<Order> orders = List.of(
            new Order("Alice", List.of("커피", "케이크")),
            new Order("Bob", List.of("녹차")),
            new Order("Alice", List.of("주스", "커피"))
        );

        // flatMap — 중첩 리스트 평탄화
        List<String> allItems = orders.stream()
            .flatMap(o -> o.items().stream())
            .distinct()
            .toList();
        System.out.println("전체 품목: " + allItems);

        // groupingBy — 그룹화
        Map<String, List<Order>> byCustomer = orders.stream()
            .collect(Collectors.groupingBy(Order::customer));
        System.out.println("고객별: " + byCustomer);

        // groupingBy + counting
        Map<String, Long> itemCount = orders.stream()
            .flatMap(o -> o.items().stream())
            .collect(Collectors.groupingBy(
                item -> item, Collectors.counting()));
        System.out.println("품목별 수: " + itemCount);

        // partitioningBy — true/false 분리
        Map<Boolean, List<String>> partition = allItems.stream()
            .collect(Collectors.partitioningBy(
                s -> s.length() > 2));
        System.out.println("2글자 초과 분리: " + partition);
    }
}
알아두면 좋은 점

groupingBy의 두 번째 인자(downstream collector)로 counting(), summingInt(), mapping() 등을 조합하면 강력한 집계가 가능합니다.

자주 하는 실수

flatMap에서 null 스트림을 반환하면 NullPointerException이 발생합니다. Stream.empty()를 반환하세요.

14Collectors 활용

Collectors의 다양한 수집 전략으로 스트림 결과를 변환합니다.

Java code

import java.util.*;
import java.util.stream.*;

public class CollectorsDemo {
    record Product(String name, String category, int price) {}

    public static void main(String[] args) {
        List<Product> products = List.of(
            new Product("노트북", "전자", 1500000),
            new Product("키보드", "전자", 80000),
            new Product("책", "도서", 25000),
            new Product("펜", "문구", 3000)
        );

        // joining
        String names = products.stream()
            .map(Product::name)
            .collect(Collectors.joining(", ", "[", "]"));
        System.out.println(names); // [노트북, 키보드, 책, 펜]

        // toMap
        Map<String, Integer> priceMap = products.stream()
            .collect(Collectors.toMap(Product::name, Product::price));

        // summarizingInt
        IntSummaryStatistics stats = products.stream()
            .collect(Collectors.summarizingInt(Product::price));
        System.out.printf("합계:%d, 평균:%.0f, 최대:%d%n",
            stats.getSum(), stats.getAverage(), stats.getMax());

        // groupingBy + downstream
        Map<String, IntSummaryStatistics> byCategory =
            products.stream().collect(
                Collectors.groupingBy(Product::category,
                    Collectors.summarizingInt(Product::price)));

        // collectingAndThen
        List<Product> unmodifiable = products.stream()
            .filter(p -> p.price() > 10000)
            .collect(Collectors.collectingAndThen(
                Collectors.toList(),
                Collections::unmodifiableList));
    }
}
알아두면 좋은 점

Collectors.toMap()에서 키 충돌 시 merge function을 반드시 제공하세요. 없으면 IllegalStateException이 발생합니다.

자주 하는 실수

Collectors.toList()는 가변 리스트를 반환합니다. 불변이 필요하면 .toList()(Java 16+)나 toUnmodifiableList()를 사용하세요.

15불변 컬렉션 (List.of, Map.of)

Java 9+의 팩토리 메서드로 불변 컬렉션을 생성하고 활용합니다.

Java code

import java.util.*;

public class ImmutableCollections {
    public static void main(String[] args) {
        // List.of — 불변 리스트
        List<String> list = List.of("A", "B", "C");
        // list.add("D"); // UnsupportedOperationException

        // Set.of — 불변 집합
        Set<Integer> set = Set.of(1, 2, 3);
        // 중복 시 IllegalArgumentException

        // Map.of — 최대 10개 엔트리
        Map<String, Integer> map = Map.of("a", 1, "b", 2);

        // Map.ofEntries — 10개 초과
        Map<String, Integer> large = Map.ofEntries(
            Map.entry("key1", 1),
            Map.entry("key2", 2),
            Map.entry("key3", 3)
        );

        // 기존 컬렉션을 불변으로 복사
        List<String> mutable = new ArrayList<>(List.of("X", "Y"));
        List<String> immutable = List.copyOf(mutable);
        mutable.add("Z"); // 원본은 변경 가능
        System.out.println(mutable);    // [X, Y, Z]
        System.out.println(immutable);  // [X, Y] (영향 없음)

        // Collections.unmodifiable* vs List.of
        // unmodifiable: 래퍼 (원본 변경 반영)
        // List.of/copyOf: 독립 복사본
    }
}
알아두면 좋은 점

List.of()는 null을 허용하지 않습니다. null이 포함될 수 있으면 Collections.unmodifiableList()를 사용하세요.

자주 하는 실수

Set.of(1, 1)처럼 중복 요소를 넘기면 IllegalArgumentException이 발생합니다. 중복이 있을 수 있으면 Set.copyOf(list)를 사용하세요.

16커스텀 Collector 만들기

Collector 인터페이스를 직접 구현하여 맞춤 수집 전략을 만듭니다.

Java code

import java.util.*;
import java.util.stream.*;

public class CustomCollector {
    // 문자열을 역순으로 연결하는 Collector
    static Collector<String, ?, String> reverseJoining(String delimiter) {
        return Collector.of(
            // supplier: 컨테이너 생성
            () -> new ArrayDeque<String>(),
            // accumulator: 원소 추가
            (deque, item) -> deque.addFirst(item),
            // combiner: 병렬 시 결합
            (left, right) -> { right.addAll(left); return right; },
            // finisher: 최종 변환
            deque -> String.join(delimiter, deque),
            // characteristics
            Collector.Characteristics.IDENTITY_FINISH
        );
    }

    // Top N 수집기
    static <T extends Comparable<T>> Collector<T, ?, List<T>> topN(int n) {
        return Collector.of(
            () -> new PriorityQueue<T>(n),
            (pq, item) -> {
                pq.add(item);
                if (pq.size() > n) pq.poll();
            },
            (left, right) -> {
                left.addAll(right);
                while (left.size() > n) left.poll();
                return left;
            },
            pq -> {
                List<T> result = new ArrayList<>(pq);
                Collections.sort(result, Comparator.reverseOrder());
                return result;
            }
        );
    }

    public static void main(String[] args) {
        String result = List.of("A", "B", "C").stream()
            .collect(reverseJoining(" -> "));
        System.out.println(result); // C -> B -> A

        List<Integer> top3 = java.util.stream.IntStream.range(1, 100)
            .boxed().collect(topN(3));
        System.out.println(top3); // [99, 98, 97]
    }
}
알아두면 좋은 점

Collector.of()의 5개 인자(supplier, accumulator, combiner, finisher, characteristics)를 이해하면 어떤 수집 로직이든 구현할 수 있습니다.

자주 하는 실수

combiner가 올바르지 않으면 병렬 스트림에서 결과가 틀립니다. 순차 스트림에서 동작해도 병렬에서 실패할 수 있으니 반드시 검증하세요.

17병렬 스트림

parallelStream()의 동작 원리, 성능 주의사항, 적합한 사용 시나리오입니다.

Java code

import java.util.*;
import java.util.concurrent.*;
import java.util.stream.*;

public class ParallelStreamDemo {
    public static void main(String[] args) {
        List<Integer> numbers = IntStream.rangeClosed(1, 1_000_000)
            .boxed().toList();

        // 순차 vs 병렬
        long start = System.nanoTime();
        long seqSum = numbers.stream()
            .mapToLong(Integer::longValue).sum();
        long seqTime = System.nanoTime() - start;

        start = System.nanoTime();
        long parSum = numbers.parallelStream()
            .mapToLong(Integer::longValue).sum();
        long parTime = System.nanoTime() - start;

        System.out.printf("순차: %dns, 병렬: %dns%n", seqTime, parTime);

        // 병렬이 유리한 경우: CPU 집약적 작업
        List<Double> results = numbers.parallelStream()
            .map(n -> Math.sqrt(n) * Math.log(n))
            .toList();

        // 커스텀 ForkJoinPool 사용 (공유 풀 분리)
        ForkJoinPool customPool = new ForkJoinPool(4);
        try {
            long result = customPool.submit(() ->
                numbers.parallelStream()
                    .mapToLong(n -> (long) n * n)
                    .sum()
            ).get();
            System.out.println("커스텀 풀 결과: " + result);
        } catch (Exception e) {
            e.printStackTrace();
        } finally {
            customPool.shutdown();
        }
    }
}
알아두면 좋은 점

병렬 스트림은 기본적으로 ForkJoinPool.commonPool()을 사용합니다. I/O 작업이 포함되면 커스텀 풀을 사용하세요.

자주 하는 실수

병렬 스트림에서 공유 가변 상태를 수정하면 경쟁 조건이 발생합니다. forEach에서 외부 리스트에 add하지 마세요.

18Spliterator

Spliterator의 분할-순회 원리와 커스텀 구현을 알아봅니다.

Java code

import java.util.*;
import java.util.stream.*;

public class SpliteratorDemo {
    // 고정 크기 배치를 위한 커스텀 Spliterator
    static <T> Stream<List<T>> batch(Stream<T> stream, int batchSize) {
        Spliterator<T> src = stream.spliterator();

        return StreamSupport.stream(new Spliterators.AbstractSpliterator<>(
            src.estimateSize() / batchSize,
            src.characteristics()) {

            @Override
            public boolean tryAdvance(java.util.function.Consumer<? super List<T>> action) {
                List<T> batch = new ArrayList<>(batchSize);
                for (int i = 0; i < batchSize; i++) {
                    if (!src.tryAdvance(batch::add)) break;
                }
                if (batch.isEmpty()) return false;
                action.accept(batch);
                return true;
            }
        }, false);
    }

    public static void main(String[] args) {
        // 기본 Spliterator 확인
        List<String> list = List.of("A", "B", "C", "D", "E");
        Spliterator<String> sp = list.spliterator();
        System.out.println("크기: " + sp.estimateSize());
        System.out.println("특성: " + sp.characteristics());

        // 배치 스트림 활용
        List<Integer> numbers = IntStream.rangeClosed(1, 10)
            .boxed().toList();

        batch(numbers.stream(), 3).forEach(b ->
            System.out.println("배치: " + b));
        // 배치: [1, 2, 3] → [4, 5, 6] → [7, 8, 9] → [10]
    }
}
알아두면 좋은 점

Spliterator.characteristics()ORDERED, SORTED, SIZED 등의 힌트를 제공하면 스트림이 더 효율적으로 최적화합니다.

자주 하는 실수

trySplit()을 잘못 구현하면 병렬 스트림에서 데이터 누락이나 중복이 발생합니다. 반드시 원소를 정확히 분할하세요.

정리하며

  • 뜨거운 연산이 인덱스 접근인지 중간 삽입인지 범위 검색인지부터 정하고 구현체를 고릅니다
  • Arrays.asList는 고정 크기 뷰이고 List.of는 진짜 불변이며 null 요소를 거부합니다
  • HashMap 키로 쓰는 객체는 불변이어야 합니다. 필드를 바꾸면 해당 항목을 찾을 수 없습니다
  • 크기를 알 수 있으면 ArrayList와 HashMap에 초기 용량을 넘겨 재해싱과 배열 복사를 없앱니다

더 깊이 들어가고 싶다면 Java 학습 라이브러리에서 다른 주제 가이드를 이어서 보거나, 언어 비교에서 같은 개념이 다른 언어에서 어떻게 표현되는지 확인해 보세요.