Repository

3단계: Java 컬렉션 & 제네릭 본문

Java

3단계: Java 컬렉션 & 제네릭

Mr.Manager 2025. 12. 11. 20:20
반응형

3단계: Java 컬렉션 & 제네릭

실무에서 가장 많이 사용하는 컬렉션 프레임워크와 제네릭을 내부 동작 원리부터 성능 최적화까지 완벽하게 정리했습니다.


3.1 컬렉션 프레임워크

List 완벽 가이드: ArrayList vs LinkedList

내부 구조와 동작 원리

ArrayList: 동적 배열

public class ArrayList<E> extends AbstractList<E>
    implements List<E>, RandomAccess, Cloneable, java.io.Serializable {

    // 내부적으로 배열 사용
    private Object[] elementData;
    private int size;

    // 기본 용량
    private static final int DEFAULT_CAPACITY = 10;

    // 생성자
    public ArrayList() {
        this.elementData = new Object[DEFAULT_CAPACITY];
    }

    public ArrayList(int initialCapacity) {
        this.elementData = new Object[initialCapacity];
    }

    // 추가
    public boolean add(E e) {
        ensureCapacityInternal(size + 1);  // 용량 확인
        elementData[size++] = e;
        return true;
    }

    // 용량 확장 (기존 용량의 1.5배)
    private void grow(int minCapacity) {
        int oldCapacity = elementData.length;
        int newCapacity = oldCapacity + (oldCapacity >> 1);  // 1.5배
        elementData = Arrays.copyOf(elementData, newCapacity);
    }

    // 조회
    public E get(int index) {
        rangeCheck(index);
        return (E) elementData[index];  // O(1)
    }

    // 삭제
    public E remove(int index) {
        rangeCheck(index);
        E oldValue = (E) elementData[index];

        int numMoved = size - index - 1;
        if (numMoved > 0) {
            // 배열 이동 (느림!)
            System.arraycopy(elementData, index + 1, 
                           elementData, index, numMoved);
        }
        elementData[--size] = null;
        return oldValue;
    }
}

메모리 레이아웃

ArrayList: 연속된 메모리 공간
┌────┬────┬────┬────┬────┬────┬────┐
│ 10 │ 20 │ 30 │ 40 │ 50 │null│null│ ← elementData
└────┴────┴────┴────┴────┴────┴────┘
 idx0  idx1 idx2 idx3 idx4

장점: 인덱스 접근 빠름 (O(1))
단점: 중간 삽입/삭제 느림 (O(n))

LinkedList: 이중 연결 리스트

public class LinkedList<E> extends AbstractSequentialList<E>
    implements List<E>, Deque<E>, Cloneable, java.io.Serializable {

    // 내부 노드 클래스
    private static class Node<E> {
        E item;
        Node<E> next;
        Node<E> prev;

        Node(Node<E> prev, E element, Node<E> next) {
            this.item = element;
            this.next = next;
            this.prev = prev;
        }
    }

    transient Node<E> first;  // 첫 번째 노드
    transient Node<E> last;   // 마지막 노드
    transient int size = 0;

    // 추가 (끝에 추가)
    public boolean add(E e) {
        linkLast(e);
        return true;
    }

    void linkLast(E e) {
        final Node<E> l = last;
        final Node<E> newNode = new Node<>(l, e, null);
        last = newNode;
        if (l == null)
            first = newNode;
        else
            l.next = newNode;
        size++;
    }

    // 조회 (순차 탐색)
    public E get(int index) {
        checkElementIndex(index);
        return node(index).item;  // O(n)
    }

    // 노드 찾기 (최적화: 반으로 나눠 탐색)
    Node<E> node(int index) {
        if (index < (size >> 1)) {
            // 앞에서부터 탐색
            Node<E> x = first;
            for (int i = 0; i < index; i++)
                x = x.next;
            return x;
        } else {
            // 뒤에서부터 탐색
            Node<E> x = last;
            for (int i = size - 1; i > index; i--)
                x = x.prev;
            return x;
        }
    }

    // 삭제 (노드 연결만 변경)
    E unlink(Node<E> x) {
        final E element = x.item;
        final Node<E> next = x.next;
        final Node<E> prev = x.prev;

        if (prev == null) {
            first = next;
        } else {
            prev.next = next;
            x.prev = null;
        }

        if (next == null) {
            last = prev;
        } else {
            next.prev = prev;
            x.next = null;
        }

        x.item = null;
        size--;
        return element;
    }
}

메모리 레이아웃

LinkedList: 노드들이 링크로 연결
┌────────┐    ┌────────┐    ┌────────┐    ┌────────┐
│ data:10│◄──►│ data:20│◄──►│ data:30│◄──►│ data:40│
│prev:null    │prev:○  │prev:○  │prev:○  │next:null
│next:○  │    │next:○  │    │next:○  │    
└────────┘    └────────┘    └────────┘    └────────┘

장점: 삽입/삭제 빠름 (O(1) - 노드만 찾으면)
단점: 인덱스 접근 느림 (O(n))
      메모리 오버헤드 (prev, next 포인터)

성능 비교 (JMH 벤치마크)

import org.openjdk.jmh.annotations.*;
import java.util.*;
import java.util.concurrent.TimeUnit;

@BenchmarkMode(Mode.AverageTime)
@OutputTimeUnit(TimeUnit.MICROSECONDS)
@State(Scope.Thread)
@Warmup(iterations = 5, time = 1)
@Measurement(iterations = 10, time = 1)
@Fork(1)
public class ListBenchmark {

    @Param({"1000", "10000", "100000"})
    private int size;

    private List<Integer> arrayList;
    private List<Integer> linkedList;

    @Setup
    public void setup() {
        arrayList = new ArrayList<>();
        linkedList = new LinkedList<>();

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

    // 1. 끝에 추가
    @Benchmark
    public void arrayListAddLast() {
        arrayList.add(999);
    }

    @Benchmark
    public void linkedListAddLast() {
        linkedList.add(999);
    }

    // 2. 처음에 추가
    @Benchmark
    public void arrayListAddFirst() {
        arrayList.add(0, 999);
    }

    @Benchmark
    public void linkedListAddFirst() {
        linkedList.add(0, 999);
    }

    // 3. 중간에 추가
    @Benchmark
    public void arrayListAddMiddle() {
        arrayList.add(size / 2, 999);
    }

    @Benchmark
    public void linkedListAddMiddle() {
        linkedList.add(size / 2, 999);
    }

    // 4. 인덱스 접근
    @Benchmark
    public int arrayListGet() {
        return arrayList.get(size / 2);
    }

    @Benchmark
    public int linkedListGet() {
        return linkedList.get(size / 2);
    }

    // 5. 순회
    @Benchmark
    public long arrayListIterate() {
        long sum = 0;
        for (int i = 0; i < arrayList.size(); i++) {
            sum += arrayList.get(i);
        }
        return sum;
    }

    @Benchmark
    public long linkedListIterate() {
        long sum = 0;
        for (int i = 0; i < linkedList.size(); i++) {
            sum += linkedList.get(i);
        }
        return sum;
    }

    // 6. Enhanced for 순회
    @Benchmark
    public long arrayListForEach() {
        long sum = 0;
        for (Integer num : arrayList) {
            sum += num;
        }
        return sum;
    }

    @Benchmark
    public long linkedListForEach() {
        long sum = 0;
        for (Integer num : linkedList) {
            sum += num;
        }
        return sum;
    }
}

/**
 * 벤치마크 결과 (예시, 환경마다 다름):
 * 
 * size = 10,000 기준
 * 
 * 끝에 추가:
 * - ArrayList:   0.003 µs/op  ✅ 빠름
 * - LinkedList:  0.003 µs/op  ✅ 빠름
 * 
 * 처음에 추가:
 * - ArrayList:  25.5 µs/op    ❌ 느림 (전체 복사)
 * - LinkedList:  0.003 µs/op  ✅ 빠름
 * 
 * 중간에 추가:
 * - ArrayList:  12.8 µs/op    ❌ 느림
 * - LinkedList: 1.2 µs/op     ✅ 더 빠름
 * 
 * 인덱스 접근:
 * - ArrayList:  0.002 µs/op   ✅ 매우 빠름 (O(1))
 * - LinkedList: 1.5 µs/op     ❌ 느림 (O(n/2))
 * 
 * 인덱스 순회:
 * - ArrayList:  15 µs/op      ✅ 빠름
 * - LinkedList: 15,000 µs/op  ❌ 1000배 느림!
 * 
 * Enhanced for 순회:
 * - ArrayList:  15 µs/op      ✅ 빠름
 * - LinkedList: 18 µs/op      ✅ 비슷 (Iterator 사용)
 */

실전 사용 시나리오

ArrayList를 사용하는 경우

public class ArrayListUseCases {
    // 1. 조회가 많은 경우
    public User findUserById(List<User> users, Long id) {
        // 랜덤 액세스가 빈번함 → ArrayList
        for (int i = 0; i < users.size(); i++) {
            if (users.get(i).getId().equals(id)) {
                return users.get(i);
            }
        }
        return null;
    }

