[Java 21] (11) - collections framework

컬렉션 프레임워크

 

컬렉션 프레임워크란, '데이터 군을 저장하는 클래스들을 표준화'한 것입니다. 컬렉션(Collection)은 다수의 데이터, 즉 데이터 그룹을, 프레임워크는 표준화된 프로그래밍 방식을 의미합니다.

 

JDK 1.2 이전까지는 Vector, Hashtable, Properties와 같은 컬렉션 클래스, 다수의 데이터를 저장할 수 있는 클래스들을 서로 다른 각자의 방식으로 처리해야 했으나 JDK 1.2부터 컬렉션 프레임워크가 등장하면서 다양한 종류의 컬렉션 클래스가 추가되고 모든 컬렉션 클래스를 표준화된 방식으로 다룰 수 있도록 체계화되었습니다.

 

핵심 인터페이스

 

컬렉션 프레임워크는 컬렉션 데이터 그룹을 크게 3가지 타입이 존재한다고 인식하고 각 컬렉션을 다루는데 필요한 기능을 가진 3개의 인터페이스로 정의하였습니다. 그리고 List와 Set의 공통된 부분을 다시 뽑아서 새로운 인터페이스인 Collection을 추가로 정의하였습니다.

인터페이스 List와 Set을 구현한 컬렉션 클래스들은 서로 많은 공통부분이 있어서, 공통된 부분을 다시 뽑아 Collection인터페이스를 정의할 수 있었지만 Map인터페이스는 이들과는 전혀 다른 형태로 컬렉션을 다루기 때문에 같은 상속 계층도에 포함되지 못했습니다.

JDK 5부터 Iterable인터페이스가 추가되고 이를 Collection인터페이스가 상속받도록 변경되었지만 그림에서는 생략했습니다.

 

인터페이스 특  징
List 순서가 있는 데이터의 집합으로 데이터의 중복을 허용합니다.
구현클래스: ArrayList, LinkedList, Stack, Vector 등
Set 순서를 유지하지 않는 데이터의 집합으로 데이터의 중복을 허용하지 않습니다.
구현클래스: HashSet, TreeSet 등
Map 키(key)와 값(value)의 쌍(pair)으로 이루어진 데이터의 집합으로
순서는 유지되지 않으며, 키는 중복을 허용하지 않고, 값을 중복을 허용합니다.
구현클래스: HashMap, TreeMap, Hashtable, Properties 등

 

컬렉션 프레임워크의 모든 컬렉션 클래스들은 List, Set, Map 중의 하나를 구현하고 있으며, 구현한 인터페이스의 이름이 클래스의 이름에 포함되어 있어서 이름만으로도 클래스의 특징을 쉽게 알 수 있도록 되어있습니다.

 

그러나 Vector, Stack, Hashtable, Properties와 같은 클래스들은 컬렉션 프레임워크가 만들어지기 전부터 존재하던 것이기 때문에 컬렉션 프레임워크의 명명법을 따르지 않습니다. Vector나 Hashtable과 같은 기존의 컬렉션 클래스들은 호환을 위해, 설계를 변경해서 남겨두었지만 가능하면 사용하지 않는 것이 좋습니다. 그 대신 새로 추가된 ArrayList와 HashMap을 사용하는게 낫습니다.

 

Collection 인터페이스

Collection인터페이스는 컬렉션 클래스에 저장된 데이터를 읽고, 추가하고 삭제하는 등 컬렉션을 다루는데 필요한 가장 기본적인 메서드를 정의하고 있습니다. 이 중에서 반환 타입이 boolean인 메서드들은 작업을 성공하거나 사실이면 true를, 그렇지 않으면 false를 반환합니다.

 

List 인터페이스

List인터페이스는 중복을 허용하면서 저장순서가 유지되는 컬렉션을 구현하는데 사용됩니다.

 

Set 인터페이스

Set인터페이스는 중복을 허용하지 않고 저장순서가 유지되지 않는 컬렉션 클래스를 구현하는데 사용됩니다. 

Map 인터페이스

Map인터페이스는 키(key)와 값(value)을 하나의 쌍으로 묶어서 저장하는 컬렉션 클래스를 구현하는데 사용됩니다. 키는 중복될 수 없지만, 값은 중복을 허용합니다. 기존에 저장된 데이터와 중복된 키와 값을 저장하면 기존의 값은 없어지고 마지막에 저장된 값이 남게됩니다.

Map인터페이스의 메서드 중에서 values()는 반환타입이 Collection이고, keySet()은 반환타입이 Set입니다. 이는 Map인터페이스에서 값은 중복을 허용하기 때문에 Collection타입으로 반환하고, 키는 중복을 허용하지 않기 때문에 Set타입으로 반환하는 것입니다.

 

Map.Entry 인터페이스

Map.Entry인터페이스는 Map인터페이스 내부 인터페이스입니다. 내부 클래스와 같이 인터페이스도 인터페이스 안에 인터페이스를 정의하는 내부 인터페이스(inner interface)를 정의하는 것이 가능합니다.

 

Map에 저장되는 key-value쌍을 다루기 위해 내부적으로 Entry인터페이스를 정의해 놓았습니다. 이것은 보다 객체지향적으로 설계하도록 유도하기 위한 것으로 Map인터페이스를 구현하는 클래스에서는 Map.Entry인터페이스도 함께 구현해야 합니다.

 

다음은 Map인터페이스의 소스코드의 일부입니다.

public interface Map<K, V> {
    ...
    interface Entry<K, V> {
        K getKey();
        V getValue();
        V setValue(V value);
        boolean equals(Object o);
        int hashCode();
        ...
    }
}

 

of()와 copyOf()

List, Set, Map을 생성해서 반환하는 팩토리 메서드입니다. 배열의 생성과 초기화를 동시에 하는 것과 같은 코드를 작성할 수 있게 해줍니다. of()는 가변인자와 오버로딩을 이용하므로 괄호()안에 여러 객체를 넣을 수 있습니다. 이는 JDK 9부터 추가되었습니다.

이때 of()로 생성된 객체는 불변(immutable) 객체로 읽기 전용(read-only)입니다.

List list = List.of("a", "b", "c");
Set set = Set.of("a", "b", "c");

Map map = Map.of("a", 1, "b", 2);
Map map = Map.ofEntries(Map.entry("a", 1), Map.entry("b", 2));
Map의 경우 of() 메서드는 최대 10쌍까지만 지원하고, ofEntries()는 개수의 제한이 없습니다.

 

copyOf()는 이름에서 알 수 있듯이 매개변수로 컬렉션을 받아서 복사해서 반환합니다. 반환된 컬렉션은 얕은 복사로 복제된 것으로 이 또한 읽기만 가능할 뿐 불변(immutable)입니다.

List list = List.of("a", "b", "c");
List copy = List.copyOf(list);

 

SequencedCollection

JDK 21부터 추가된 인터페이스로 Collection의 자손이고 List의 조상입니다. 다음과 같은 디폴트 메서드가 정의되어 있습니다.

메서드 설  명
default void addFirst(E e) e를 컬렉션의 맨 앞에 저장
default void addLast(E e)  e를 컬렉션의 맨 뒤에 저장
 default E getFirst()  컬렉션의 첫 번째 요소를 반환
default E getLast() 컬렉션의 마지막 요소를 반환
default E removeFirst() 컬렉션의 첫 번째 요소를 제거
default E removeLast() 컬렉션의 마지막 요소를 제거
SequencedCollection<E> reversed(); 컬렉션의 역순으로 해당 컬렉션에 담아 반환

 

