자바 컬렉션에는 List, Queue, Set 등이 있습니다. 이 컬렉션들은 여러 클래스 계층의 연속으로 구성돼있어서 여러 컬렉션에서 add() 메서드를 제공하는 특성이 있습니다. 부모클래스는 같아도 상속받거나 구현하는 컬렉션마다 다른 자료구조를 사용할 수 있으므로 add() 메서드의 구현부는 다를텐데, ArrayList를 통해 이 계층 구조를 분석해보겠습니다.
자바에서 컬렉션은 최상위 클래스인 Iterable 인터페이스를 시작으로 각기 목적에 맞는 자료구조를 구현하기 위한 계층 구조를 가지고 있습니다. 이 클래스들 중에서 ArrayList 클래스를 add() 메서드 중심으로 알아보려고 합니다.
[그림2] add() 메서드 중심의 ArrayList 클래스 다이어그램
ArrayList 클래스와 구현, 상속 관계에 있는 클래스를 클래스 다이어그램으로 표시하면 다음과 같습니다. ArrayList 클래스는 여러 인터페이스와 추상 클래스를 통해 만들어졌습니다. 이 클래스 다이어그램에서 먼저 맨 아래에 있는 마커 인터페이스 3개를 먼저 보겠습니다.
[그림3] 마커 인터페이스 친구들
// RandomAccess 인터페이스: 요소에 대한 인덱스 접근을 효율적으로 할 수 있음
public interface RandomAccess {
}
// Serializable 인터페이스: 데이터를 바이트 스트림으로 변환하여 파일, DB에 저장하거나 네트워크로 전송할 수 있음
public interface Serializable {
}
// Cloneable 인터페이스: Object 클래스의 clone() 메서드를 통해 객체의 복사본을 생성할 수 있음
public interface Cloneable {
}
[그림4] add() 메서드가 없는 Iterable, SequencedCollection 인터페이스
다음으로는 ArrayList 계층 구조에서 add() 메서드를 갖고 있지 않은 Iterable, SequencedCollection 인터페이스를 알아보겠습니다.
다이어그램 상 최상단에 있는 Iterable 인터페이스를 구현한 클래스는 향상된 for문(for-each)을 사용(Override 해서)할 수 있습니다. 따라서 ArrayList의 인스턴스는 for-each문을 사용할 수 있다는 뜻이 됩니다. List, Queue, Set 등 Iterable 인터페이스를 상속받은 Collection 인터페이스를 구현했다면 모두 for-each문을 사용할 수 있습니다. for-each를 사용하게 되면 기존의 for 루프에 비해 코드가 간결해지고, 인덱스나 반복자를 직접 다루지 않아 코드의 의도가 명확해지는 장점이 있습니다.
SequencedCollection 인터페이스는 자바 21에서 추가되었고, addFirst(), addLast(), getFisrt(), getLast(), removeFirst(), removeLast(), reversed() 메서드를 사용할 수 있게 해줍니다. 직관적인 메서드 이름에서 알 수 있듯이, List처럼 순차적인 데이터 구조에서 처음과 끝 데이터를 편리하게 조작하는 메서드를 사용할 수 있습니다.
(Stack 클래스는 Vector 클래스를 상속받은 자바의 잘못된 구조적 문제 때문에 이 메서드들을 사용할 경우 LIFO 원칙을 어기게 되는 문제가 있습니다..)
본격적으로 add() 메서드가 ArrayList 클래스에 오기까지 어떻게 변화하는지 알아보겠습니다. 자바 컬렉션에서 List, Queue, Set은 Collection 인터페이스를 구현하는데, 컬렉션이라면 기본적으로 가져야할 특성들을 정의해두었습니다. 기본적인 컬렉션의 특징 중 하나로 데이터를 추가할 수 있다는 것이 있기 때문에 add() 메서드를 정의하면서, 구체적인 구현은 각 컬렉션 타입에 따라 다르게 구현할 수 있도록 추상 메서드로 Collection 인터페이스에 처음 정의됩니다.
public interface Collection<E> extends Iterable<E> {
...
boolean add(E e);
...
Collection 인터페이스를 구현한 AbstractCollection 클래스에서는 add() 메서드를 다음과 같이 UnsupportedOperationException을 던지도록 구현하고 있습니다. 이유는 불변 컬렉션 등 모든 컬렉션이 add 연산을 지원하지 않을 수도 있고, 템플릿 메서드 패턴에 따라 하위 클래스가 필요에 의해 이 메서드를 오버라이드 할 수 있도록 하기 위함이 있습니다. add 메서드를 이용하려는 구체 클래스는 반드시 자신의 add 구현을 제공하도록 명시적으로 요구하는 효과가 있습니다.
public abstract class AbstractCollection<E> implements Collection<E> {
...
public boolean add(E e) {
throw new UnsupportedOperationException();
}
...
불변 컬렉션은 unmodifiableCollection 메서드를 통해 정의할 수 있습니다. 이름 그대로 add(), remove() 메서드 등으로 요소 조작이 불가능합니다. 사용 예시를 아래와 같습니다. 불변 컬렉션은 스레드 안정성이 보장되고, 데이터 무결성을 보장할 수 있으며 데이터가 변경되지 않기 때문에 캐싱 등을 통해 성능상의 이점을 가져올 수 있습니다.
import java.util.*;
public class Main {
public static void main(String[] args) {
List<Object> objects = new ArrayList<>(); // ArrayList 생성
objects.add(10);
objects.add(20);
objects.add(30); // 요소를 3개 추가한다.
Collection<Object> unmodifiableCollection = Collections.unmodifiableCollection(objects); // 불변 컬렉션으로 변환
unmodifiableCollection.add(10); // 요소를 추가해본다 (아래와 같이 컴파일 에러 발생)
}
}
/*
Exception in thread "main" java.lang.UnsupportedOperationException
at java.base/java.util.Collections$UnmodifiableCollection.add(Collections.java:1092)
at Main.main(Main.java:11)
Process finished with exit code 1
*/
AbstractCollection클래스를 상속받은 AbstractList 클래스는 add()를 다음과 같이 Override 하였습니다. 기본적인 add 동작을 제공해서 하위 클래스의 구현 부담을 줄이면서도, List의 특성인 인덱스 기반 add의 동작을 지원할 수 있음을 정의합니다. AbstractCollection에서 그랬던 것처럼 List의 동작에 따라 인덱스 add 메서드의 구체적인 동작을 구현하도록 명시적으로 요구하는 효과가 있습니다.
public abstract class AbstractList<E> extends AbstractCollection<E> implements List<E> {
...
public boolean add(E e) {
add(size(), e);
return true;
}
public void add(int index, E element) { // List 인터페이스에 정의된 메서드
throw new UnsupportedOperationException();
}
...
ArryList의 특성에 맞게 완전히 구현하였습니다. ArrayList의 핵심 특성인 동적 크기 조정을 구현하였고, 내부 배열에 인덱스 접근을 통해 요소를 추가함으로써 높은 성능을 제공합니다.
public class ArrayList<E> extends AbstractList<E>
implements List<E>, RandomAccess, Cloneable, java.io.Serializable
{
...
public boolean add(E e) {
modCount++;
add(e, elementData, size);
return true;
}
private void add(E e, Object[] elementData, int s) {
if (s == elementData.length)
elementData = grow(); // ArrayList에 할당된 공간이 가득 찼을 경우, 공간 확장 메서드 호출 (ArrayList의 특성)
elementData[s] = e;
size = s + 1;
}
...
[그림5] List 인터페이스를 불필요하게 구현하고 있는 듯한 모습..?
[그림5]를 보면 ArrayList 클래스는 List 인터페이스를 구현하고 있는데, ArrayList 클래스가 상속받은 AbstractList 클래스가 이미 List 인터페이스를 구현하고 있습니다. 이런식으로 구현 클래스에서 인터페이스를 다시 구현해주는 것은 다음과 같은 이유가 있습니다.
ArrayList를 상속이나 구현 없이 단일 클래스로 구현할 수도 있겠지만, 계층 구조를 통해 구현된 여러가지 이유가 있습니다. 컬렉션이 전체적으로 비슷한 구조를 가지는 부분이 많기 때문에 공통 로직을 상위 클래스에서 구현하여 중복을 줄일 수 있습니다. 그리고 공통 로직에서 필요한 세부 구현만을 하위 클래스에서 구현해주면 되기 때문에 새로운 컬렉션 타입의 추가에도 확장성있게 대응이 가능합니다. 또한 가장 중요하다고 생각하는 것은 모든 컬렉션이 공통 인터페이스를 따르도록 하여 이것들을 사용하는 입장에서 일관성있는 사용을 보장해준다는 특성입니다. 모든 컬렉션이 각자 자기들만의 표현을 통해 추가, 삭제, 조회 등의 기능을 제공한다면 개발하는 과정에서 많은 혼란으로 인해 개발 효율이 떨어졌을 것입니다.
처음 컬렉션의 클래스 다이어그램을 봤을 때 복잡해보였지만 코드의 재사용성과 유지보수성을 높이고, 오버로딩과 오버라이딩을 통해 메서드를 구체화하면서 객체지향 원칙인 다형성을 추구하기 위한 구조임을 알 수 있었습니다. 이런 계층 구조를 통해 코드 재사용, 유연성, 확장성, 일관성 있는 개발이 가능해졌습니다. 컬렉션 뿐만 아니라 자바의 많은 API들이 이러한 객체지향의 특성을 기반으로 개발되었기 때문에 여전히 많은 지지를 받고 있다고 생각합니다. 앞으로 서비스를 개발할 때도 이러한 특성을 잘 고려하여 유연하고 확장성있는 구조를 유지할 수 있도록 노력할 필요가 있겠습니다.