    // 2. 끝에 추가만 하는 경우
    public void collectLogs(List<String> logs, String newLog) {
        logs.add(newLog);  // O(1) amortized
    }

    // 3. 크기를 미리 아는 경우
    public List<Integer> generateNumbers(int count) {
        List<Integer> numbers = new ArrayList<>(count);  // 초기 용량 지정
        for (int i = 0; i < count; i++) {
            numbers.add(i);  // 재할당 없음!
        }
        return numbers;
    }

    // 4. Stream 처리
    public List<String> processNames(List<User> users) {
        return users.stream()  // ArrayList의 stream()이 효율적
            .map(User::getName)
            .filter(name -> name.length() > 3)
            .collect(Collectors.toList());
    }

    // 5. 정렬
    public void sortUsers(List<User> users) {
        users.sort(Comparator.comparing(User::getAge));  // ArrayList는 정렬이 빠름
    }
}

LinkedList를 사용하는 경우

public class LinkedListUseCases {
    // 1. Queue/Deque로 사용
    public class TaskQueue {
        private Deque<Task> queue = new LinkedList<>();

        public void enqueue(Task task) {
            queue.addLast(task);  // O(1)
        }

        public Task dequeue() {
            return queue.pollFirst();  // O(1)
        }

        public Task peek() {
            return queue.peekFirst();  // O(1)
        }
    }

    // 2. 양쪽 삽입/삭제가 빈번한 경우
    public class RecentlyViewed {
        private LinkedList<String> items = new LinkedList<>();
        private static final int MAX_SIZE = 10;

        public void addItem(String item) {
            // 이미 있으면 제거
            items.remove(item);
            // 맨 앞에 추가
            items.addFirst(item);  // O(1)
            // 크기 제한
            if (items.size() > MAX_SIZE) {
                items.removeLast();  // O(1)
            }
        }
    }

    // 3. LRU 캐시 구현
    public class LRUCache<K, V> {
        private int capacity;
        private Map<K, Node<K, V>> map = new HashMap<>();
        private DoublyLinkedList<K, V> list = new DoublyLinkedList<>();

        public V get(K key) {
            Node<K, V> node = map.get(key);
            if (node == null) return null;

            // 최근 사용으로 이동
            list.moveToHead(node);  // O(1)
            return node.value;
        }

        public void put(K key, V value) {
            Node<K, V> node = map.get(key);
            if (node != null) {
                node.value = value;
                list.moveToHead(node);  // O(1)
            } else {
                Node<K, V> newNode = new Node<>(key, value);
                map.put(key, newNode);
                list.addFirst(newNode);  // O(1)

                if (map.size() > capacity) {
                    Node<K, V> tail = list.removeLast();  // O(1)
                    map.remove(tail.key);
                }
            }
        }
    }

    // 4. 중간 삽입/삭제가 많은 경우
    public void processEvents(LinkedList<Event> events) {
        Iterator<Event> iterator = events.iterator();
        while (iterator.hasNext()) {
            Event event = iterator.next();
            if (event.isExpired()) {
                iterator.remove();  // O(1) - 현재 위치에서 삭제
            } else if (event.needsUpdate()) {
                // 현재 위치 뒤에 새 이벤트 추가
                // LinkedList에서 효율적
            }
        }
    }
}

의사결정 가이드

/**
 * ArrayList 선택:
 * ✅ 조회가 많다 (get() 빈번)
 * ✅ 끝에 추가만 한다
 * ✅ 크기를 미리 안다
 * ✅ 메모리 효율이 중요하다
 * ✅ Cache locality가 중요하다
 * ✅ 일반적인 경우 (90%)
 * 
 * LinkedList 선택:
 * ✅ 처음/끝 삽입/삭제가 많다
 * ✅ Queue/Deque가 필요하다
 * ✅ 중간 삽입/삭제가 많다 (Iterator 사용)
 * ✅ 순회만 한다 (Enhanced for)
 * 
 * ❌ LinkedList 피해야 할 경우:
 * - 인덱스 접근이 필요하다
 * - 랜덤 액세스가 많다
 * - 정렬이 필요하다
 * - Stream 처리가 많다
 */

public class ListDecisionTree {
    public List<String> chooseList() {
        // 대부분의 경우: ArrayList
        return new ArrayList<>();

        // Queue가 필요: LinkedList 또는 ArrayDeque
        // (ArrayDeque가 더 빠름!)
        Deque<String> queue = new ArrayDeque<>();

        // 동시성: CopyOnWriteArrayList
        List<String> concurrentList = new CopyOnWriteArrayList<>();
    }
}

ArrayList 최적화 팁

public class ArrayListOptimization {
    // 1. 초기 용량 지정
    public List<User> loadUsers(int expectedSize) {
        // ✅ 재할당 방지
        List<User> users = new ArrayList<>(expectedSize);
        // 데이터 로드...
        return users;
    }

    // 2. ensureCapacity 사용
    public void addManyItems(List<String> list) {
        if (list instanceof ArrayList) {
            ((ArrayList<String>) list).ensureCapacity(list.size() + 10000);
        }
        for (int i = 0; i < 10000; i++) {
            list.add("Item " + i);
        }
    }

    // 3. trimToSize로 메모리 절약
    public List<String> buildList() {
        ArrayList<String> list = new ArrayList<>(1000);
        // 실제로는 100개만 추가됨
        for (int i = 0; i < 100; i++) {
            list.add("Item " + i);
        }
        list.trimToSize();  // 900개 공간 반환
        return list;
    }

    // 4. subList 주의
    public void subListPitfall() {
        List<String> original = new ArrayList<>(Arrays.asList("A", "B", "C", "D", "E"));

        // ❌ subList는 원본을 참조! (메모리 누수 위험)
        List<String> sub = original.subList(0, 2);  // [A, B]

        // ✅ 새 리스트 생성
        List<String> newList = new ArrayList<>(original.subList(0, 2));
    }