기존에 있던 List인터페이스를 구현한 클래스의 메서드 중에 공통으로 사용할 수 있는 것들을 뽑아서 인터페이스로 정의한 것일 뿐 특별한 것은 없습니다. 따라서 기존의 상속 계층도가 아래와 같이 변경되었습니다.

 

 

ArrayList와 Vector

 

아마도 ArrayList는 컬렉션 프레임워크에서 가장 많이 되는 클래스일 것입니다. 이 ArrayList는 List인터페이스를 구현하기 때문에 데이터의 저장순서가 유지되고 중복을 허용한다는 특징을 갖습니다. ArrayList는 기존의 Vector를 개선한 것으로 이 둘은 거의 동일합니다. 중요한 차이점은 동기화인데, Vector는 동기화되어 있고 ArrayList는 동기화되어 있지 않습니다. 보통은 Vector보다 ArrayList를 주로 사용합니다.

 

ArrayList는 Object배열을 이용해서 데이터를 순차적으로 저장합니다. 예를 들면, 첫 번째로 저장한 객체는 Object배열의 0번째 위치에 저장되고 그 다음에 저장하는 객체는 1번째 위치에 저장됩니다. 이런 식으로 계속 배열에 순서대로 저장되며, 배열에 더 이상 저장할 공간이 없으면 보다 큰 새로운 배열을 생성해서 기존의 배열에 저장된 내용을 새로운 배열로 복사한 다음에 저장됩니다.

public class ArrayList<E> extends AbstractList<E>
        implements List<E>, RandomAccess, Cloneable, java.io.Serializable
{
    ...
    private static final int DEFAULT_CAPACITY = 10;
    transient Object[] elementData;
    private int size;
    ...
}

위의 코드는 ArrayList의 소스코드 일부인데, ArrayList는 elementData라는 이름의 Object배열을 멤버변수로 선언하고 있습니다. 

 

ArrayList를 생성할 때, 저장할 요소의 개수를 고려해서 실제 저장할 개수보다 약간 여유 있는 크기로 하는 것이 좋습니다. 생성할 때 지정한 크기보다 더 많은 객체를 저장하면 자동적으로 크기가 늘어나기는 하지만 이 과정에서 처리시간이 많이 소요되기 때문입니다.

 

Capacity와 Size

 

다음은 Vector를 사용해 용량(capacity)와 크기(size)가 어떻게 변하는지 확인해보는 예제입니다.

public void static main(String[] args) {
    Vector<Integer> v = new Vector<>(5);
    v.add(1);
    v.add(2);
    v.add(3);
    print(v);

    System.out.println("===========");
    v.trimToSize();
    print(v);

    System.out.println("===========");
    v.ensureCapacity(6);
    print(v);

    System.out.println("===========");
    v.setSize(7);
    print(v);

    System.out.println("===========");
    v.clear();
    print(v);
}

public static void print(Vector v) {
    System.out.println(v);
    System.out.println("size : " + v.size());
    System.out.println("capacity : " + v.capacity());
}

다음은 위 코드의 실행 흐름을 그림으로 표현한 것입니다.

 

위에서 사용한 trimToSize(), ensureCapacity(), setSize() 모두 배열의 크기를 변경하는 메서드입니다. 그 중 trimToSize() 메서드를 확인해보겠습니다.

public synchronized void trimToSize() {
    modCount++;
    int oldCapacity = elementData.length;
    if (elementCount < oldCapacity) {
        elementData = Arrays.copyOf(elementData, elementCount);		// 배열 복사
    }
}

위 코드를 보면 알 수 있듯이 배열은 용량을 변경할 수 없기 때문에, 용량을 변경해야할 때는 새로운 배열을 생성한 후 기존의 배열로부터 새로 생성된 배열로 데이터를 복사해야 하므로 상당히 효율이 떨어진다는 단점을 가지고 있습니다. 그래서 처음에 인스턴스를 생성할 때, 저장할 데이터의 개수를 잘 고려하여 충분한 용량의 인스턴스를 생성하는 것이 좋습니다.

 

remove()

 

다음 코드는 Vector의 remove() 메서드입니다. Vector의 다른 메서드들과 달리 remove만큼은 헷갈릴 수가 있어서 자세히 알아보겠습니다.

public synchronized E remove(int index) {
    modCount++;
    if (index >= elementCount)
        throw new ArrayIndexOutOfBoundsException(index);
    E oldValue = elementData(index);

    int numMoved = elementCount - index - 1;
    if (numMoved > 0)
        System.arraycopy(elementData, index+1, elementData, index,
                         numMoved);
    elementData[--elementCount] = null; // Let gc do its work

    return oldValue;
}

위 코드의 실행 흐름을 그림으로 표현하면 다음과 같습니다.

위 과정을 통해 알 수 있는 것은 배열의 마지막 객체를 삭제할 때에는 옮겨야 할 값(numMoved)들이 0이기 때문에 System.arraycopy()가 호출되지 않아 작업시간이 짧다는 것입니다.

 

 

LinkedList

 

배열은 가장 기본적인 형태의 자료구조로 구조가 간단하며 사용하기 쉽고 데이터를 읽어오는데 걸리는 시간(접근 시간, access time)이 가장 빠르다는 장점을 가지고 있지만 다음과 같은 단점도 갖고 있습니다.

 

1. 크기를 변경할 수 없습니다.

  • 크기를 변경할 수 없으므로 새로운 배열을 생성해서 데이터를 복사해야 합니다.
  • 실행 속도를 향상시키려면 충분히 큰 크기의 배열을 생성해야 하므로 메모리가 낭비됩니다.

2. 비순차적인 데이터의 추가 또는 삭제에 시간이 많이 걸립니다.

  • 차례대로 데이터를 추가하고 마지막에서부터 데이터를 삭제하는 것은 빠르지만,
    배열의 중간에 데이터를 추가하거나 삭제하려면, 빈자리를 만들기 위해 다른 데이터들을 복사해서 이동해야 합니다.

이러한 배열의 단점을 보완하기 위해 링크드 리스트(linked list)라는 자료구조가 고안되었습니다. 배열은 모든 데이터가 연속적으로 존재하지만 링크드 리스트는 불연속적으로 존재하는 데이터를 서로 연결(link)한 형태로 구성되어 있습니다.

위의 그림에서 알 수 있듯이 링크드 리스트의 각 요소(node)들은 자신과 연결된 다음 요소에 대한 참조(주소값)와 데이터로 구성되어 있습니다.

private static class Node<E> {
    Node<E> next;		// 다음 요소의 주소
    E item;			// 데이터
}

 

링크드 리스트에서의 데이터 삭제는 간단합니다. 삭제하고자 하는 요소의 이전 요소가 삭제하고자 하는 요소의 다음 요소를 참조하도록 변경하기만 하면 됩니다. 단 하나의 참조만 변경하면 삭제가 이루어지는 것이기 때문에, 배열처럼 데이터를 이동하기 위해 복사하는 과정이 없어 처리 속도가 매우 빠릅니다.

 

다음은 삭제하는 과정을 그림으로 표현한 것입니다.

새로운 데이터를 추가하는 것도 삭제와 비슷합니다. 추가하고자 하는 위치의 이전 요소의 참조를 새로운 요소에 대한 참조로 변경해주고, 새로운 요소가 그 다음 요소를 참조하도록 변경하기만 하면 되므로 이또한 처리속도가 매우 빠릅니다.

 

Doubly Linked List

 

