도도한 개발자

[Java] #22-1. Collection(컬렉션) - List(1) 본문

Backend/Java

[Java] #22-1. Collection(컬렉션) - List(1)

Kiara Kim 2022. 4. 6. 20:00

* 자바 컬렉션

 

자바에서 컬렉션은 목록성 데이터를 처리하는 자료 구조를 통칭한다. 자료 구조란 무엇일까? 영어로 "Data Structure"이라고 한다. 즉 어떤 정보를 담는 것을 의미하는데, 하나의 데이터가 아닌 여러 데이터를 담을 때 사용한다. 

 

여러 개의 String 객체들을 하나의 객체에 담는다고 생각해보자. 배열에 담아도 될 것이다. 성능상이나 메모리 효율면에서 가장 좋기 때문이다. 그러나 배열은 그 크기가 정해져 있을 때 유용하다. 그런데 담으려는 데이터의 크기가 얼마나 되는지 모르는 경우엔 어떻게 할까? 

 

자바에서 데이터를 담는 자료 구조는 크게 다음과 같이 분류할 수 있다.

 

· 순서가 있는 목록(List)형

· 순서가 중요하지 않은 셋(Set)형

· 먼저 들어온 것이 먼저 나가는 큐(Queue)형

· 키-값(key-value)으로 저장되는 맵(Map)형

 

자바에서 "목록"과 "셋", "큐"는 Collection이라는 인터페이스를 구현하고 있다. 자바의 컬렉션과 관련된 클래스들을 하나의 그림으로 나타내면 다음과 같다.

 

먼저 "목록"과 "셋", "큐"의 기본이 되는 Collection 인터페이스에 대해 알아보자. Collection 인터페이스는 다음과 같이 선언되어 있다.

 

public interface Collection<E> extends Iterable<E>

 

이 Collection 인터페이스 선언문에서 특이한 것은 Iterable<E>이라는 인터페이스를 확장했다는 점이다. Iterable 인터페이스에 선언되어 있는 메소드는 단지 하나다. 

 

리턴 타입 메소드 이름 및 매개 변수
Iterator<T> iterator()

 

결론적으로 Collection 인터페이스가 Iterable 인터페이스를 확장했다는 의미는, Iterator 인터페이스를 사용하여 데이터를 순차적으로 가져올 수 있다는 의미이다. Collection 인터페이스에 선언된 주요 메소드들의 목록을 살펴보자.

 

리턴 타입 메소드 이름 및 매개 변수 설명
boolean add(E e) 요소를 추가한다.
boolean addAll(Collection) 매개 변수로 넘어온 컬렉션의 모든 요소를 추가한다.
void clear() 컬렉션에 있는 모든 요소 데이터를 지운다.
boolean contains(Object) 매개 변수로 넘어온 객체가 해당 컬렉션에 있는지 확인한다. 동일한 값이 있으면 true를 리턴한다.
boolean containsAll(Collection) 매개 변수로 넘어온 객체들이 해당 컬렉션에 있는지 확인한다. 매개 변수로 넘어온 컬렉션에 있는 요소들과 동일한 값들이 모두 있으면 true를 리턴한다.
boolean equals(Object) 매개 변수로 넘어온 객체와 같은 객체인지 확인한다.
int hashCode() 해시 코드값을 리턴한다.
boolean isEmpty() 컬렉션이 비어있는지 확인한다. 비어있으면 ture를 리턴한다.
Iterator iterator() 데이터를 한 건씩 처리하기 위한 Iterator 객체를 리턴한다.
boolean remove(Object) 매개 변수와 동일한 객체를 삭제한다.
boolean removeAll(collection) 매개 변수로 넘어온 객체들을 해당 컬렉션에서 삭제한다.
boolean retainAll(Collection) 매개 변수로 넘어온 객체들만을 컬렉션에 남겨 둔다.
int size() 요소의 개수를 리턴한다.
Object[] toArray() 컬렉션에 있는 데이터들을 배열로 복사한다.
<T> T[] toArray(T[]) 컬렉션에 있는 데이터들을 지정한 타입의 배열로 복사한다.

 

 

* ArrayList에 대하여

 

지금 학습하는 컬렉션, 나중에 배울 쓰레드, IO, 네트워크 등의 관련 클래스를 사용할 때에는 한 번 정도 그 클래스의 상속 관계를 살펴보는 것이 좋다. 그 클래스의 API에 있는 메소드나 상수만 사용할 수 있는 것이 아니고 부모 클래스에 선언되어 있는 메소드도 사용할 수 있기 때문이다. 

 

가장 먼저 ArrayList 클래스의 상속 관계를 보자.

 