    // 5. 삭제 최적화
    public void efficientRemove(List<String> list) {
        // ❌ 느림: 매번 배열 이동
        for (int i = 0; i < list.size(); i++) {
            if (list.get(i).startsWith("remove")) {
                list.remove(i);
                i--;  // 인덱스 조정
            }
        }

        // ✅ 빠름: Iterator 사용
        Iterator<String> iter = list.iterator();
        while (iter.hasNext()) {
            if (iter.next().startsWith("remove")) {
                iter.remove();
            }
        }

        // ✅✅ 가장 빠름: removeIf
        list.removeIf(s -> s.startsWith("remove"));
    }
}

Set의 모든 것: HashSet, TreeSet, LinkedHashSet

중복 제거의 원리

HashSet: 해시 테이블 기반

public class HashSet<E> extends AbstractSet<E>
    implements Set<E>, Cloneable, java.io.Serializable {

    // 내부적으로 HashMap 사용!
    private transient HashMap<E, Object> map;

    // 더미 값
    private static final Object PRESENT = new Object();

    public HashSet() {
        map = new HashMap<>();
    }

    // 추가
    public boolean add(E e) {
        return map.put(e, PRESENT) == null;  // 새로 추가되면 true
    }

    // 포함 확인
    public boolean contains(Object o) {
        return map.containsKey(o);  // O(1)
    }

    // 삭제
    public boolean remove(Object o) {
        return map.remove(o) == PRESENT;
    }
}

중복 제거 원리: hashCode()와 equals()

public class HashSetInternals {
    public static void main(String[] args) {
        Set<User> users = new HashSet<>();

        User user1 = new User(1L, "Alice");
        User user2 = new User(1L, "Alice");

        users.add(user1);
        users.add(user2);  // 중복 제거될까?

        System.out.println(users.size());  // ???
    }
}

// ❌ hashCode(), equals() 미구현
class User {
    private Long id;
    private String name;

    // 생성자...
}
// 결과: 2 (중복 제거 안됨!)

// ✅ hashCode(), equals() 구현
class User {
    private Long id;
    private String name;

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;
        User user = (User) o;
        return Objects.equals(id, user.id);
    }

    @Override
    public int hashCode() {
        return Objects.hash(id);
    }
}
// 결과: 1 (중복 제거됨!)

HashSet 동작 과정

/**
 * HashSet에 요소 추가 과정:
 * 
 * 1. hashCode() 호출
 *    └─> 해시 값 계산
 *    
 * 2. 해시 값으로 버킷 찾기
 *    └─> bucket = hashCode & (capacity - 1)
 *    
 * 3. 버킷 내에서 equals() 비교
 *    └─> 같은 객체가 있으면 추가 안함
 *    └─> 없으면 추가
 */

public class HashSetExample {
    // hashCode() 계약
    public void hashCodeContract() {
        User u1 = new User(1L, "Alice");
        User u2 = new User(1L, "Alice");

        // equals()가 true면 hashCode()도 같아야 함
        assert u1.equals(u2);
        assert u1.hashCode() == u2.hashCode();

        // hashCode()가 다르면 equals()는 false (역은 성립 안함)
        User u3 = new User(2L, "Bob");
        assert u1.hashCode() != u3.hashCode();
        assert !u1.equals(u3);
    }

    // 나쁜 hashCode() 구현
    class BadUser {
        private Long id;
        private String name;

        @Override
        public boolean equals(Object o) {
            if (this == o) return true;
            if (!(o instanceof BadUser)) return false;
            BadUser user = (BadUser) o;
            return Objects.equals(id, user.id);
        }

        // ❌ 항상 같은 값 반환 (모든 객체가 같은 버킷에!)
        @Override
        public int hashCode() {
            return 42;
        }
    }

    // 좋은 hashCode() 구현
    class GoodUser {
        private Long id;
        private String name;
        private String email;

        @Override
        public boolean equals(Object o) {
            if (this == o) return true;
            if (!(o instanceof GoodUser)) return false;
            GoodUser user = (GoodUser) o;
            return Objects.equals(id, user.id);
        }

        // ✅ 여러 필드 조합 (Objects.hash 사용)
        @Override
        public int hashCode() {
            return Objects.hash(id, name, email);
        }

        // ✅✅ 수동 구현 (더 효율적)
        @Override
        public int hashCode() {
            int result = 17;
            result = 31 * result + (id != null ? id.hashCode() : 0);
            result = 31 * result + (name != null ? name.hashCode() : 0);
            result = 31 * result + (email != null ? email.hashCode() : 0);
            return result;
        }
    }
}

TreeSet: 정렬된 Set

public class TreeSet<E> extends AbstractSet<E>
    implements NavigableSet<E>, Cloneable, java.io.Serializable {

    // 내부적으로 TreeMap 사용 (Red-Black Tree)
    private transient NavigableMap<E, Object> m;
    private static final Object PRESENT = new Object();

    public TreeSet() {
        this(new TreeMap<>());
    }

    public TreeSet(Comparator<? super E> comparator) {
        this(new TreeMap<>(comparator));
    }

    // 추가 - O(log n)
    public boolean add(E e) {
        return m.put(e, PRESENT) == null;
    }

    // 특별한 메서드들
    public E first() {
        return m.firstKey();  // 최소값
    }

    public E last() {
        return m.lastKey();  // 최대값
    }

    public E lower(E e) {
        return m.lowerKey(e);  // e보다 작은 최대값
    }

    public E higher(E e) {
        return m.higherKey(e);  // e보다 큰 최소값
    }

    public SortedSet<E> subSet(E fromElement, E toElement) {
        return m.subMap(fromElement, toElement).keySet();
    }
}

TreeSet 활용 예제

public class TreeSetExamples {
    // 1. 자동 정렬
    public void autoSort() {
        Set<Integer> numbers = new TreeSet<>();
        numbers.add(5);
        numbers.add(2);
        numbers.add(8);
        numbers.add(1);

        System.out.println(numbers);  // [1, 2, 5, 8] - 자동 정렬!
    }

    // 2. 커스텀 정렬
    public void customSort() {
        // 역순 정렬
        Set<String> names = new TreeSet<>(Comparator.reverseOrder());
        names.addAll(Arrays.asList("Charlie", "Alice", "Bob"));
        System.out.println(names);  // [Charlie, Bob, Alice]

        // 길이순 정렬
        Set<String> words = new TreeSet<>(Comparator.comparing(String::length));
        words.add("apple");
        words.add("pie");
        words.add("banana");
        System.out.println(words);  // [pie, apple, banana]
    }

    // 3. 범위 검색
    public void rangeSearch() {
        TreeSet<Integer> scores = new TreeSet<>();
        scores.addAll(Arrays.asList(85, 90, 75, 95, 88, 92));

        // 90점 이상
        SortedSet<Integer> above90 = scores.tailSet(90);
        System.out.println(above90);  // [90, 92, 95]

        // 85점 이하
        SortedSet<Integer> below85 = scores.headSet(85, true);
        System.out.println(below85);  // [75, 85]

        // 85 ~ 92 사이
        SortedSet<Integer> between = scores.subSet(85, true, 92, true);
        System.out.println(between);  // [85, 88, 90, 92]
    }