그런데 링크드 리스트는 이동 방향이 단방향이기 때문에 다음 요소에 대한 접근은 쉽지만 이전 요소에 대한 접근은 어렵습니다. 이 점을 보완한 것이 더블 링크드 리스트(이중 연결 리스트, doubly linked list)입니다.

 

더블 링크드 리스트는 단순히 링크드 리스트에 참조변수를 하나 더 추가하여 다음 요소에 대한 참조뿐 아니라 이전 요소에 대한 참조가 가능하도록 했을 뿐, 그 외에는 링크드 리스트와 같습니다. 더블 링크드 리스트는 링크드 리스트보다 각 요소에 대한 접근과 이동이 더 쉽게 때문에 더 많이 사용됩니다.

private static class Node<E> {
    Node<E> next;		// 다음 요소의 주소
    Node<E> prev;		// 이전 요소의 주소
    E item;			// 데이터
}

 

 

Doubly Circular Linked List

 

더블 링크드 리스트의 접근성을 보다 향상시킨 것이 '더블 서큘러 링크드 리스트(이중 원형 연결 리스트, doubly circular linked list)'인데, 단순히 더블 링크드 리스트의 첫 번째 요소와 마지막 요소를 서로 연결시킨 것입니다. 이렇게 하면 마지막 요소의 다음 요소가 첫 번째 요소가 되고, 첫 번째 요소의 이전 요소가 마지막 요소가 됩니다. 

 

LinkedList 역시 List인터페이스를 구현했기 때문에 ArrayList와 내부 구현 방법만 다를뿐 제공하는 메서드의 종류와 기능은 거의 같습니다.

 

데이터 접근 시간

 

ArrayList의 경우 각 요소들이 연속적으로 메모리상에 존재하기 때문에 `배열의 주소 + (n x 데이터 타입 크기)` 를 계산하면 원하는 요소의 주소를 얻어서 곧바로 읽어 올 수 있지만, LinkedList는 불연속적으로 위치한 각 요소들이 서로 연결된 것이라 처음부터 n번째 데이터까지 차례대로 따라가야만 원하는 값을 얻을 수 있습니다.

 

그래서 LinkedList는 저장해야하는 데이터의 개수가 많아질수록 데이터를 읽어 오는 시간, 즉 접근 시간(access time)이 길어진다는 단점이 있습니다.

 

결론

  1. 순차적으로 추가/삭제하는 경우에는 ArrayList가 LinkedList보다 빠릅니다.
  2. 중간 데이터를 추가/삭제하는 경우에는 LinkedList가 ArrayList보다 빠릅니다.
  3. 데이터 읽기 접근 시간은 ArrayList가 LinkedList보다 빠릅니다.

다루고자 하는 데이터의 개수가 변하지 않는 경우라면, ArrayList가 최상의 선택이 되겠지만, 데이터 개수의 변경이 잦다면 LinkedList를 사용하는 것이 더 나은 선택이 될 것입니다.

 

두 클래스의 장점을 조합해서 데이터를 저장할 때는 ArrayList를 이용하고, 작업할 때는 LinkedList로 데이터를 옮겨서 작업할 수도 있습니다.

ArrayList al = new ArrayList(1_000_000);

LinkedList ll = new LinkedList(al);		// ArrayList -> LinkedList

 

 

Stack과 Queue

 

자바에서 제공하는 Stack과 Queue에 대해서 알아보기 이전에 기본 개념과 특징에 대해서 먼저 살펴보겠습니다.

 

스택은 마지막에 저장한 데이터를 가장 먼저 꺼내게 되는 LIFO(Last In First Out)구조로 되어 있고, 는 처음에 저장한 데이터를 가장 먼저 꺼내게 되는 FIFO(First In First Out)구조로 되어 있습니다. 

위 그림과 같이 순차적으로 데이터를 추가하고 삭제하는 스택은 ArrayList와 같은 배열 기반의 컬레션 클래스가 적합하고, 항상 첫 번째 데이터를 꺼내야하는 큐는 추가/삭제가 쉬운 LinkedList로 구현하는 것이 적합합니다.

메서드 설  명
boolean empty() Stack이 비어있는지 알려줍니다.
E peek() Stack의 맨 위에 저장된 객체를 반환합니다.
pop()과 달리 Stack에서 객체를 꺼내지는 않습니다. (없으면 EmptyStackException)
E pop() Stack의 맨 위에 저장된 객체를 꺼냅니다. (없으면 EmptyStackException)
E push(E item) Stack에 객체(item)를 저장합니다.
int search(Object o) Stack에서 주어진 객체(o)를 찾아서 그 위치를 반환하고, 못 찾으면 -1을 반환합니다.
(배열과 달리 위치는 0이 아닌 1부터 시작)

Stack의 메서드

 

메서드 설  명
boolean add(E e) 지정된 객체를 Queue에 추가합니다.
성공하면 true를 반환하고, 저장공간이 부족하면 IllegalStateException 발생합니다.
E remove() Queue에서 객체를 꺼내 반환합니다. (없으면 NoSuchElementException)
E element() 삭제 없이 요소를 읽어 옵니다.
(peek()와 달리 요소가 없으면 NoSuchElementException)
boolean offer(E e) Queue에 객체를 저장하고, 성공하면 true, 실패하면 false를 반환합니다.
E poll() Queue에서 객체를 꺼내서 반환합니다. (없으면 NoSuchElementException)
E peek() 삭제 없이 요소를 읽어 옵니다. (없으면 null)

Queue의 메서드

 

 

자바에서는 스택을 Stack클래스로 구현하여 제공하고 있지만, 큐는 Queue인터페이스로만 정의해 놓았을 뿐 별도의 클래스를 제공하지 않습니다. 대신 Queue인터페이스를 구현한 클래스들이 있어서 이들중에서 하나를 선택해서 사용하면 됩니다.

Stack은 컬렉션 프레임워크 이전부터 존재하던 것이기 때문에 ArrayList가 아닌 Vector로부터 상속받아 구현했습니다.

 

스택과 큐는 다음과 같은 활용 예시가 있습니다.

  • 스택 : 수식 괄호 검사, undo/redo, 뒤로/앞으로
  • : 최근 사용 기록, 인쇄작업 대기 목록, 버퍼(buffer)

 

PriorityQueue

 

Queue인터페이스의 구현체 중의 하나로, 저장한 순서에 관계없이 우선 순위(priority)가 높은 것부터 꺼내게 된다는 특징이 있습니다. 그리고 null을 저장하면 NullPointerException이 발생하여 저장할 수 없습니다.

public class PriorityQueue<E> extends AbstractQueue<E>
    implements java.io.Serializable {
    ...
    transient Object[] queue;		// 배열 기반
 	...   
}

 

PriorityQueue는 저장공간으로 배열을 사용하며, 각 요소를 '힙(heap)'이라는 자료구조의 형태로 저장합니다. 힙은 이진 트리의 한 종류로 가장 큰 값이나 가장 작은 값을 빠르게 찾을 수 있다는 특징이 있습니다.

JVM 메모리 영역인 Heap은 C언어에서 관리하던 동적 메모리 공간(Heap) 개념에서 유래한 것으로, 자료구조와는 무관합니다.
Queue pq = new PriorityQueue();
qp.offer(3);
qp.offer(1);
qp.offer(5);
qp.offer(2);
qp.offer(4);

// 1, 2, 3, 4, 5 순서로 poll()

 

다음은 PriorityQueue에서 사용하는 offer()의 소스코드중 일부입니다.