java.lang.Object
  ㄴ java.util.AbstractCollection<E>
    ㄴ java.util.AbstractList<E>
      ㄴ java.util.ArrayList<E>

 

ArrayList의 가장 상위 부모는 Object 클래스다. 그 다음으로 AbstractCollection, AbstractList 순으로 확장했다. AbstractCollection은 Collection 인터페이스 중 일부 공통적인 메소드를 구현해 놓은 것이며, AbstractList는 List 인터페이스 중 일부 공통적인 메소드를 구현해 놓은 것이다. ArrayList가 구현한 모든 인터페이스들은 다음과 같다.

 

Serializable, Cloneable, Iterable<E>, Collection<E>, List<E>, RandomAccess

이와 같은 인터페이스들을 ArrayList가 구현했다는 것은 각 인터페이스에서 선언한 기능을 ArrayList에서 사용할 수 있다는 말이다.

 

 

*ArrayList의 3개의 생성자

 

ArrayList는 "확장 가능한 배열"이라고도 한다. 따라서 배열처럼 사용하지만, 대괄호는 사용하지 않고, 메소드를 통해서 객체를 넣고, 빼고, 조회한다. 생성자를 보자.

 

생성자 설명
ArrayList() 객체를 저장할 공간이 10개인 ArrayList를 만든다.
ArrayList(Collection<? extends E> c) 매개 변수로 넘어온 컬렉션 객체가 저장되어 있는 ArrayList를 만든다.
ArrayList(int initialCapacity) 매개 변수로 넘어온 initialCapacity 개수만큼의 저장 공간을 갖는 ArrayList를 만든다.

 

코드를 보며 이해해보자. 

 

import java.util.ArrayList;

public class ListSample {

	public static void main(String[] args) {
		ListSample sample = new ListSample();
		sample.checkArrayList1();
	}
	public void checkArrayList1() {
		ArrayList list1 = new ArrayList(); 
	}
}

 

컴파일이 잘 되었다. list1 객체를 생성하면, 이 ArrayList에는 어떤 객체도 넣을 수 있다. 그리고 데이터를 넣을 땐 add()라는 메소드를 사용한다.

 

public void checkArrayList1() {
    ArrayList list1 = new ArrayList(); 
    list1.add(new Object());
    list1.add("ArrayListSample");
    list1.add(new Double(1));
}

 

그런데 보톤 ArrayList는 이렇게 사용하지 않고 대부분 서로 다른 종류의 객체를 하나의 배열에 넣지 않고 하나의 종류의 객체만 저장한다. 여러 종류를 하나의 객체에 담을 때엔 되도록 DTO라는 객체를 하나 만들어 담는 것이 좋다. 그래서 컬렉션 관련 클래스의 객체들을 선언할 땐 제네릭을 사용하여 선언하는 것이 권장된다.

 

예를 들어 String만 담는 ArrayList를 생성할 때에는 다음과 같이 사용하면 된다.

 

ArrayList<String> list1 = new ArrayList<String>();

 

JDK 7부터는 생성자를 호출하는 부분에 따로 타입 적지 않고 다음과 같이 <>로만 사용해도 좋다.

 

ArrayList<String> list1 = new ArrayList< >();

 

이렇게 선언하고 컴파일 해보면 컴파일 에러나 발생한다. 컴파일러 입장에선 "String만 넣는다면서 왜 Object와 Double 타입을 넣어?" 하면서 에러를 발생시킨다. 이렇게 제네릭을 사용하면 컴파일 시점에 타입을 잘못 지정한 부분을 걸러낼 수 있다. 

 

ArrayList 객체를 선언할 때 매개 변수를 넣지 않으면, 초기 크기는 10이다. 따라서 10개 이상의 데이터가 들어가면 크기를 늘이는 작업이 ArrayList 내부에서 자동으로 수행된다. 이렇게 되면 애플리케이션 성능에 영향을 주게 되므로 데이터의 크기가 어느 정도 예측 가능하다면 다음과 같이 예측한 초기 크기를 지정하는 것이 권장된다.

 

ArrayList<String> list2 = new ArrayList<String>(100);

 

 

* ArrayList에 데이터를 담아보자

 

리턴 타입 메소드 이름 및 매개 변수 설명
boolean add(E e) 매개 변수로 넘어온 데이터를 가장 끝에 담는다.
void add(int index, E e) 매개 변수로 넘어온 데이터를 지정된 index 위치에 담는다.
boolean addAll(Collection<? extends E> c) 매개 변수로 넘어온 컬렉션 데이터를 가장 끝에 담는다.
boolean addAll(int index, Collection<? extends E> c) 매개 변수로 넘어온 컬렉션 데이터를 index에 지정된 위치부터 담는다.

 