    // 4. 순위 기반 검색
    public void rankBasedSearch() {
        TreeSet<Integer> set = new TreeSet<>(Arrays.asList(10, 20, 30, 40, 50));

        System.out.println(set.first());     // 10 (최소)
        System.out.println(set.last());      // 50 (최대)
        System.out.println(set.lower(30));   // 20 (30보다 작은 최대)
        System.out.println(set.higher(30));  // 40 (30보다 큰 최소)
        System.out.println(set.floor(35));   // 30 (35 이하 최대)
        System.out.println(set.ceiling(35)); // 40 (35 이상 최소)
    }

    // 5. 실무 예제: 순위표
    public class LeaderBoard {
        private TreeSet<Player> rankings = new TreeSet<>(
            Comparator.comparing(Player::getScore).reversed()
                .thenComparing(Player::getName)
        );

        public void addPlayer(Player player) {
            rankings.add(player);
        }

        public Player getTopPlayer() {
            return rankings.first();
        }

        public Set<Player> getTop10() {
            return rankings.stream()
                .limit(10)
                .collect(Collectors.toCollection(LinkedHashSet::new));
        }

        public int getRank(Player player) {
            return rankings.headSet(player, false).size() + 1;
        }
    }
}

LinkedHashSet: 삽입 순서 유지

public class LinkedHashSet<E> extends HashSet<E>
    implements Set<E>, Cloneable, java.io.Serializable {

    // 내부적으로 LinkedHashMap 사용
    public LinkedHashSet() {
        super(16, 0.75f, true);  // true = LinkedHashMap 사용
    }
}

LinkedHashSet 활용

public class LinkedHashSetExamples {
    // 1. 삽입 순서 유지
    public void insertionOrder() {
        Set<String> linkedSet = new LinkedHashSet<>();
        linkedSet.add("C");
        linkedSet.add("A");
        linkedSet.add("B");

        System.out.println(linkedSet);  // [C, A, B] - 삽입 순서!

        Set<String> hashSet = new HashSet<>();
        hashSet.add("C");
        hashSet.add("A");
        hashSet.add("B");

        System.out.println(hashSet);  // [A, B, C] 또는 임의 순서
    }

    // 2. LRU 캐시
    public class LRUSetCache<E> {
        private final int capacity;
        private final LinkedHashSet<E> cache;

        public LRUSetCache(int capacity) {
            this.capacity = capacity;
            this.cache = new LinkedHashSet<>(capacity, 0.75f);
        }

        public void add(E element) {
            // 이미 있으면 제거 후 재추가 (순서 변경)
            if (cache.contains(element)) {
                cache.remove(element);
            } else if (cache.size() >= capacity) {
                // 가장 오래된 것 제거
                E oldest = cache.iterator().next();
                cache.remove(oldest);
            }
            cache.add(element);
        }

        public boolean contains(E element) {
            return cache.contains(element);
        }
    }

    // 3. 중복 제거하면서 순서 유지
    public List<String> removeDuplicatesKeepOrder(List<String> list) {
        return new ArrayList<>(new LinkedHashSet<>(list));
    }
}

Set 비교 및 선택 가이드

/**
 * Set 구현체 비교:
 * 
 * ┌─────────────────┬──────────┬──────────┬────────────┬─────────┐
 * │     특성        │ HashSet  │ TreeSet  │LinkedHashSet│  성능   │
 * ├─────────────────┼──────────┼──────────┼────────────┼─────────┤
 * │ 추가            │ O(1)     │ O(log n) │ O(1)       │ Hash 빠름│
 * │ 삭제            │ O(1)     │ O(log n) │ O(1)       │ Hash 빠름│
 * │ 검색            │ O(1)     │ O(log n) │ O(1)       │ Hash 빠름│
 * │ 순서 보장       │ ❌       │ ✅ 정렬   │ ✅ 삽입순  │ -        │
 * │ null 허용       │ ✅(1개)  │ ❌       │ ✅(1개)    │ -        │
 * │ 메모리          │ 보통     │ 많음     │ 많음       │ Hash 적음│
 * │ Thread-Safe     │ ❌       │ ❌       │ ❌         │ -        │
 * └─────────────────┴──────────┴──────────┴────────────┴─────────┘
 */

public class SetSelectionGuide {
    // HashSet: 일반적인 경우 (빠른 성능)
    public Set<String> generalUse() {
        return new HashSet<>();
    }

    // TreeSet: 정렬이 필요한 경우
    public Set<Integer> sortedSet() {
        return new TreeSet<>();
    }

    // TreeSet: 범위 검색이 필요한 경우
    public NavigableSet<LocalDate> dateRange() {
        return new TreeSet<>();
    }

    // LinkedHashSet: 삽입 순서가 중요한 경우
    public Set<String> orderedSet() {
        return new LinkedHashSet<>();
    }

    // EnumSet: Enum 타입 (가장 효율적!)
    public Set<DayOfWeek> weekdays() {
        return EnumSet.range(DayOfWeek.MONDAY, DayOfWeek.FRIDAY);
    }

    // ConcurrentSkipListSet: 동시성 + 정렬
    public Set<String> concurrentSorted() {
        return new ConcurrentSkipListSet<>();
    }

    // CopyOnWriteArraySet: 읽기가 많고 쓰기가 적은 경우
    public Set<String> readHeavy() {
        return new CopyOnWriteArraySet<>();
    }
}

Set 연산

public class SetOperations {
    public void basicOperations() {
        Set<Integer> set1 = new HashSet<>(Arrays.asList(1, 2, 3, 4, 5));
        Set<Integer> set2 = new HashSet<>(Arrays.asList(4, 5, 6, 7, 8));

        // 합집합 (Union)
        Set<Integer> union = new HashSet<>(set1);
        union.addAll(set2);
        System.out.println(union);  // [1, 2, 3, 4, 5, 6, 7, 8]

        // 교집합 (Intersection)
        Set<Integer> intersection = new HashSet<>(set1);
        intersection.retainAll(set2);
        System.out.println(intersection);  // [4, 5]

        // 차집합 (Difference)
        Set<Integer> difference = new HashSet<>(set1);
        difference.removeAll(set2);
        System.out.println(difference);  // [1, 2, 3]

        // 대칭 차집합 (Symmetric Difference)
        Set<Integer> symDiff = new HashSet<>(set1);
        symDiff.addAll(set2);
        Set<Integer> tmp = new HashSet<>(set1);
        tmp.retainAll(set2);
        symDiff.removeAll(tmp);
        System.out.println(symDiff);  // [1, 2, 3, 6, 7, 8]

        // 부분집합 확인
        Set<Integer> subset = new HashSet<>(Arrays.asList(1, 2, 3));
        boolean isSubset = set1.containsAll(subset);
        System.out.println(isSubset);  // true
    }

    // Stream을 사용한 Set 연산
    public void streamOperations() {
        Set<String> set1 = Set.of("A", "B", "C");
        Set<String> set2 = Set.of("B", "C", "D");

        // 합집합
        Set<String> union = Stream.concat(set1.stream(), set2.stream())
            .collect(Collectors.toSet());

        // 교집합
        Set<String> intersection = set1.stream()
            .filter(set2::contains)
            .collect(Collectors.toSet());

        // 차집합
        Set<String> difference = set1.stream()
            .filter(e -> !set2.contains(e))
            .collect(Collectors.toSet());
    }
}

Map 마스터하기: HashMap 내부 구조

Hash 충돌과 체이닝

HashMap 내부 구조