private static <T> void siftUpComparable(int k, T x, Object[] es) {
    Comparable<? super T> key = (Comparable<? super T>) x;
    while (k > 0) {
        int parent = (k - 1) >>> 1;
        Object e = es[parent];
        if (key.compareTo((T) e) >= 0)
            break;
        es[k] = e;
        k = parent;
    }
    es[k] = key;
}

 

코드를 보면 Comparable인터페이스를 구현한 객체의 compareTo() 결과를 기준으로 요소를 삽입할 때마다 힙(heap) 구조의 형태를 유지하도록 자동으로 재배열합니다. 이로써 전체 정렬 없이도 항상 가장 우선순위 높은 요소가 루트에 위치하게 됩니다

 

Deque(Double-Ended Queue)

 

Queue의 변형으로, 한 쪽 끝으로만 추가/삭제할 수 있는 Queue와 달리, Deque(덱, 또는 디큐)은 양쪽 끝에 추가/삭제가 가능합니다. Deque의 조상은 Queue이며, 구현체로는 ArrayDequeLinkedList 등이 있습니다.

덱은 스택과 큐를 하나로 합쳐놓은 것과 같으며, 스택으로도 사용할 수 있고, 큐로도 사용할 수 있습니다.

 

 

Iterator, ListIterator, Enumeration

 

Iterator, ListIterator, Enumeration은 모두 컬렉션에 저장된 요소를 접근하는데 사용되는 인터페이스입니다. Enumeration은 Iterator의 구버전이며, ListIterator는 Iterator의 기능을 향상 시킨 것입니다. 즉, Iterator가 핵심입니다.

 

Iterator와 Iterable

 

컬렉션 프레임워크에서는 Iterable과 Iterator로 컬렉션에 저장된 요소들을 읽어오는 방법을 표준화하였습니다. Iterator는 컬렉션에 저장된 모든 요소에 하나씩 접근하는 기능을 정의한 인터페이스이고, Iterable은 Iterator를 반환하는 메서드를 정의한 인터페이스입니다.

public interface Iterator<E> {
    boolean hasNext();
    E next();
    default void remove();
    ...
}
public interface Iterable<T> {
    Iterator<T> iterator();
}

 

iterator()는 Iterable인터페이스에 정의된 메서드이고, Collection인터페이스는 Iterable의 자손입니다. 따라서 Collection의 자손인 List와 Set을 구현한 클래스는 iterator()가 각 컬렉션 클래스의 특징에 맞게 작성되어 있습니다. 그래서 Iterator를 이용하면 컬렉션 클래스의 종류에 관계없이 같은 방식으로 모든 요소를 조회할 수 있습니다.

Collection c = new ArrayList();
Iterator t = c.iterator();

while(it.hasNext())
    System.out.println(it.next());

 

remove()

Iterator의 메서드 중에서 remove()는 default 메서드로 구현이 필수는 아닙니다. 

default void remove() {
    throw new UnsupportedOperationException("remove");
}

위 코드에서 알 수 있듯이 기본적으로 remove() 메서드는 UnsupportedOperationException을 발생시키도록 되어 있는데, 그 이유는 Iterator의 핵심 역할이 데이터의 순회(traversal) 에 있으며, 삭제(remove) 는 선택적인 부가 기능이기 때문입니다.


실제로 대부분의 Iterator 구현체는 요소 삭제 기능을 지원하지 않으므로, 이러한 경우 예외를 발생시키는 기본 동작을 두어 일관성을 유지하도록 설계되었습니다.

컬렉션에서 흔히 사용하는 삭제 메서드인 Collection.remove(Object)와는 별개의 메서드입니다.

 

Map

Map인터페이스를 구현한 컬렉션 클래스는 키(key)와 값(value)을 쌍으로 저장하고 있기 때문에 iterator()를 직접 호출할 수 없고, 그 대신 keySet()이나 values()과 같은 메서드를 통해서 키와 값을 각각 따로 Set의 형태로 얻어 오거나, entrySet()을 사용하여 Map.Entry를 Set으로 얻어온 후에 다시 iterator()를 호출해야 Iterator를 얻을 수 있습니다.

Map map = new HashMap();

Iterator it = map.entrySet().iterator();

 

Enumeration과 ListIterator

 

Enumeration은 컬렉션 프레임워크가 만들어지기 이전에 사용하던 것으로 Iterator의 구버전이라고 생각하면 됩니다. 이전 버전으로 작성된 소스와의 호환을 위해서 남겨 두고 있을 뿐이므로 가능하면 Enumeration 대신 Iterator를 사용하는 것이 좋습니다.

 

ListIterator는 Iterator를 상속받아서 기능을 추가한 것으로, 컬렉션의 요소에 접근할 때 Iterator는 단방향으로만 이동할 수 있는 데 반해 ListIterator는 양방향으로의 이동이 가능합니다.

public interface ListIterator<E> extends Iterator<E> {
    ...
}

 ListIterator에서는 Iterator에서 제공하는 hasNext(), next() 메서드 외에도 hasPrevious(), previous()와 같은 메서드도 추가로 제공합니다.

 

다만, ArrayList나 LinkedList와 같이 List인터페이스를 구현한 컬렉션에서만 사용가능합니다. 

public interface List<E> extends Collection<E> {
    ...
    ListIterator<E> listIterator();
}

 

 

 

Arrays

 

Arrays클래스는 배열을 다루는데 유용한 메서드가 정의되어 있습니다. 같은 기능의 메서드가 배열의 타입만 다르게 오버로딩되어 있어서 많아 보이지만, 실제로는 그리 많지 않습니다. 

 

배열의 복사 - copyOf(), copyOfRange()

 

copyOf()는 배열 전체를, copyOfRange()는 배열의 일부를 복사해서 새로운 배열을 만들어 반환합니다. 여기서 copyOfRange()에 지정된 범위의 끝은 포함되지 않습니다.

int[] arr = {0, 1, 2, 3, 4};

int[] arr2 = Arrays.copyOf(arr, arr.length);		// [0, 1, 2, 3, 4]
int[] arr3 = Arrays.copyOfRange(arr, 2, 4);		// [2, 3]

 

배열 채우기 - fill(), setAll()

 

fill()은 배열의 모든 요소를 지정된 값으로 채웁니다. setAll()은 배열을 채우는데 사용할 함수형 인터페이스를 매개변수로 받습니다. 

int[] arr = new int[3];