간단한다. 하나의 데이터를 담을 때에는 add() 메소드를, Collection을 구현한 객체를 한꺼번에 담을 때에는 addAll() 메소드를 사용하면 된다. ArrayList는 확장된 배열 타입이기 때문에 배열처럼 순서가 매우 중요하다.

 

** add(E e)

 

이 메소드를 사용하여 데이터를 추가했을 때 리턴되는 boolean 값은 제대로 추가가 되었는지 여부를 말한다. 

 

** add(int index, E e)

 

지정된 위치에 데이터를 담는다. 이 경우 지정된 위치에 있는 기존 데이터들은 위치가 하나씩 뒤로 밀려난다. 코드로 확인해보자.

 

public void checkArrayList2() {
    ArrayList<String> list = new ArrayList<String>();
    list.add("A");
    list.add("B");
    list.add("C");
    list.add("D");
    list.add("E");
    list.add(1, "A1");
}

 

출력한 것이 없으니 아무 내용도 나오지 않는다. index자리에 1대신 10을 쓰면 어떻게 될까? 컴파일은 잘 되지만 실행할 때 다음과 같이 IndexOutOfBoundsException이라는 예외가 발생한다.

 

Exception in thread "main" java.lang.IndexOutOfBoundsException: Index: 10, Size: 5
	at java.base/java.util.ArrayList.rangeCheckForAdd(ArrayList.java:787)
	at java.base/java.util.ArrayList.add(ArrayList.java:512)
	at chapter22.ListSample.checkArrayList2(ListSample.java:23)
	at chapter22.ListSample.main(ListSample.java:9)

 

ArrayList의 크기는 5인데 10번째 값을 찾고 있다고 알려준다. 이렇게 위치를 잘못 지정하면 실행 시 예외가 발생하므로 주의해야 한다.

 

다음과 같이 출력문을 만들어 메소드의 결과를 출력해보자. 

 

for(String i:list) {
    System.out.println(i);

 

이 방식은 Collection 인터페이스를 구현한 모든 클래스에서 사용할 수 있으며 결과는 예상과 같이 출력된다.

 

A
A1
B
C
D
E

 

이번엔 addAll() 메소드의 예제를 보자.

 

public void checkArrayList3() {
    ArrayList<String> list = new ArrayList<String>();
    list.add("A");
    list.add("B");
    list.add("C");
    list.add("D");
    list.add("E");
    list.add(1, "A1");
    ArrayList<String> list2 = new ArrayList<String>();
    list2.add("0 ");
    list2.addAll(list);
    for(String i:list2) {
        System.out.println(i);
    }
}

 

방금 사용한 메소드에 list2라는 ArrayList의 객체를 새로 만들고 addAll() 메소드를 사용해 값을 추가했다. 그러면 비어있는 list2 객체에 값들이 들어간다. 결과는 다음과 같다.

 

0 
A
A1
B
C
D
E

예상대로 나왔다. 그런데 만약 list의 값을 list2에 복사해야 할 일이 생긴 경우 다음과 같이 생성자를 사용하면 편리하다.

 

ArrayList<String> list2 = new ArrayList<String>(list);

 

이것이 가능한 이유는 ArrayList는 Collection 인터페이스를 구현한 어떠한 클래스도 포함시킬 수 있는 생성자가 있기 때문이다. 그럼 여기서 도대체 왜 Collection을 매개 변수로 갖는 생성자와 메소드가 존재할까? 다음의 코드를 보자.

 

public void checkArrayList4() {
    ArrayList<String> list = new ArrayList<String>();
    list.add("A");

    ArrayList<String> list2 = list;
    list.add("Ooops");
    for(String i:list2) {
        System.out.println("List2 " + i);
    }
}

 

어디에도 list2 객체에 "Ooops"라는 데이터를 저장한 적이 없다. 왜 이런 결과가 나왔을까? 

 

list2 = list;

 

라는 문장은 list2가 list의 값만 사용하겠다는 것이 아니라 list라는 객체가 생성되어 참조되고 있는 주소까지도 사용하겠다는 말이다. 

자바를 사용할 때 JVM이라는 자바 가상 머신이 메모리를 할당하고 해제해주기 때문에 주소에 대한 생각을 사지 않는다. 그러나 자바의 모든 객체가 생성되면 그 객체가 위치하는 주소가 내부적으로 할당된다. 따라서 list2 = list라고 작성하면, 두 객체의 변수는 다르지만 하나의 객체가 변경되면 다른 이름의 변수를 갖는 객체의 내용도 바뀐다.