public class HashMap<K, V> extends AbstractMap<K, V>
    implements Map<K, V>, Cloneable, Serializable {

    // 내부 배열 (버킷)
    transient Node<K,V>[] table;

    // 노드 클래스
    static class Node<K,V> implements Map.Entry<K,V> {
        final int hash;
        final K key;
        V value;
        Node<K,V> next;  // 충돌 시 다음 노드

        Node(int hash, K key, V value, Node<K,V> next) {
            this.hash = hash;
            this.key = key;
            this.value = value;
            this.next = next;
        }
    }

    // 기본 용량과 로드 팩터
    static final int DEFAULT_INITIAL_CAPACITY = 16;
    static final float DEFAULT_LOAD_FACTOR = 0.75f;

    // 저장
    public V put(K key, V value) {
        return putVal(hash(key), key, value, false, true);
    }

    // 해시 계산 (중요!)
    static final int hash(Object key) {
        int h;
        // null은 0, 아니면 hashCode() ^ (hashCode() >>> 16)
        return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
    }

    // 실제 저장 로직
    final V putVal(int hash, K key, V value, boolean onlyIfAbsent, boolean evict) {
        Node<K,V>[] tab; Node<K,V> p; int n, i;

        // 1. 테이블이 없으면 초기화
        if ((tab = table) == null || (n = tab.length) == 0)
            n = (tab = resize()).length;

        // 2. 버킷 인덱스 계산: (n - 1) & hash
        if ((p = tab[i = (n - 1) & hash]) == null)
            tab[i] = newNode(hash, key, value, null);  // 빈 버킷
        else {
            // 3. 충돌 발생
            Node<K,V> e; K k;

            // 3-1. 첫 번째 노드와 키가 같으면
            if (p.hash == hash &&
                ((k = p.key) == key || (key != null && key.equals(k))))
                e = p;

            // 3-2. TreeNode면 트리에 추가
            else if (p instanceof TreeNode)
                e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);

            // 3-3. 링크드 리스트에 추가
            else {
                for (int binCount = 0; ; ++binCount) {
                    if ((e = p.next) == null) {
                        p.next = newNode(hash, key, value, null);
                        // 8개 이상이면 트리로 변환!
                        if (binCount >= TREEIFY_THRESHOLD - 1)
                            treeifyBin(tab, hash);
                        break;
                    }
                    if (e.hash == hash &&
                        ((k = e.key) == key || (key != null && key.equals(k))))
                        break;
                    p = e;
                }
            }

            // 4. 기존 값이 있으면 교체
            if (e != null) {
                V oldValue = e.value;
                if (!onlyIfAbsent || oldValue == null)
                    e.value = value;
                return oldValue;
            }
        }

        // 5. 크기 증가 및 리사이즈 확인
        if (++size > threshold)
            resize();

        return null;
    }

    // 조회
    public V get(Object key) {
        Node<K,V> e;
        return (e = getNode(hash(key), key)) == null ? null : e.value;
    }

    final Node<K,V> getNode(int hash, Object key) {
        Node<K,V>[] tab; Node<K,V> first, e; int n; K k;

        // 1. 버킷 찾기
        if ((tab = table) != null && (n = tab.length) > 0 &&
            (first = tab[(n - 1) & hash]) != null) {

            // 2. 첫 번째 노드 확인
            if (first.hash == hash &&
                ((k = first.key) == key || (key != null && key.equals(k))))
                return first;

            // 3. 나머지 노드 탐색
            if ((e = first.next) != null) {
                if (first instanceof TreeNode)
                    return ((TreeNode<K,V>)first).getTreeNode(hash, key);

                // 링크드 리스트 순회
                do {
                    if (e.hash == hash &&
                        ((k = e.key) == key || (key != null && key.equals(k))))
                        return e;
                } while ((e = e.next) != null);
            }
        }
        return null;
    }
}

메모리 구조 시각화

HashMap 내부 구조 (capacity=16, size=6):

table (배열):
┌───┬─────────────────────────────────┐
│ 0 │ null                            │
├───┼─────────────────────────────────┤
│ 1 │ → [key1, val1] → [key9, val9]   │  (충돌! 체이닝)
├───┼─────────────────────────────────┤
│ 2 │ null                            │
├───┼─────────────────────────────────┤
│ 3 │ → [key3, val3]                  │
├───┼─────────────────────────────────┤
│ 4 │ → [key4, val4] → [key12, val12] │  (충돌! 체이닝)
│   │                → [key20, val20] │  (3개 충돌)
├───┼─────────────────────────────────┤
│...│ ...                             │
├───┼─────────────────────────────────┤
│15 │ null                            │
└───┴─────────────────────────────────┘

로드 팩터 = size / capacity = 6 / 16 = 0.375
threshold = capacity * loadFactor = 16 * 0.75 = 12
→ size가 12를 넘으면 resize (capacity를 32로 증가)

Java 8의 Tree 변환 최적화

충돌이 많을 때: 링크드 리스트 → 레드-블랙 트리

public class HashMapTreeOptimization {
    /**
     * Java 8+ 최적화:
     * 
     * 같은 버킷에 8개 이상 충돌 시:
     * - 링크드 리스트 → 레드-블랙 트리 변환
     * - 조회 성능: O(n) → O(log n)
     * 
     * 6개 이하로 줄어들면:
     * - 트리 → 링크드 리스트 복원
     */

    static final int TREEIFY_THRESHOLD = 8;   // 트리화 임계값
    static final int UNTREEIFY_THRESHOLD = 6; // 트리 해제 임계값

    public void demonstrateTreeConversion() {
        // 같은 해시 코드를 가지는 키들
        Map<BadKey, String> map = new HashMap<>();

        for (int i = 0; i < 10; i++) {
            map.put(new BadKey(i), "Value " + i);
        }

        // 내부적으로 트리로 변환됨!
    }

    // 나쁜 키 (항상 같은 해시 코드)
    static class BadKey {
        private int value;

        BadKey(int value) {
            this.value = value;
        }

        @Override
        public int hashCode() {
            return 1;  // 항상 같은 값!
        }

        @Override
        public boolean equals(Object o) {
            if (this == o) return true;
            if (!(o instanceof BadKey)) return false;
            return value == ((BadKey) o).value;
        }
    }
}

성능 비교

import org.openjdk.jmh.annotations.*;
import java.util.*;
import java.util.concurrent.TimeUnit;

@BenchmarkMode(Mode.AverageTime)
@OutputTimeUnit(TimeUnit.NANOSECONDS)
@State(Scope.Thread)
public class HashMapPerformanceBenchmark {

    @Param({"100", "1000", "10000"})
    private int size;

    private Map<Integer, String> hashMap;
    private Map<Integer, String> treeMap;
    private Map<Integer, String> linkedHashMap;

    @Setup
    public void setup() {
        hashMap = new HashMap<>();
        treeMap = new TreeMap<>();
        linkedHashMap = new LinkedHashMap<>();

        for (int i = 0; i < size; i++) {
            String value = "Value " + i;
            hashMap.put(i, value);
            treeMap.put(i, value);
            linkedHashMap.put(i, value);
        }
    }

    @Benchmark
    public String hashMapGet() {
        return hashMap.get(size / 2);
    }

    @Benchmark
    public String treeMapGet() {
        return treeMap.get(size / 2);
    }

    @Benchmark
    public String linkedHashMapGet() {
        return linkedHashMap.get(size / 2);
    }