Arrays.fill(arr, -1);		// [-1, -1, -1]
Arrays.setAll(arr, () -> (int)(Math.random() * 5 + 1);		// [4, 1, 2]

 

배열의 정렬과 검색 - sort(), binarySearch()

 

sort()는 배열을 정렬할 때 사용하고, 배열에 저장된 요소를 검색할 때는 binarySearch()를 사용합니다. binarySearch()는 배열에서 지정된 값이 저장된 위치(index)를 찾아서 반환하는데, 반드시 배열이 정렬된 상태여야 올바른 결과를 얻습니다. 그리고 만일 검색한 값과 일치하는 요소들이 여러 개 있다면, 그 중에서 어떤 것의 위치가 반환될지는 알 수 없습니다.

int[] arr = {3, 2, 0, 1, 4};
int idx = Arrays.binarySearch(arr ,2);		// -5 : 잘못된 결과(정렬 X)

Arrays.sort(arr);
int idx = Arrays.binarySearch(arr ,2);		// 2

 

배열의 첫 번째 요소부터 순서대로 하나씩 검색하는 것을 '순차 검색(linear seach)'라고 하는데, 이 방법은 배열이 정렬되어 있지 않아도 되지만, 배열의 요소를 하나씩 비교하기 때문에 시간이 많이 걸립니다.

 

반면 '이진 검색(binary search)'은 배열의 검색할 범위를 반복적으로 절반씩 줄여가면서 검색하기 때문에 검색속도가 상당히 빠릅니다. 단, 배열이 정렬되어 있는 경우에만 사용할 수 있다는 단점이 있습니다.

 

배열의 비교와 출력 - toString(), equals(), compare(), mismatch()

 

toString()은 배열의 모든 요소를 문자열로 출력할 수 있습니다. toString()은 일차원 배열에만 사용할 수 있으므로, 다차원 배열에서는 deepToString()을 사용해야 합니다. deepToString()은 배열의 모든 요소를 재귀적으로 접근해서 문자열을 구성하므로 2차원뿐만아니라 3차원 이상의 배열에도 동작합니다.

int[] arr = {0, 1, 2, 3};
int[][] arr2 = {{1, 2}, {3, 4}};

System.out.println(Arrays.toString(arr));
System.out.println(Arrays.deepToString(arr2));

 

equals()는 두 배열에 저장된 모든 요소를 비교해서 같으면 true, 다르면 false를 반환합니다. equals()도 일차원 배열에만 사용할 수 있으므로, 다차원 배열의 비교에는 deepEquals()를 사용해야 합니다.

int[][] arr = {{1, 2}, {3, 4}};
int[][] arr2 = {{1, 2}, {5, 6}};

System.out.println(Arrays.deepEquals(arr, arr2));

 

compare()는 equals()처럼 두 배열을 비교하지만, equals()와 달리 정렬을 목적으로 비교합니다. 

 

mismatch()는 두 배열이 일치하지 않는 첫 요소의 index를 반환합니다. 두 배열이 같으면 일치하지 않는 요소를 찾을 수 없으므로 -1을 반환합니다.

int ix = Arrays.mismatch(new int[]{1}, new int[]{1, 2});

 

배열을 List로 변환 - asList()

 

asList()는 배열을 List에 담아서 반환합니다. 매개변수의 타입이 가변인수라서 배열 생성 없이 저장할 요소들만 나열하는 것도 가능합니다.

List list = Arrays.asList(new Integer[]{1, 2, 3});
List list2 = Arrays.asList(1, 2, 3, 4);

 

한 가지 주의할 점은 asList()가 반환한 List의 크기를 변경할 수 없다는 것입니다. 즉, 추가 또는 삭제가 불가능합니다.

만일 크기를 변경할 수 있는 List가 필요하다면 다음과 같이 해야합니다.

List list = new ArrayList(Arrays.asList(1, 2, 3, 4));

 

parallelXxx(), spliterator(), stream()

 

이 외에도 'parallel'로 시작하는 이름의 메서드들이 있는데, 이 메서드들은 보다 빠른 결과를 얻기 위해 여러 쓰레드가 나누어 작업을 처리합니다. spliterator()는 여러 쓰레드가 처리할 수 있게 하나의 작업을 여러 작업으로 나누는 Spliterator를 반환하며, stream() 컬렉션을 스트림으로 변환합니다.

 

 

Comparator와 Comparable

 

이전 Arrays클래스의 sort() 메서드를 호출하면 알아서 배열을 정렬하는 것처럼 보이지만, 실제로는 Character클래스의 Comparable의 구현에 의해 정렬되었던 것입니다. Comparator Comparable은 모두 인터페이스로 컬렉션을 정렬하는데 필요한 메서드를 정의하고 있으며, Comparable을 구현하고 있는 클래스들은 같은 타입의 인스턴스끼리 서로 비교할 수 있는 클래스들, 주로 Integer와 같은 wrapper클래스와 String, Date, File과 같은 것들이며 기본적으로 오름차순, 즉 작은 값에서부터 큰 값의 순으로 정렬되도록 구현되어 있습니다. 그래서 Comparable을 구현한 클래스는 정렬이 가능하다는 것을 의미합니다.

 

Comprarator와 Comparable의 실제 소스코드는 다음과 같습니다.

@FunctionalInterface
public interface Comparator<T> {
    int compare(T o1, T o2);
    boolean equals(Object obj);
}
public interface Comparable<T> {
    public int compareTo(T o);
}

 

compare() compareTo()는 선언형태와 이름이 약간 다를 뿐, 두 객체를 비교한다는 같은 기능을 목적으로 고안된 것입니다. compareTo()의 반환값은 int이지만 실제로는 비교하는 두 객체가 같으면 0, 비교하는 값보다 작으면 음수, 크면 양수를 반환하도록 구현해야 합니다. 이와 마찬가지로 compare()도 객체를 비교해서 음수, 0, 양수 중의 하나를 반환하도록 구현해야합니다.

 

equals()메서드는 모든 클래스가 갖고 있는 공통적인 메서드지만, Comparator를 구현하는 클래스는 오버라이딩이 필요할 수도 있다는 것을 알리기 위해서 정의한 것일 뿐, 그냥 compare()만 구현하면 됩니다.

 

Integer

 

다음 코드는 Integer클래스의 일부입니다.

public final class Integer extends Number implements Comparable<Integer>, ... {
    public int compareTo(Integer anotherInteger) {
        return compare(this.value, anotherInteger.value);
    }

    public static int compare(int x, int y) {
        return (x < y) ? -1 : ((x == y) ? 0 : 1);
    }
    
    ...
}

Comparable의 compareTo()를 구현해 놓은 것을 볼 수 있습니다. 두 Integer객체에 저장된 int값(value)을 비교해서 같으면 0, 크면 -1, 작으면 1을 반환합니다. 

 

Comparable을 구현한 클래스들이 기본적으로 오름차순으로 정렬되어 있지만, 내림차순으로 정렬한다던가 아니면 다른 기준에 의해서 정렬되도록 하고 싶을 때 Comparator를 구현해서 정렬 기준을 제공할 수 있습니다.

 

  • Comparable : 기본 정렬 기준을 구현
  • Comparator : 기본 정렬 기준 외에 다른 기준으로 정렬하고자 할 때

 

앞서 사용한 Arrays.sort()는 배열을 정렬할 때, Comparator를 지정해주지 않으면 저장하는 객체에 구현된 내용에 따라 정렬됩니다.

 

String

 

String의 Comparable 구현은 문자열이 사전 순으로 정렬되도록 작성되어 있습니다. 정확히 얘기하면 문자의 유니코드의 순서가 작은 값에서부터 큰 값으로 정렬되는 것입니다. 그리고 String클래스에는 아래와 같이 대소문자를 구분하지 않고 비교하는 Comparator를 상수의 형태로 제공하고 있습니다.

public static final Comparator<String> CASE_INSENSITIVE_ORDER
                                         = new CaseInsensitiveComparator();

이 Comparator를 이용하면, 문자열을 대소문자 구분없이 정렬할 수 있습니다.

Arrays.sort(strArr, String.CASE_INSENSITIVE_ORDER);

 

 

String의 기본 정렬을 반대로 하는 것, 즉 문자열을 내림차순으로 구현하는 것은 아주 간단합니다. 단지 String에 구현된 compareTo()의 결과에 -1을 곱하기만 하면 됩니다. 또는 비교하는 객체의 위치를 바꿔도 됩니다.

Arrays.sort(new String[]{"1", "2"}, new Comparator<String>() {
    @Override
    public int compare(String o1, String o2) {
        return o1.compareTo(o2) * -1;
    }
});
Arrays.sort(new String[]{"1", "2"}, new Comparator<String>() {
    @Override
    public int compare(String o1, String o2) {
        return o2.compareTo(o1);
    }
});

 

 

HashSet

 

HashSet은 Set인터페이스를 구현한 가장 대표적인 컬렉션이며, Set인터페이스의 특징대로 HashSet은 중복된 요소를 저장하지 않습니다. HashSet에 새로운 요소를 추가할 때는 add() addAll()을 사용하는데, 만일 HashSet에 이미 저장되어 있는 요소와 중복된 요소를 추가하고자 한다면 이 메서드들은 false를 반환함으로써 중복된 요소이기 때문에 추가에 실패헀다는 것을 알립니다.

 

이러한 HashSet의 특징을 이용하면, 컬렉션 내의 중복 요소들을 쉽게 제거할 수 있습니다. 그러나 ArrayList와 같이 List인터페이스를 구현한 컬렉션과 달리 HashSet은 저장순서를 유지하지 않으므로 저장순서를 유지하고자 한다면 LinkedHashSet을 사용해야 합니다.

 

다음은 HashSet의 생성자들입니다.

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

public HashSet(int initialCapacity) {..}
    
public HashSet(int initialCapacity, float loadFactor) {..}
HashSet은 내부적으로 HashMap을 이용해서 만들었으며, HashSet이란 이름은 해싱(hashing)을 이용해서 구현했기 때문에 붙여졌습니다.

 

위 생성자 중에서 4번째 생성자는 'loadFactor'라는 인자를 받는데, 이는 해시 테이블의 적재율(load factor) 을 의미하며, 테이블이 얼마나 차야 리사이징할지를 결정합니다. 기본값은 0.75로, 전체 버킷 중 75%가 채워지면 자동으로 용량을 두 배로 늘리고 재해싱을 수행합니다. 값이 낮을수록 해시 충돌이 줄어들어 검색 성능은 좋아지지만 메모리 사용량이 많아지고, 높을수록 메모리는 절약되지만 충돌이 늘어 성능이 떨어질 수 있습니다.

 

 

TreeSet

 

TreeSet은 이진 탐색 트리(binary search tree)라는 자료구조의 형태로 데이터를 저장하는 컬렉션 클래스입니다. 이진 탐색 트리는 정렬, 검색, 범위 검색(range search)에 높은 성능을 보이는 자료구조이며 TreeSet은 이진 탐색 트리의 성능을 향상시킨 '레드-블랙 트리(Red-Black tree)'로 구현되어 있습니다.

 

HashSet과 마찬가지로 생성자를 보면 내부적으로 TreeMap을 이용해서 만들었습니다.

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

 

그리고 Set인터페이스를 구현했으므로 중복된 데이터의 저장을 허용하지 않으며 정렬된 위치에 저장하므로 저장순서를 유지하지도 않습니다. 이진 트리(binary tree)는 LinkedList처럼 여러 개의 노드(node)가 서로 연결된 구조로, 각 노드에 대해 최대 2개의 노드를 연결할 수 있으며 '루트(root)'라고 불리는 하나의 노드에서부터 시작해서 계속 확장해 나갈 수 있습니다.

 

위 아래로 연결된 두 노드를 '부모-자식관계'에 있다고 하며, 위 노드를 부모 노드, 아래의 노드를 자식 노드라고 합니다. 부모-자식관계는 상대적인 것이며 하나의 부모 노드는 최대 두 개의 자식 노드와 연결될 수 있습니다.

이진 트리의 노드를 코드로 표현하면 다음과 같습니다.

class TreeNode {
    TreeNode left;
    Object element;
    TreeNode right;
}

 

이진 탐색 트리(binary search tree)는 부모노드의 왼쪽 자식노드에는 부모노드보다 작은 값이, 오른쪽 자식 노드에는 큰 값을 저장하는 이진 트리입니다. 예를 들어 이진 탐색 트리에 {7, 4, 9, 1, 5}의 값을 순서대로 저장한다고 가정하면 다음과 같습니다.

첫 번째로 저장되는 값은 루트가 되고, 두 번째 값은 트리의 루트부터 시작해서 값의 크기를 비교하면서 트리를 따라 내려갑니다. 작은 값은 왼쪽에, 큰 값은 오른쪽에 저장합니다. 이렇게 트리를 구성하면, 왼쪽 마지막 레벨이 제일 작은 값이 되고, 오른쪽 마지막 레벨의 값이 제일 큰 값이 됩니다.

데이터를 삽입할 때 트리가 한쪽으로 치우친 불균형 상태가 되면, 트리의 균형을 유지하기 위해 자동으로 회전(rotation) 연산이 수행됩니다.

 

컴퓨터는 알아서 값을 비교하지 못하므로, TreeSet에 저장되는 객체가 Comparable을 구현하던가 아니면, TreeSet에게 Comparator를 제공해서 두 객체를 비교할 방법을 알려줘야 합니다. 그렇지 않으면 TreeSet에 저장할 때 예외가 발생합니다.

 

왼쪽 마지막 값에서부터 오른쪽 값까지 값을 '왼쪽 노드 -> 부모 노드 -> 오른쪽 노드' 순으로 읽어오면 오름차순으로 정렬된 순서를 얻을 수 있습니다. TreeSet은 이처럼 정렬된 상태를 유지하기 때문에 단일 값 검색과 범위 검색(range search)이 매우 빠릅니다.

 

트리는 데이터를 순차적으로 저장하는 것이 아니라 저장위치를 찾아서 저장해야하고, 삭제하는 경우 트리의 일부를 재구성해야하므로 LinkedList보다 데이터의 추가/삭제 시간은 더 걸립니다. 대신 검색과 정렬기능이 더 뛰어납니다.

 

subSet()

 

subSet메서드를 사용하여 범위 검색(range search)을 수행할 수 있습니다. 이때 시작 범위는 포함되지만 끝 범위는 포함되지 않습니다.

다음은 예시 코드입니다.

public static void main(String[] args) {
    TreeSet set = new TreeSet();

    set.add("abc");   set.add("alien");   set.add("bat");
    set.add("car");   set.add("Car");     set.add("disc");
    set.add("dance"); set.add("dzzzz");   set.add("elephant");
    set.add("fan");	  set.add("flower");  set.add("zoo");

    System.out.println(set.subSet("b", "d");	// [bat, car]
}

문자열의 경우 정렬순서는 문자의 코드값이 기준이 되므로, 오름차순 정렬의 경우 코드값의 크기가 작은 순서에서 큰 순서, 즉, 공백, 숫자, 대문자, 소문자 순으로 정렬되고 내림차순의 경우 그 반대가 됩니다.

 

headSet()과 tailSet()

 

headSet메서드와 tailSet메서드를 이용하면, TreeSet에 저장된 객체 중 지정된 기준 값보다 큰 값의 객체들과, 작은 값의 객체들을 얻을 수 있습니다. 다음은 예시 코드입니다.

public static void main(String[] args) {
    TreeSet set = new TreeSet();
    int[] score = {80, 95, 50, 35, 45, 65, 10, 100};

    for(int i = 0; i < score.length; i++)
        set.add(score[i]);

    System.out.println(set.headSet(new Integer(50)));	// [10, 35, 45]
    System.out.println(set.tailSet(new Integer(50)));	// [50, 65, 80, 95, 100]
}

위 그림을 보면 50이 저장된 노드의 왼쪽노드와 그 아래 연결된 모든 노드의 값은 50보다 작고, 나머지 다른 노드의 값들은 50보다 같거나 크다는 것을 알 수 있습니다.

 

 

HashMap과 Hashtable

 

Hashtable과 HashMap의 관계는 Vector와 ArrayList의 관계와 같아서, Hashtable보다는 새로운 버전인 HashMap을 사용할 것을 권장합니다.

 

HashMap은 Map을 구현했으므로 앞에서 살펴본 Map의 특징, 키(key)와 값(value)을 묶어서 하나의 데이터(entry)로 저장한다는 특징을 갖습니다. 그리고 해싱(hashing)을 사용하기 때문에 많은 양의 데이터를 검색하는데 있어서 뛰어난 성능을 보입니다.

 

다음은 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;
        ...
    }
}

 