    @Benchmark
    public void hashMapPut() {
        hashMap.put(size + 1, "New Value");
        hashMap.remove(size + 1);
    }

    @Benchmark
    public void treeMapPut() {
        treeMap.put(size + 1, "New Value");
        treeMap.remove(size + 1);
    }

    @Benchmark
    public void linkedHashMapPut() {
        linkedHashMap.put(size + 1, "New Value");
        linkedHashMap.remove(size + 1);
    }
}

/**
 * 벤치마크 결과 (예시):
 * 
 * size = 10,000 기준
 * 
 * Get:
 * - HashMap:       8 ns/op   ✅ 가장 빠름
 * - LinkedHashMap: 10 ns/op  ✅ 거의 같음
 * - TreeMap:       45 ns/op  ❌ 느림
 * 
 * Put:
 * - HashMap:       12 ns/op  ✅ 가장 빠름
 * - LinkedHashMap: 15 ns/op  ✅ 거의 같음
 * - TreeMap:       65 ns/op  ❌ 느림
 */

HashMap vs ConcurrentHashMap

ConcurrentHashMap: 동시성 안전

public class ConcurrentHashMapExample {
    // ❌ HashMap (Thread-unsafe)
    public void unsafeHashMap() {
        Map<String, Integer> map = new HashMap<>();

        // 여러 스레드가 동시에 접근하면 문제 발생!
        ExecutorService executor = Executors.newFixedThreadPool(10);
        for (int i = 0; i < 100; i++) {
            final int value = i;
            executor.submit(() -> {
                map.put("key" + value, value);  // ConcurrentModificationException!
            });
        }
    }

    // ✅ ConcurrentHashMap (Thread-safe)
    public void safeConcurrentHashMap() {
        Map<String, Integer> map = new ConcurrentHashMap<>();

        ExecutorService executor = Executors.newFixedThreadPool(10);
        for (int i = 0; i < 100; i++) {
            final int value = i;
            executor.submit(() -> {
                map.put("key" + value, value);  // 안전!
            });
        }
    }

    // ConcurrentHashMap의 고급 기능
    public void advancedFeatures() {
        ConcurrentHashMap<String, Integer> map = new ConcurrentHashMap<>();

        // 1. putIfAbsent (원자적 연산)
        map.putIfAbsent("key1", 100);
        map.putIfAbsent("key1", 200);  // 무시됨

        // 2. compute (원자적 연산)
        map.compute("key2", (k, v) -> v == null ? 1 : v + 1);

        // 3. merge (원자적 연산)
        map.merge("key3", 1, Integer::sum);  // 없으면 1, 있으면 +1

        // 4. forEach (병렬 처리 가능)
        map.forEach(1, (k, v) -> {
            System.out.println(k + ": " + v);
        });
    }

    // 실무 예제: 카운터
    public class ConcurrentCounter {
        private final ConcurrentHashMap<String, AtomicInteger> counters = 
            new ConcurrentHashMap<>();

        public void increment(String key) {
            // ✅ 원자적 연산
            counters.computeIfAbsent(key, k -> new AtomicInteger(0))
                .incrementAndGet();
        }

        public int getCount(String key) {
            AtomicInteger count = counters.get(key);
            return count != null ? count.get() : 0;
        }
    }
}

실전 캐싱 전략

public class CachingStrategies {
    // 1. 단순 캐시
    public class SimpleCache<K, V> {
        private final Map<K, V> cache = new HashMap<>();

        public V get(K key, Supplier<V> supplier) {
            return cache.computeIfAbsent(key, k -> supplier.get());
        }
    }

    // 2. 시간 기반 캐시
    public class TimedCache<K, V> {
        private final Map<K, CacheEntry<V>> cache = new ConcurrentHashMap<>();
        private final long ttlMillis;

        static class CacheEntry<V> {
            final V value;
            final long expiryTime;

            CacheEntry(V value, long ttl) {
                this.value = value;
                this.expiryTime = System.currentTimeMillis() + ttl;
            }

            boolean isExpired() {
                return System.currentTimeMillis() > expiryTime;
            }
        }

        public TimedCache(long ttlMillis) {
            this.ttlMillis = ttlMillis;
        }

        public V get(K key, Supplier<V> supplier) {
            CacheEntry<V> entry = cache.get(key);

            if (entry == null || entry.isExpired()) {
                V value = supplier.get();
                cache.put(key, new CacheEntry<>(value, ttlMillis));
                return value;
            }

            return entry.value;
        }

        public void cleanup() {
            cache.entrySet().removeIf(e -> e.getValue().isExpired());
        }
    }

    // 3. LRU 캐시 (LinkedHashMap 활용)
    public class LRUCache<K, V> extends LinkedHashMap<K, V> {
        private final int maxSize;

        public LRUCache(int maxSize) {
            super(16, 0.75f, true);  // true = accessOrder
            this.maxSize = maxSize;
        }

        @Override
        protected boolean removeEldestEntry(Map.Entry<K, V> eldest) {
            return size() > maxSize;
        }
    }

    // 사용 예제
    public void useLRUCache() {
        Map<String, String> cache = new LRUCache<>(3);

        cache.put("1", "A");
        cache.put("2", "B");
        cache.put("3", "C");
        cache.get("1");  // 1을 최근 사용으로 이동
        cache.put("4", "D");  // 2가 제거됨 (가장 오래 사용 안함)

        System.out.println(cache);  // {3=C, 1=A, 4=D}
    }

    // 4. Caffeine 라이브러리 (권장)
    public void caffeineCache() {
        // Gradle: implementation 'com.github.ben-manes.caffeine:caffeine:3.1.8'

        /*
        LoadingCache<String, String> cache = Caffeine.newBuilder()
            .maximumSize(1000)
            .expireAfterWrite(10, TimeUnit.MINUTES)
            .refreshAfterWrite(1, TimeUnit.MINUTES)
            .recordStats()
            .build(key -> loadFromDatabase(key));

        String value = cache.get("key");
        CacheStats stats = cache.stats();
        */
    }
}

컬렉션 선택 가이드: 어떤 상황에 무엇을?

/**
 * 컬렉션 선택 의사결정 트리:
 * 
 * 1. 중복을 허용하는가?
 *    ├─ Yes → List
 *    │   ├─ 조회가 많은가? → ArrayList ✅
 *    │   ├─ 처음/끝 삽입/삭제가 많은가? → ArrayDeque
 *    │   └─ 중간 삽입/삭제가 많은가? → LinkedList
 *    │
 *    └─ No → Set
 *        ├─ 순서가 필요한가?
 *        │   ├─ Yes, 정렬 순서 → TreeSet
 *        │   ├─ Yes, 삽입 순서 → LinkedHashSet
 *        │   └─ No → HashSet ✅
 *        │
 *        └─ Enum인가? → EnumSet
 * 
 * 2. Key-Value 쌍인가?
 *    ├─ Yes → Map
 *    │   ├─ 순서가 필요한가?
 *    │   │   ├─ Yes, 정렬 순서 → TreeMap
 *    │   │   ├─ Yes, 삽입 순서 → LinkedHashMap
 *    │   │   └─ No → HashMap ✅
 *    │   │
 *    │   ├─ 동시성이 필요한가? → ConcurrentHashMap
 *    │   └─ Enum 키인가? → EnumMap
 *    │
 *    └─ No → 1번으로
 * 
 * 3. 동시성이 필요한가?
 *    ├─ Yes, 읽기가 많다 → CopyOnWriteArrayList/Set
 *    ├─ Yes, 쓰기도 많다 → ConcurrentHashMap
 *    └─ Yes, Queue → ConcurrentLinkedQueue
 */

public class CollectionSelectionGuide {
    // 시나리오별 추천

    // 1. 순서가 있는 데이터 (조회 많음)
    public List<User> userList() {
        return new ArrayList<>();  // ✅
    }

    // 2. 중복 제거
    public Set<String> uniqueEmails() {
        return new HashSet<>();  // ✅
    }

    // 3. 정렬이 필요한 고유 데이터
    public Set<Integer> sortedScores() {
        return new TreeSet<>();  // ✅
    }

    // 4. 키-값 매핑
    public Map<String, User> usersByEmail() {
        return new HashMap<>();  // ✅
    }

    // 5. 정렬된 키-값
    public Map<LocalDate, List<Event>> eventsByDate() {
        return new TreeMap<>();  // ✅
    }

    // 6. Queue (FIFO)
    public Queue<Task> taskQueue() {
        return new ArrayDeque<>();  // ✅ (LinkedList보다 빠름)
    }

    // 7. Stack (LIFO)
    public Deque<String> navigationStack() {
        return new ArrayDeque<>();  // ✅ (Stack 클래스는 deprecated)
    }

    // 8. 동시성 List
    public List<String> concurrentList() {
        return new CopyOnWriteArrayList<>();  // ✅
    }

    // 9. 동시성 Map
    public Map<String, Object> concurrentMap() {
        return new ConcurrentHashMap<>();  // ✅
    }

    // 10. Enum 기반
    public Set<DayOfWeek> workdays() {
        return EnumSet.of(MONDAY, TUESDAY, WEDNESDAY, THURSDAY, FRIDAY);  // ✅
    }

    public Map<Month, String> monthNames() {
        Map<Month, String> map = new EnumMap<>(Month.class);  // ✅
        map.put(Month.JANUARY, "1월");
        return map;
    }
}

성능 비교표

/**
 * 시간 복잡도 비교:
 * 
 * ┌────────────┬─────────┬─────────┬─────────┬─────────┐
 * │   자료구조 │  추가   │  삭제   │  조회   │  검색   │
 * ├────────────┼─────────┼─────────┼─────────┼─────────┤
 * │ ArrayList  │ O(1)*   │ O(n)    │ O(1)    │ O(n)    │
 * │ LinkedList │ O(1)**  │ O(1)**  │ O(n)    │ O(n)    │
 * │ ArrayDeque │ O(1)*   │ O(1)*   │ -       │ -       │
 * ├────────────┼─────────┼─────────┼─────────┼─────────┤
 * │ HashSet    │ O(1)    │ O(1)    │ -       │ O(1)    │
 * │ TreeSet    │ O(log n)│ O(log n)│ -       │ O(log n)│
 * │LinkedHashSet│ O(1)    │ O(1)    │ -       │ O(1)    │
 * ├────────────┼─────────┼─────────┼─────────┼─────────┤
 * │ HashMap    │ O(1)    │ O(1)    │ O(1)    │ O(1)    │
 * │ TreeMap    │ O(log n)│ O(log n)│ O(log n)│ O(log n)│
 * │LinkedHashMap│ O(1)    │ O(1)    │ O(1)    │ O(1)    │
 * └────────────┴─────────┴─────────┴─────────┴─────────┘
 * 
 * * 끝에 추가/삭제, amortized
 * ** 위치를 알 때
 */

3.2 제네릭

제네릭 기초부터 고급까지

제네릭이 필요한 이유

제네릭 이전 (Java 5 이전)

public class BeforeGenerics {
    public void oldWay() {
        // ❌ 타입 안전성 없음
        List list = new ArrayList();
        list.add("Hello");
        list.add(123);
        list.add(new Date());

        // 런타임 에러 발생!
        String str = (String) list.get(1);  // ClassCastException!
    }
}

제네릭 사용 (Java 5+)

public class WithGenerics {
    public void newWay() {
        // ✅ 컴파일 타임 타입 체크
        List<String> list = new ArrayList<>();
        list.add("Hello");
        // list.add(123);  // 컴파일 에러!

        // 캐스팅 불필요
        String str = list.get(0);  // 안전!
    }
}

타입 파라미터 <T, E, K, V>

기본 제네릭 클래스

// 단일 타입 파라미터
public class Box<T> {
    private T item;

    public void set(T item) {
        this.item = item;
    }

    public T get() {
        return item;
    }
}

// 사용
Box<String> stringBox = new Box<>();
stringBox.set("Hello");
String value = stringBox.get();

Box<Integer> intBox = new Box<>();
intBox.set(123);
Integer num = intBox.get();

다중 타입 파라미터

// Key-Value 쌍
public class Pair<K, V> {
    private K key;
    private V value;

    public Pair(K key, V value) {
        this.key = key;
        this.value = value;
    }

    public K getKey() { return key; }
    public V getValue() { return value; }
}

// 사용
Pair<String, Integer> pair = new Pair<>("Age", 30);
String key = pair.getKey();
Integer value = pair.getValue();

제네릭 메서드

public class GenericMethods {
    // 제네릭 메서드 정의
    public static <T> void printArray(T[] array) {
        for (T element : array) {
            System.out.println(element);
        }
    }

    // 여러 타입 파라미터
    public static <K, V> Map<K, V> createMap(K[] keys, V[] values) {
        Map<K, V> map = new HashMap<>();
        for (int i = 0; i < keys.length; i++) {
            map.put(keys[i], values[i]);
        }
        return map;
    }

    // 제한된 타입 파라미터
    public static <T extends Comparable<T>> T max(T a, T b) {
        return a.compareTo(b) > 0 ? a : b;
    }

    // 사용
    public void use() {
        String[] strings = {"A", "B", "C"};
        printArray(strings);  // 타입 추론

        Integer maxNum = max(10, 20);  // 20
        String maxStr = max("apple", "banana");  // "banana"
    }
}

와일드카드 (? extends, ? super)

상한 경계 와일드카드 (? extends T)

public class UpperBoundWildcard {
    // Number 또는 그 하위 타입 (Integer, Double 등)
    public static double sum(List<? extends Number> numbers) {
        double sum = 0.0;
        for (Number num : numbers) {
            sum += num.doubleValue();
        }
        return sum;
    }

    public void use() {
        List<Integer> integers = Arrays.asList(1, 2, 3);
        List<Double> doubles = Arrays.asList(1.1, 2.2, 3.3);

        double sum1 = sum(integers);  // OK
        double sum2 = sum(doubles);   // OK

        // ❌ 추가는 불가능! (어떤 타입인지 모르므로)
        List<? extends Number> list = integers;
        // list.add(10);  // 컴파일 에러!
    }

    // 실무 예제: 복사
    public static <T> void copy(List<? extends T> source, List<T> dest) {
        for (T item : source) {
            dest.add(item);
        }
    }
}

하한 경계 와일드카드 (? super T)

public class LowerBoundWildcard {
    // Integer 또는 그 상위 타입 (Number, Object 등)
    public static void addIntegers(List<? super Integer> list) {
        list.add(1);
        list.add(2);
        list.add(3);
        // ✅ 추가 가능!

        // ❌ 조회는 Object로만 가능
        Object obj = list.get(0);
        // Integer num = list.get(0);  // 컴파일 에러!
    }