HashMap은 Map.Entry인터페이스를 구현한 Node라는 내부 클래스를 정의하고, 다시 Node타입의 배열을 선언하고 있습니다. 키(key)와 값(value)은 별개의 값이 아니라 서로 관련된 값이기 때문에 각각의 배열로 선언하기 보다는 하나의 클래스로 정의해서 하나의 배열로 다루는 것이 데이터의 무결성적인 측면에서 더 바람직하기 때문입니다.

 

키(key)는 저장된 값을 찾는데 사용되는 것이기 때문에 컬렉션 내에서 유일(unique)해야 합니다. 즉, HashMap에 저장된 데이터를 하나의 키로 검색했을 때 결과가 단 하나이어야 함을 뜻합니다. 만일 하나의 키에 대해 여러 검색결과 값을 얻는다면 원하는 값이 어떤 것인지 알 수 없기 때문입니다. 

Hashtable은 키와 값으로 null을 허용하지 않지만, HashMap은 허용합니다. 그래서 `map.put(null, null);` 이나 `map.get(null);` 과 같이 할 수 있습니다.

 

데이터를 읽어 올 때는 entrySet(), keySet(), values() 를 이용해서 요소를 순회할 수 있습니다.

 

해싱과 해시함수

 

해싱이란 해시함수(hash function)를 이용해서 데이터를 해시테이블(hash table)에 저장하고 검색하는 기법을 말합니다. 해시함수는 데이터가 저장되어 있는 곳을 알려주기 때문에 다량의 데이터 중에서도 원하는 데이터를 빠르게 찾을 수 있습니다.

 