    public void use() {
        List<Integer> integers = new ArrayList<>();
        List<Number> numbers = new ArrayList<>();
        List<Object> objects = new ArrayList<>();

        addIntegers(integers);  // OK
        addIntegers(numbers);   // OK
        addIntegers(objects);   // OK
    }

    // 실무 예제: Collections.sort()
    public static <T extends Comparable<? super T>> void sort(List<T> list) {
        // T가 Comparable<T> 또는 상위 타입의 Comparable 구현
    }
}

PECS 원칙 (Producer Extends, Consumer Super)

public class PECSPrinciple {
    /**
     * PECS: Producer Extends, Consumer Super
     * 
     * - Producer (읽기만): ? extends T
     * - Consumer (쓰기만): ? super T
     */

    // Producer: 데이터를 제공 (읽기)
    public static double sum(List<? extends Number> numbers) {
        double sum = 0.0;
        for (Number num : numbers) {  // 읽기만 함
            sum += num.doubleValue();
        }
        return sum;
    }

    // Consumer: 데이터를 소비 (쓰기)
    public static void addNumbers(List<? super Integer> list) {
        list.add(1);  // 쓰기만 함
        list.add(2);
        list.add(3);
    }

    // 둘 다 필요하면: 와일드카드 사용 안함
    public static <T> void processList(List<T> list) {
        T item = list.get(0);  // 읽기
        list.add(item);        // 쓰기
    }

    // 실무 예제: Collections.copy()
    public static <T> void copy(
        List<? extends T> src,   // Producer (읽기)
        List<? super T> dest     // Consumer (쓰기)
    ) {
        for (T item : src) {
            dest.add(item);
        }
    }
}

타입 소거(Type Erasure)

컴파일 타임 vs 런타임

public class TypeErasure {
    // 컴파일 전
    public class Box<T> {
        private T item;

        public void set(T item) {
            this.item = item;
        }

        public T get() {
            return item;
        }
    }

    // 컴파일 후 (타입 소거)
    public class Box {
        private Object item;

        public void set(Object item) {
            this.item = item;
        }

        public Object get() {
            return item;
        }
    }

    // 사용 코드 변환
    // 컴파일 전:
    Box<String> box = new Box<>();
    box.set("Hello");
    String value = box.get();

    // 컴파일 후:
    Box box = new Box();
    box.set("Hello");
    String value = (String) box.get();  // 자동 캐스팅 삽입
}

타입 소거의 제약

public class TypeErasureConstraints {
    // ❌ 제네릭 타입의 인스턴스 생성 불가
    public <T> T create() {
        // return new T();  // 컴파일 에러!
        return null;
    }

    // ✅ 해결: Class<T> 전달
    public <T> T create(Class<T> clazz) throws Exception {
        return clazz.getDeclaredConstructor().newInstance();
    }

    // ❌ 제네릭 타입의 배열 생성 불가
    public <T> T[] createArray(int size) {
        // return new T[size];  // 컴파일 에러!
        return null;
    }

    // ✅ 해결: Class<T> 전달 + Array.newInstance()
    @SuppressWarnings("unchecked")
    public <T> T[] createArray(Class<T> clazz, int size) {
        return (T[]) Array.newInstance(clazz, size);
    }

    // ❌ static 필드에 타입 파라미터 사용 불가
    public class Container<T> {
        // private static T staticItem;  // 컴파일 에러!
    }

    // ❌ instanceof에 제네릭 타입 사용 불가
    public <T> boolean check(Object obj) {
        // return obj instanceof T;  // 컴파일 에러!
        return false;
    }

    // ✅ 해결: Class<T> 전달
    public <T> boolean check(Object obj, Class<T> clazz) {
        return clazz.isInstance(obj);
    }
}

실무 패턴: Super Type Token

public class SuperTypeToken {
    // Type Token 패턴
    public abstract class TypeReference<T> {
        private final Type type;

        protected TypeReference() {
            Type superclass = getClass().getGenericSuperclass();
            this.type = ((ParameterizedType) superclass).getActualTypeArguments()[0];
        }

        public Type getType() {
            return type;
        }
    }

    // 사용
    public void use() {
        TypeReference<List<String>> listType = new TypeReference<List<String>>() {};
        Type type = listType.getType();

        System.out.println(type);  // java.util.List<java.lang.String>
    }

    // 실무 예제: JSON 역직렬화
    public class JsonParser {
        public <T> T parse(String json, TypeReference<T> typeRef) {
            // Type 정보를 사용하여 역직렬화
            // Jackson, Gson 등이 이 패턴 사용
            return null;
        }
    }

    // 사용
    public void parseJson() {
        String json = "[{\"name\":\"Alice\"}, {\"name\":\"Bob\"}]";

        // List<User> 타입 정보 전달
        List<User> users = new JsonParser().parse(json, 
            new TypeReference<List<User>>() {}
        );
    }
}

제네릭 베스트 프랙티스

public class GenericsBestPractices {
    // 1. 원시 타입 사용 금지
    // ❌
    List list = new ArrayList();
    // ✅
    List<String> list = new ArrayList<>();

    // 2. 다이아몬드 연산자 사용 (Java 7+)
    // ❌
    Map<String, List<String>> map = new HashMap<String, List<String>>();
    // ✅
    Map<String, List<String>> map = new HashMap<>();

    // 3. 제네릭 메서드 활용
    // ❌
    public void process(List list) { }
    // ✅
    public <T> void process(List<T> list) { }

    // 4. 경계 지정
    // ❌
    public <T> T max(T a, T b) {
        // compareTo() 사용 불가
        return a;
    }
    // ✅
    public <T extends Comparable<T>> T max(T a, T b) {
        return a.compareTo(b) > 0 ? a : b;
    }

    // 5. 와일드카드 사용
    // ❌ 너무 구체적
    public void print(List<Object> list) { }
    // ✅ 유연함
    public void print(List<?> list) { }

    // 6. PECS 원칙 준수
    // Producer Extends
    public <T> void addAll(List<T> dest, List<? extends T> src) {
        dest.addAll(src);
    }

    // Consumer Super
    public <T> void copyAll(List<? super T> dest, List<T> src) {
        dest.addAll(src);
    }
}

마치며

이번 글에서는 Java 컬렉션 프레임워크와 제네릭을 깊이 있게 다뤄보았습니다.

핵심 요약:

  1. List

    • ArrayList: 대부분의 경우 (조회 중심)
    • LinkedList: Queue/Deque로 사용
    • ArrayDeque: Stack/Queue 최적 (LinkedList보다 빠름)
  2. Set

    • HashSet: 일반적인 고유 집합
    • TreeSet: 정렬이 필요할 때
    • LinkedHashSet: 순서가 중요할 때
  3. Map

    • HashMap: 일반적인 키-값 매핑
    • TreeMap: 정렬된 키 필요
    • LinkedHashMap: 순서 유지
    • ConcurrentHashMap: 동시성 필요
  4. 제네릭

    • 타입 안전성과 코드 재사용성
    • 와일드카드로 유연성 확보
    • PECS 원칙 준수
    • 타입 소거의 제약 이해

실무 팁:

  • 대부분의 경우: ArrayList, HashSet, HashMap
  • 성능이 중요하면: 벤치마크로 확인
  • 동시성이 필요하면: Concurrent 컬렉션
  • 명확한 의도 전달: 적절한 인터페이스 사용

다음 단계에서는 Stream API와 함수형 프로그래밍을 다룰 예정입니다.

반응형