해싱을 구현한 컬렉션 클래스로는 HashSet, HashMap, Hashtable 등이 있습니다. Hashtable은 컬렉션 프레임워크가 도입되면서 HashMap으로 대체되었으나, 이전 소스와의 호환성 문제로 남겨두고 있습니다. 따라서 가능하면 Hashtable대신 HashMap 사용을 권장합니다.

 

해싱에서 사용하는 자료구조는 다음과 같이 배열과 LinkedList의 조합으로 되어있습니다.

HashMap 1

 

저장할 데이터의 키를 해시함수에 넣으면 배열의 한 요소를 얻게 되고, 다시 그 곳에 연결되어 있는 LinkedList에 저장하게 됩니다.

 

앞서 배운 바와 같이 LinkedList는 검색에 불리한 자료구조이기 때문에 LinkedList의 크기가 커질수록 검색속도도 떨어지게 됩니다. 반면 배열은 크기가 커져도, 원하는 요소가 몇 번째에 있는지만 알면 바로 접근 가능하기 때문에 빠른 검색결과를 얻을 수 있습니다.

 

따라서 위의 그림의 형태보다 아래의 그림의 형태가 더 빠른 검색 결과를 얻을 수 있습니다.

HashMap 2

 

위 HashMap 2 그림과 같은 형태가 되기 위해서는 해시함수가 서로 다른 키에 대해서 중복된 해시코드의 반환을 최소화해야 합니다. 그래야 HashMap에서 빠른 검색시간을 얻을 수 있습니다. 그래서 해싱을 구현하는 과정에서 제일 중요한 것은 해시함수의 알고리즘입니다.

 

실제 HashMap과 같이 해싱을 구현한 컬렉션 클래스에서는 Object클래스에 정의된 hashCode()를 해시함수로 사용합니다. Object클래스에 정의된 hashCode()는 객체의 주소를 이용하는 알고리즘으로 해시코드를 만들어 내기 때문에 모든 객체에 대해 hashCode()를 호출한 결과가 서로 유일한 매우 좋은 방법입니다.

 

String클래스의 경우 Object로부터 상속받은 hashCode()를 오버라이딩해서 문자열의 내용으로 해시코드를 만들어 냅니다. 그래서 서로 다른 String인스턴스일지라도 같은 내용의 문자열을 가졌다면 hashCode()를 호출하면 같은 해시코드를 얻습니다. 

 

해싱을 구현한 컬렉션 클래스에서는 서로 다른 두 객체에 대해 equals()로 비교한 결과가 true인 동시에 hashCode()의 반환값이 같아야 같은 객체로 인식합니다. HashMap에서도 같은 방법으로 객체를 구별하며, 이미 존재하는 키에 대한 값을 저장하면 기존의 값을 새로운 값으로 덮어씁니다.

 

그래서 새로운 클래스를 정의할 때 equals()를 재정의해야 한다면 hashCode()도 같이 재정의해서 equals()의 결과가 true인 두 객체의 hashCode()의 결과가 항상 같도록 해주어야 합니다. 그렇지 않으면 HashMap과 같이 해싱을 구현한 컬렉션 클래스에서는 equals()의 호출결과가 true지만 해시코드가 다른 두 객체를 서로 다른 것으로 인식하고 따로 저장하게 됩니다.

 

 

TreeMap

 

TreeMap은 이름에서 알 수 있듯이 이진 검색 트리의 형태로 키와 값의 쌍으로 이루어진 데이터를 저장합니다. 그래서 앞서 TreeSet과 같이 검색과 정렬에 적합한 컬렉션 클래스입니다. 검색에 관한한 대부분의 경우에서 HashMap이 TreeMap보다 더 뛰어나나, 범위 검색이나 정렬이 필요한 경우에는 TreeMap이 낫습니다.

 

컴퓨터는 알아서 값을 비교하지 못하므로, TreeMap에 저장되는 키(key) 객체가 Comparable을 구현하던가 아니면, TreeMap에게 Comparator를 제공해서 키를 비교할 방법을 알려줘야 합니다.

 

또한 TreeSet의 subSet(), headSet(), tailSet()과 같이 TreeMap에서도 subMap(), headMap(), tailMap() 메서드를 제공합니다.

 

 

Properties

 

Properties는 HashMap의 구버전인 Hashtable을 상속받아 구현한 것으로, Hashtable은 키와 값을 (Object, Object)의 형태로 저장하는데 비해 Properties는 (String, String)의 형태로 저장하는 보다 단순화된 컬렉션클래스입니다.

public class Properties extends Hashtable<Object,Object> {..}

 

주로 애플리케이션의 환경설정과 관련된 속성(property)을 저장하는데 사용되며 데이터를 파일로부터 읽고 쓰는 편리한 기능을 제공합니다. 그래서 간단한 입출력은 Properties를 활용하면 몇 줄의 코드로 쉽게 해결될 수 있습니다.

 

다음은 Properties의 기본적인 메서드인 setProperty()와 getProperty()입니다.

public synchronized Object setProperty(String key, String value) {
    return put(key, value);
}

public String getProperty(String key) {..}
public String getProperty(String key, String defaultValue) {..}

setProperty()는 단순히 Hashtable의 put메서드를 호출할 뿐이며, 기존의 같은 키로 저장된 값이 있는 경우 그 값을 Object 타입으로 반환하며, 그렇지 않을 때는 null을 반환합니다.

 

getProperty()는 Properties에 저장된 값을 읽어오는 일을 하는데, 만일 읽어오려는 키가 존재하지 않으면 지정된 기본값(defaultValue)를 반환합니다.

 

Properties는 컬렉션 프레임워크 이전의 구버전이므로 Iterator가 아닌 Enumeration을 사용합니다. 그리고 list메서드를 이용하면 Properties에 저장된 모든 데이터를 화면 또는 파일에 편리하게 출력할 수 있습니다.

public void list(PrintStream out) {..}
public void list(PrintWriter out) {..}

 

 

load()와 store()

 

다음은 외부파일(input.txt)로부터 데이터를 읽어오는 예제입니다.

public static void main(String[] args) {
    Properties prop = new Properties();
    File inputFile = new File("input.txt");

    try (FileInputStream fis = new FileInputStream(inputFile)) {
        prop.load(fis);
    } catch (IOException e) {
        e.printStackTrace();
        System.exit(0);
    }
}

 

다음은 반대로 Properties에 저장된 데이터를 외부파일에 저장하는 예제입니다.

public static void main(String[] args) {
    Properties prop = new Properties();

    prop.setProperty("username", "admin");
    prop.setProperty("password", "1234");
    prop.setProperty("language", "ko");

    File outputFile = new File("output.properties");

    try (FileOutputStream fos = new FileOutputStream(outputFile)) {
        prop.store(fos, "Example");
    } catch (IOException e) {
        e.printStackTrace();
    }
}

store()의 두 번째 인자는 첫 번째 줄 주석(comment)의 내용이며, 자동으로 두 번째 줄에 타임스탬프 주석을 추가합니다.

자바의 Properties 클래스는 기본적으로 파일을 읽고 쓸 때 내부 인코딩으로 'ISO-8859-1(Latin-1)'을 사용하기 때문에, UTF-8로 읽고 쓰기 위해서는 문자셋을 명시적으로 지정해줘야 합니다.

 

시스템 속성

 

Properties는 다음 코드처럼 JVM의 시스템 속성(System Properties)에 접근할 수 있는 기능도 제공합니다.

Properties sysProp = System.getProperties();
sysProps.list(System.out);	// 출력

 

이 중에서 필요한 속성은 getProperty()를 사용하여 가져올 수 있습니다.

 

 

Collections

 

Arrays가 배열과 관련된 메서드를 제공하는 것처럼, Collections클래스에서는 컬렉션과 관련된 메서드를 제공합니다. fill(), copy(), sort(), binarySearch() 등의 메서드는 두 클래스에 모두 포함되어 있으며 같은 기능을 합니다.

 

컬렉션 동기화

 

멀티 쓰레드(multi-thread) 프로그래밍에서는 하나의 객체를 여러 쓰레드가 동시에 접근할 수 있기 때문에 데이터의 무결성을 유지하기 위해서는 공유되는 객체에 동기화(synchronization)이 필요합니다.

 

Vector와 Hashtable과 같은 구버전(JDK 2이전)의 클래스들은 자체적으로 동기화처리가 되어 있는데, 멀티쓰레드 프로그래밍이 아닌 경우에는 불필요한 기능이 되어 성능을 떨어뜨리는 요인이 됩니다.

 

그래서 새로 추가된 ArrayList와 HashMap과 같은 컬렉션은 동기화를 자체적으로 처리하지 않고 필요한 경우에만 java.util.Collections클래스의 동기화 메서드를 이용해서 동기화처리가 가능하도록 변경하였습니다.

 

Collections클래스에는 다음과 같은 동기화 메서드를 제공하고 있으므로, 동기화가 필요할 때 해당하는 것을 사용하면 됩니다.

static Collection synchronizedCollection(Collection c)
static List synchronizedList(List list)
static Set synchronizedSet(Set s)
static Map synchronizedMap(Map m)
static SortedSet synchronizedSortedSet(SortedSet s)
static SortedMap synchronizedSortedMap(SortedMap m)
static NavigableSet synchronizedNavigableSet(NavigableSet s)
static NavigableMap synchronizedNavigableMap(NavigableMap m)

 

사용법은 다음과 같습니다.

List syncList = Collections.synchronizedList(new ArrayList(...));

 

불변 컬렉션

 

컬렉션에 저장된 데이터를 보호하기 위해서 컬렉션을 변경할 수 없게, 즉 읽기전용으로 만들어야할 때가 있습니다. 주로 멀티 쓰레드 프로그래밍에서 여러 쓰레드가 하나의 컬렉션을 공유하다보면 데이터가 손상될 수 있는데, 이를 방지하려면 아래의 메서드들을 이용하면 됩니다.

static Collection unmodifiableCollection(Collection c)
static List unmodifiableList(List list)
static Set unmodifiableSet(Set s)
static Map unmodifiableMap(Map m)
static NavigableSet unmodifiableNavigableSet(NavigableSet s)
static NavigableMap unmodifiableNavigableMap(NavigableMap m)
static SortedSet unmodifiableSortedSet(SortedSet s)
static SortedMap unmodifiableSortedMap(SortedMap m)
static SequencedSet unmodifiableSequencedSet(SequencedSet s)
static SequencedMap unmodifiableSequencedMap(SequencedMap m)
static SequencedCollection unmodifiableSequencedCollection(SequencedCollection c)

 

데이터가 보호되는 것은 물론 성능 향상까지 기대할 수 있습니다.

 

싱글톤 컬렉션

 

단 하나의 객체만을 저장하는 컬렉션, 즉 크기가 1인 컬렉션을 만들고 싶은 경우가 있습니다. 이럴 때는 아래의 메서드를 사용하면 됩니다.

static List singletonList(Object o)
static Set singletonSet(Object o)
static Map singletonMap(Object key, Object value)

 

매개변수로 저장할 요소를 지정하면, 해당 요소를 저장하는 컬렉션을 반환합니다. 그리고 반환된 컬렉션은 불변(immutable)입니다.

 

빈 컬렉션

 

NullPointerException의 발생을 줄이기 위해 문자열을 null 대신 빈 문자열로 초기화하는 것처럼, 같은 이유로 비슷한 상황에서 null 대신 빈 컬렉션을 사용해야할 때가 있습니다. Collections클래스는 기본적으로 아래와 같이 3개의 빈 컬렉션을 제공하며 모두 불변(immutable) 입니다.

static final List EMPTY_LIST
static final Set EMPTY_SET
static final Map EMPTY_MAP

 

이 외에도 아래와 같이 빈 컬렉션을 반환하는 메서드도 제공합니다. Collections의 생성자는 private이라 상속이 불가능하므로 오버라이딩도 할 수 없습니다. 그래서 메서드에 final을 붙일 필요가 없지만 몇몇 메서드는 컴파일러 최적화를 위해 final이 붙어있습니다.

static final List emptyList()
static final Set emptySet()
static final Map emptyMap()
static NavigableSet emptyNavigableSet()
static final NavigableMap emptyNavigableMap()
static SortedSet emptySortedSet()
static final SortedMap emptySortedMap()

 

그리고 컬렉션은 아니지만 이 외에도 빈 Iterator를 반환하는 메서드도 있습니다.

static Enumeration emptyEnumeration()
static Iterator emptyIterator()
static ListIterator emptyListIterator()