컬렉션 프레임워크

goose_bumps·2024년 8월 4일

Java

목록 보기
15/21

자바 1회차 공부할 시절에 가볍게 넘어간 파트였는데 코딩테스트나 서버 개발할 때 많이 사용되서 그 중요성을 느끼고 다시 공부하고자 한다.

컬렉션 프레임워크를 왜 사용할까? 데이터 군을 저장하는 클래스들을 표준화한 설계를 의미하는데 쉽게 말해 데이터 처리를 용이하게 하기 위함이다.

JDK 1.2 이전에는 Vector, Hashtable, Properties 같은 컬렉션 클래스들로 데이터를 각기 다른 방식으로 처리했으나 컬렉션 프레임워크 등장 이후 표준화된 방식으로 다룰 수 있게 되었다.

지금은 Vector, Hashtable, Properties 클래스들은 호환성을 위해 남아있으나 효율성이 떨어져 잘 사용되지는 않는다.

1. 컬렉션 프레임워크의 핵심 인터페이스들

컬렉션 프레임워크에는 List, Set, Map 이라는 3가지 타입으로 크게 묶일 수 있다.
List 와 Set 의 공통된 부분을 다시 뽑아서 Collection 이라는 인터페이스가 추가로 정의되었다.

Map은 전혀 다른 형태로 컬렉션을 다루기 때문에 List, Set 처럼 Collection 상속계층도에 포함되지 않는다.

  • List : 데이터의 중복을 허용하면서 저장 순서가 유지됨
  • Set : 데이터의 중복을 허용하지 않고 저장 순서도 유지되지 않음
  • Map : Key와 Value로 이루어진 데이터 집합으로, Key는 중복을 허용하지 않지만 Value는 중복을 허용함

컬렉션 프레임워크에 있는 모든 컬렉션 클래스들은 이 3가지 인터페이스 중 하나를 구현한다.

ArrayList, LinkedList, Stack, Vector 등은 List를, HashSet, TreeSet 등은 Set을 구현한다.
그리고 구현하는 클래스들은 자기가 구현하는 인터페이스의 이름을 따오기 때문에 클래스명만 봐도 어떤 인터페이스를 구현하였는지 알 수 있다.

Vector 같은 기존의 컬렉션 클래스들은 호환을 위해 남겨두었지만 ArrayList 와 같은 새로운 컬렉션 클래스를 사용하는 편이 더 좋다.

1) Collection 인터페이스

List와 Set 인터페이스의 조상으로 CRUD에 기본적인 메서드들을 정의하고 있다.

  • boolean add(Object o) / addAll(Collection c) : 지정된 객체 또는 Collection의 객체들을 Collection에 추가
  • void clear() : Collection의 모든 객체 삭제
  • boolean contains(Object o) / containsAll(Collection c) : 지정된 객체 또는 Collection의 객체들이 Collection에 포함되어 있는지 확인 후 boolean 값 반환
  • boolean equals(Object o) : 동일한 Collection인지 확인
  • int hashCode() : Collection의 hash code 반환
  • boolean isEmpty() : Collection이 비어있는지 확인
  • iterator iterator() : Collection의 iterator를 얻어서 반환
  • boolean remove(Object o) : 지정된 객체를 삭제
  • boolean removeAll(Collection c) : 지정된 Collection에 포함된 객체들 삭제
  • boolean retainAll(Collection c) : 지정된 Collection에 호함된 객체만 남기고 다른 객체들은 모두 삭제 후 Collection에 변화가 있으면 true, 그렇지 않으면 false 반환

//필요할 때 보고 참고

2) 선언 타입에 대하여...

예전부터 궁금했던 점이었는데 왜 굳이 참조변수 선언타입은 List로 하고 ArrayList를 만들까?
ChatGPT 선생한테 물어보고 찾아본 결과는 이거다.

  • 인터페이스를 통한 유연성 제공
  • 구현 세부 사항의 숨김
  • 코드의 유지 보수성 향상

유연성? 유지 보수성? 무슨 말일까?
인터페이스를 공부했을 때 다형성에 있어 이점이 있다고 했었다. List 인터페이스를 구현하는 ArrayList, LinkedList 등의 구현 클래스들은 선언타입을 List로 하는 것이 가능하다는 것이다.
그렇다면 선언타입을 List로 하면 어떤 점이 좋을까? 최소한의 코드 변경으로 구현 클래스를 변경할 수 있다는 점이다.
내가 ArrayList를 사용하다가 LinkedList로 변경하고 싶을 경우 다음과 같이 최소한의 변경으로 해결이 가능하다.

// List<String> myList = new ArrayList<>();
List<String> myList = new LinkedList<>();

구현 세부 사항을 숨길 수 있다는 것은 사용하는 측에서 List의 메서드만 알면 된다는 것이다.
생각을 해보자. 어떤 인터페이스를 구현하는 클래스가 그 인터페이스보다 더 많은 메서드를 가지고 있지 않는가?
하지만, 선언 타입을 인터페이스로 해버리면 사용할 수 있는 메서드는 구현한 메서드뿐이다.

이 특징은 장점이자 단점이다.

단점은 특징 그대로 사용할 수 있는 메서드가 제한적이라는 것이다.
그리고 ArrayList는 인덱스 기반의 빠른 접근 속도를 제공하지만, 참조타입을 List로 선언해버리면 이러한 이점을 활용할 수 없는 것이다.

결론은, 상황에 맞게 적절하게 사용하면 된다.

2. List 인터페이스

중복을 허용하면서 저장순서가 유지되는 컬렉션을 구현하는데 사용되는 인터페이스로, Collection 인터페이스로부터 상속받은 메서드를 제외하고 정의된 메서드는 다음과 같다.

  • void add(int index, Object element) : 지정된 인덱스에 객체를 추가
  • boolean addAll(int index, Collection c) : 지정된 인덱스에 컬렉션에 포함된 객체들 추가
  • Object get(int index) : 지정된 인덱스에 있는 객체 반환
  • int indexOf(Object o) : 지정된 객체가 있는 인덱스를 반환
  • int lastIndexOf(Object o) : 지정된 객체가 있는 인덱스를 역방향을 찾아서 반환
  • Object remove(int index) : 지정된 인덱스에 있는 객체를 삭제하고 삭제된 객체를 반환
  • Object set(int index, Object o) : 지정된 인덱스에 객체를 저장
  • void sort(Comparator c) : 지정된 비교자(Comparator)를 기준으로 List를 정렬
  • List subList(int fromIndex, int toIndex) : 지정된 범위에 있는 객체를 반환

ArrayList, LinkedList, Vector, Stack 클래스 등이 List 인터페이스를 구현한다.

1) ArrayList 클래스

가장 많이 사용되는 컬렉션 클래스로, List 인터페이스를 구현하였기 때문에 중복을 허용하면서 저장순서가 유지되는 특성을 가진다.

Object 배열을 사용하여 데이터를 순차적으로 저장한다. Object 배열의 0번째 인덱스부터 객체를 저장 하며, 계속 추가를 하다가 저장 공간이 부족할 경우 크기가 더 큰 새로운 배열을 생성하여 기존의 내용을 복사한 다음에 저장한다.

ArrayList 내에 선언된 Object 배열을 Object 타입이기 때문에 모든 종류 객체를 저장할 수 있다.

  • ArrayList() : 크기가 10인 ArrayList 생성
  • ArrayList(Collection c) : 주어진 컬렉션이 저장된 ArrayList 생성
  • ArrayList(int initialCapacity) : 지정된 초기용량을 가진 ArrayList 생성
  • boolean add(Object o) : 마지막에 객체를 추가 후 성공하면 true를 반환
  • void add(int index, Object element) : 지정된 인덱스에 객체 저장
  • boolean addAll(Collection c) : 주어진 컬렉션의 모든 객체 저장
  • boolean addAll(int index, Collection c) : 지정된 위치부터 주어진 컬렉션의 모든 객체 저장
  • void clear() : ArrayList에 저장되어 있는 모든 객체를 완전히 지운다
  • Object clone() : ArrayList를 복제한다
  • boolean contains(Object o) : 지정된 객체가 ArrayList에 포함되어 있는지 확인 후 true/false 반환
  • boolean containsAll(Collection c) : 주어진 컬렉션에 저장되어 있는 객체가 ArrayList에 저장되어 있는지 여부를 true/false 반환
  • void ensureCapacity(int minCapacity) : ArrayList의 용량이 최소한 minCapacity가 되도록 한다
  • Object get(int index) : 저장되어 있는 인덱스번째 객체를 반환
  • int indexOf(Object o) : 지정된 객체가 저장되어 있는 인덱스 번호를 반환
  • int lastIndexOf(Object o) : 지정된 객체가 저장되어 있는 인덱스 번호를 역방향으로 검색해서 반환(검색 방향만 다르고 반환값은 indexOf와 같음)
  • boolean isEmpty() : ArrayList가 비어있는지 확인 후 true/false 반환
  • Object remove(int index) : 인덱스번째 객체를 제거
  • boolean remove(Object o) : 지정된 객체를 제거 후 true/false 반환
  • boolean removeAll(Collection c) : 지정한 컬렉션에 저장된 것과 동일한 객체들을 ArrayList에서 제거
  • boolean retainAll(Collection c) : ArrayList에 저장된 객체 중에서 주어진 컬렉션과 공통된 것만 남기고 나머지는 삭제
  • Object set(int index, Object o) : 인덱스 위치에 주어진 객체 저장
  • int size() : 저장된 객체의 개수를 반환
  • void sort(Comparator c) : 지정된 정렬기준으로 ArrayList 정렬
  • List subList(int fromIndex, int lastIndex) : 주어진 범위에 저장된 객체 반환
  • void trimSize() : 용량을 크기에 맞게 줄임(빈 공간을 없앰)

용량과 크기에 차이점은 용량은 ArrayList의 저장 가능 공간을 말하는 것이고, 크기는 저장되어 있는 객체의 수를 말하는 것이다.

자, 그렇다면 ArrayList는 왜 사용할까?
가장 큰 이점은 바로 데이터에 대한 접근과 삽입/삭제가 용이하다는 것이다.
특정 위치에 데이터를 접근할 때 get(int i), 삽입 시 set(int index, Object o), 삭제 시 remove(int index) 등을 사용하는 것이 취급도 편하다.

하지만, Vector와 마찬가지로 지정한 용량이 다 찰 경우(또는 용량을 변경해야 할 경우) 데이터를 추가하면 배열을 새로 만들고 기존에 저장된 객체들은 복사하는 과정이 필요하기 때문에 이 과정에서 시간이 오래 걸린다.
이런 측면에서는 공간의 제약을 받지 않는 LinkedList가 더 유리하다.

import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;

public class Review {
	public static void main(String[] args) {
		ArrayList list1 = new ArrayList(); //기본 생성자로 용량이 10인 ArrayList 생성
		
		list1.add(new Integer(9));
		list1.add(new Integer(1));
		list1.add(new Integer(4));
		list1.add(new Integer(5));
		list1.add(new Integer(3));
		list1.add(new Integer(7));
		
		ArrayList list2 = new ArrayList(list1.subList(0, 4)); //주어진 컬렉션이 저장된 ArrayList 생성
		
		Collections.sort(list1);
		print(list1,list2);
		
		System.out.println(list1.containsAll(list2)); //주어진 컬렉션에 저장되어 있는 객체들이 ArrayList에 저장 여부를 boolean으로 반환
		System.out.println();
		
		list2.add("A");
		list2.add("C");
		list2.add("D");
		
		list1.retainAll(list2); //주어진 컬렉션의 객체들과 일치하지 않는 객체는 ArrayList에서 삭제하고 나머지만 유지
		
		print(list1,list2);
		
		for(int i = list2.size() - 1; i >= 0; i--) {
			if(list1.contains(list2.get(i))) {
				list2.remove(i);
			}
		} //list2와 공통된 부분을 list1에서 삭제
		
		print(list1,list2);
	}
	static void print(ArrayList list1, ArrayList list2) {
		System.out.println("list1 : " + list1);
		System.out.println("list2 : " + list2);
		System.out.println();
	}
}

여기서 마지막에 index를 마지막 부분부터 시작한 이유는 0번째부터 시작해서 삭제할 경우 ArrayList의 특성상 연속적으로 데이터가 존재하기 때문에 만약, 3번째 인덱스가 삭제되면 4번째 인덱스부터 전부 앞으로 당겨져온다.

이러한 특성은 데이터 삽입/삭제에서 처리 시간을 많이 잡아먹게 만드는 데, 인덱스의 첫과 끝이 아닌 중간 정도에 삽입될 경우 그 뒤에 있는 모든 데이터를 뒤로 미루기 위해 새로운 배열을 만들고 데이터를 복사하는 과정을 거치기 때문이다.

지금처럼 간단한 예제는 데이터의 개수가 많지 않아 잘 느껴지지 않아도 데이터 크기가 클 경우 이러한 삽입/삭제의 처리 시간에서 유의미한 차이가 나타난다.


2) LinkedList 클래스

기본적으로 배열을 사용하는 ArrayList는 위에서도 언급했지만 배열의 단점을 가지고 있다.

  • 크기 변경이 불가능
  • 비순차적인 데이터 추가/삭제에 시간이 오래 걸림

이 단점들이 모두 적용되지 않는 List 인터페이스 구현 클래스가 바로 LinkedList 클래스이다.
불연속인 데이터들을 서로 연결한 형태로 각 노드들은 다음 요소에 대한 참조(주소값)와 데이터를 가지고 있다.
예를 들어,
LinkedList(0x200) -> node1(0x250) -> node2(0x300) -> node3(0x350) ....

이런 형태를 가지는데 괄호 안에 값은 다음 노드의 주소값이다. 예시에 있는 node2의 경우 주소값이 0x250이고, 그 주소값을 바로 전 노드인 node1이 가지고 있는 것이다.

그렇다면 의문이 생긴다. 왜 굳이 다음 노드에 대한 주소값을 가질까?

이유는 바로 중간에 데이터를 추가/삭제 시 작업 소요를 줄이기 위해서이다.

- 비순차적인 데이터 추가/삭제에 있어 이점

앞에 예시에서 node2와 node3 사이에 node4를 추가한다고 가정해보자.
이때 LinkedList에서는 node2가 기존에 가지고 있던 0x300이라는 node3의 주소값을 node4의 주소값으로 변경한다.

즉, LinkedList(0x200) -> node1(0x250) -> node2(0x270) -> node4(0x300) -> node3(0x350) ....

이런식으로 전 노드가 가지고 있는 주소값을 바꾸고 다음 노드의 주소값을 추가할 데이터가 가지는 것이다.

삭제도 비슷한 방식으로 처리된다. 삭제할 데이터의 이전 노드가 삭제할 데이터의 이후 노드의 주소값을 참조하면 되는 것이다.

이렇게 참조 하나만 바꾸어서 데이터를 추가/삭제할 수 있으니 처리 속도가 당연히 빠르다.

- 순차적 접근으로 인한 불리함

하지만, 특정 데이터를 찾을 때마다 노드의 참조를 따라가야하기 때문에 데이터 접근에서는 ArrayList보다 느릴 수 밖에 없다.

접근성의 불리함을 극복하기 위해 나온 것이 Double LinkedList인데, 원리는 똑같고 마지막 노드에서 다시 역순으로 이전 노드 값의 주소를 참조하는 원리이다.

LinkedList(0x200) -> node1(0x250) -> node2(0x300) -> node3(0x250) -> node2(0x200) -> node1(0x250)....

즉, 다음 노드뿐만 아니라 이전 노드의 주소값도 참조변수로 추가된 것이다.

현재 new LinkedList()로 생성되는 LinkedList가 바로 Double LinkedList를 구현한 것이다.

- LinkedList의 메서드

List 인터페이스를 구현하였기 때문에 기본적인 메서드의 기능은 ArrayList와 거의 동일하다.
몇 가지 추가적인 메서드들은 알아보자.

  • Object element() : LinkedList의 첫 번째 요소를 반환
  • boolean offer(Object o) : 지정된 객체 o를 LinkedList의 마지막에 추가 후 true/false 반환
  • boolean offerFirst(Object o) : 지정된 객체 o를 LinkedList의 맨 앞에 추가 후 true/false 반환
  • boolean offerLast(Object o) : 지정된 객체 o를 LinkedList의 맨 끝에 추가 후 true/false 반환
  • void addFirst(Object o) : LinkedList 맨 앞에 객체 o를 추가(=void push(Object o))
  • void addLast(Object o) : LinkedList 맨 끝에 객체 o를 추가
  • Object peak() : LinkedList의 첫 번째 요소 반환
  • Object peakFirst() : LinkedList의 첫 번째 요소 반환
  • Object peakLast() : LinkedList의 마지막 요소 반환
  • Object poll() : LinkedList의 첫 번째 요소 반환 후 제거
  • Object pollFist() : LinkedList의 첫 번째 요소 반환 후 제거
  • Object pollLast() : LinkedList의 마지막 요소 반환 후 제거
  • Object getFirst() : LinkedList의 첫 번째 요소 반환
  • Object getLast() : LinkedList의 마지막 요소 반환
  • Object remove() : LinkedList의 첫 번째 요소 제거
  • Object removeFirst() : LinkedList의 첫 번째 요소 제거(=Object pop())
  • Object removeLast() : LinkedList의 마지막 요소 제거
  • Object removeFirstOccurrence(Object o) : LinkedList에서 첫번째로 일치하는 객체를 제거
  • Object removeLastOccurrence(Object o) : LinkedList에서 마지막로 일치하는 객체를 제거
import java.util.ArrayList;
import java.util.LinkedList;
import java.util.List;

public class Review {
	public static void main(String[] args) {
		ArrayList list1 = new ArrayList();
		LinkedList list2 = new LinkedList();
		System.out.println("순차적 추가 비교");
		System.out.println("ArrayList : " + add1(list1));
		System.out.println("LinkedList : " + add1(list2));
		
		System.out.println("중간에 추가 비교");
		System.out.println("ArrayList : " + add2(list1));
		System.out.println("LinkedList : " + add2(list2));
		
		System.out.println("중간에 삭제 비교");
		System.out.println("ArrayList : " + remove2(list1));
		System.out.println("LinkedList : " + remove2(list2));
		
		System.out.println("순차적 삭제 비교");
		System.out.println("ArrayList : " + remove1(list1));
		System.out.println("LinkedList : " + remove1(list2));
		
	}
	
	public static long add1(List list) {
		long start = System.currentTimeMillis();
		for(int i = 0; i < 1000000; i++) list.add(i);
		long end = System.currentTimeMillis();
		return end - start;
	} //순차적 추가
	
	public static long add2(List list) {
		long start = System.currentTimeMillis();
		for(int i = 0; i < 10000; i++) list.add(500, "X");
		long end = System.currentTimeMillis();
		return end - start;
	} //중간에 추가
	
	public static long remove1(List list) {
		long start = System.currentTimeMillis();
		for(int i = list.size() - 1; i >= 0; i--) list.remove(i);
		long end = System.currentTimeMillis();
		return end - start;
	} //순차적 삭제
	
	public static long remove2(List list) {
		long start = System.currentTimeMillis();
		for(int i = 0; i < 10000; i++) list.remove(i);
		long end = System.currentTimeMillis();
		return end - start;
	} //중간에 삭제(첫 번째 인덱스부터 삭제이므로 ArrayList는 배열의 재배치가 이루어짐)
}

이 예시는 ArrayList와 LinkedList의 성능 차이를 비교하기 위한 예이다.
add1는 0번째 인덱스부터 순서대로 데이터를 추가하는 것이고, add2는 500번째 인덱스부터 10000개의 "X"를 중간에 추가하는 것이다.

remove1은 마지막 인덱스부터 순차적으로 삭제, remove2는 첫 번째 인덱스부터 순차적으로 삭제하는 메서드이다.

remove2를 중간에서부터 삭제라고 한 이유는 어차피 0번째 인덱스부터 삭제하면 ArrayList의 경우 배열의 재배치 과정이 발생하기 때문이다.

순차적 추가 비교
ArrayList : 42
LinkedList : 165
중간에 추가 비교
ArrayList : 11715
LinkedList : 11
중간에 삭제 비교
ArrayList : 10413
LinkedList : 110
순차적 삭제 비교
ArrayList : 9
LinkedList : 28

출력값을 통해 알 수 있는 점은 다음과 같다.

  • ArrayList가 접근이 더 빨라 데이터를 앞에서부터 추가할 경우 더 빠르다
    -> 단, 배열은 비어있고 저장공간도 데이터 크기보다 크다고 가정했을 시
  • 중간에 데이터를 삽입하는 경우 ArrayList는 배열을 새로 만들고 복사하는 과정을 거치기 때문에 더 느리다
  • 맨 앞에서 또는 중간에서 데이터를 삭제하는 경우에도 마찬가지로 ArrayList는 배열의 재배치로 더 느리다
  • 끝에서부터 순차적으로 삭제하는 경우에는 접근이 빠른 ArrayList가 더 빠르다

- 데이터 접근은 어떤 원리로 이루어질까?

배열의 경우 특정 인덱스의 요소에 접근할 경우 주소값을 통해 찾아간다.

n번째 인덱스 데이터의 주소 = 배열의 주소 + (n * 데이터 타입의 크기)

배열의 주소는 0X100 이므로 3번째 인덱스의 데이터는 0X100 + (3 * 4Byte) = 0X112
ArrayList는 3번째 인덱스 데이터에 접근 시 0X112 주소값으로 바로 찾아가게 되어 속도가 빠를 수 밖에 없다.

LinkedList의 경우 3번째 인덱스 데이터에 접근하려면 1번째 데이터 주소부터 찾아가 2번째, 그 다음 3번째 이런 방식으로 찾아간다.

만약, 1000번째 데이터를 찾을 경우에는 이 차이가 더 커질 것이다.

따라서, 데이터 개수의 변경이 거의 없을 경우 ArrayList를 사용하는 것이 유리하겠지만, 데이터 개수 변경이 잦을 경우 LinkedList를 사용하는 것이 더 좋다.

아니면, 초기 데이터 저장 시 ArrayList 사용 후, 이후에 수정시에는 LinkedList로 데이터를 옮겨서 관리하는 방법도 효율적이다.

중요한 것은 개발자는 데이터의 특성에 따라 어떤 구현 클래스를 사용해야 더 효율적일지 알고 있어야 한다.

3. Stack / Queue

후입선출, 선입선출로 잘 알려진 Stack과 Queue는 데이터가 꺼내지는 순서에서 차이가 있다.
우선, Stack은 List 인터페이스를 구현하는 구현 클래스이고, Queue는 Collection 인터페이스를 상속받는 인터페이스이다.

Stack은 가장 마지막에 들어온 데이터가 가장 먼저 꺼내지는 LIFO(Last In First Out), Queue는 가장 먼저 들어온 데이터가 가장 먼저 꺼내지는 FIFO(First In First Out)의 구조를 가진다.

쉽게 비유하자면, Stack은 한 방향으로만 뺄 수 있는 저금통, Queue는 양 쪽에 통로가 있는 터널로 생각하면 된다.

Stack은 구현 클래스이므로 new Stack()으로 구현해도 되고, 마지막에 들어온 데이터 먼저 꺼내는 특성 상 ArrayList로 구현하는 것이 더 효율적일 것이다.

Queue는 반대로 인터페이스이기도 하고 먼저 들어온 데이터 먼저 꺼내기 때문에 ArrayList로 구현하는 것은 적합하지 않다. 배열의 재배치가 필요없는 LinkedList가 데이터 추가/삭제에 유리하기 때문에 LinkedList로 구현하는 편이 좋다.(실제로 LinkedList는 Queue의 구현 클래스이다)

그 외에 구현 클래스를 찾고 싶을 경우 자바 API 문서로 들어가 "All Known Implementing Classes 중 적합한 것을 사용하면 된다.

- Stack의 메서드

  • boolean empty() : Stack이 비어있는지 true/false 반환
  • Object peek() : Stack의 맨 위에 저장된 객체(=마지막에 저장된 객체)를 반환. 반환만 하고 실제로 꺼내지는 않음 (비어있을 경우 EmptyStackException 발생)
  • Object pop() : Stack의 맨 위에 저장된 객체를 꺼냄 (비어있을 경우 EmptyStackException 발생)
  • Object push(Object o) : Stack에 객체 o를 저장한다
  • int search(Object o) : Stack에서 주어진 객체 o를 찾아서 그 위치를 반환 -> 못찾을 경우 -1을 반환. 배열과 달리 위치는 0이 아닌 1부터 시작

//EmptyStackException은 RuntimeException이므로 예외처리를 해주지 않아도 됨

- Queue의 메서드

  • boolean add(Object O) : 지정된 객체 o를 Queue에 저장. 저장공간 부족 시 IllegalStateException 발생
  • Object remove() : Queue에서 객체를 꺼내서 반환. 비어있으면 NoSuchElementException 발생
  • Object poll() : Queue에서 객체를 꺼내서 반환. 비어있으면 null을 반환
  • Object element() : 삭제없이 요소를 읽어온다. Queue가 비어있을 경우 NoSuchElementException 발생
  • Object peek() : 삭제없이 요소를 읽어온다. 비어 있을 경우 null을 반환
  • boolean offer(Object o) : Queue에 객체 o를 저장 후 true/false 반환
public class Review {
	public static Stack back = new Stack();
	public static Stack forward = new Stack();
	
	public static void main(String[] args) {
		goURL("네이버");
		goURL("다음");
		goURL("카카오");
		goURL("신한은행");
		
		printStatus();
		
		goBack();
		goBack();
		goBack();
		goBack();
		
		goForward();
		
	}
	
	public static void printStatus() {
		System.out.println("back : " + back);
		System.out.println("forward : " + forward);
		System.out.println("현재 화면은 '" + back.peek() +"' 입니다.");
		System.out.println();
	}
	
	public static void goURL(String url) {
		back.push(url);
		if(forward.empty() == false) {
			forward.clear();
		}
	}
	
	public static void goForward() {
		if(forward.empty() == false) {
			try {
				back.push(forward.pop());
				printStatus();
			}
			catch(EmptyStackException e) {
				System.out.println("다음 페이지가 없습니다");
				System.out.println();
			}
		}
	}

	public static void goBack() {
		if(back.empty() == false) {
			try{
				forward.push(back.pop());
				printStatus();
			}
			catch(EmptyStackException e){
				System.out.println("이전 페이지가 없습니다");
				System.out.println();
			}
		}
	}
}

back : [네이버, 다음, 카카오, 신한은행]
forward : []
현재 화면은 '신한은행' 입니다.
back : [네이버, 다음, 카카오]
forward : [신한은행]
현재 화면은 '카카오' 입니다.
back : [네이버, 다음]
forward : [신한은행, 카카오]
현재 화면은 '다음' 입니다.
back : [네이버]
forward : [신한은행, 카카오, 다음]
현재 화면은 '네이버' 입니다.
back : []
forward : [신한은행, 카카오, 다음, 네이버]
이전 페이지가 없습니다
back : [네이버]
forward : [신한은행, 카카오, 다음]
현재 화면은 '네이버' 입니다.

이 예시는 Stack의 LIFO 특성을 이용한 웹 브라우저 뒤로 가기/앞으로 가기 기능을 구현한 것이다.
뒤로 가기를 할 경우, 앞으로 가기 할 페이지가 있는지 확인 후(forward.empty()) 있으면(false일 경우) pop으로 객체를 꺼내서 back에 push하여 저장한다.
forward.empty가 true, 즉 앞으로 가기 할 페이지가 없으면(객체가 비어있으면) EmptyStackException이 발생하여 예외처리 시켰다.

앞으로 가기도 뒤로 가기와 같은 원리도 만들면 된다.

4. Iterator

Collection에 저장된 요소들을 읽어오는 방법을 표준화한 인터페이스이다.
ArrayList, LinkedList의 메서드 중 Iterator를 반환하는 iterator()가 생각날 것이다.
원래 iterator()는 Collection 인터페이스의 메서드로 Collection을 구현하는 클래스들은 모드 가지고 있는 메서드이다.

iterator()를 사용하면 Iterator 인터페이스를 구현하는 클래스의 인스턴스인 Iterator를 반환하게 되는데 주로 while문을 사용해서 요소를 읽어온다.

예시를 통해 간단히 설명하겠다.

public class Example {
	public static void main(String[] args) {
		Collection c = new ArrayList();
		for(int i = 0; i < 10; i++) {
			c.add(i);
		}
		
		Iterator it = c.iterator();
		
		while(it.hasNext()) {
			System.out.println(it.next());
		}
	}
}

0
1
2
3
4
5
6
7
8
9

iterator()로 반환한 인스턴스는 Iterator 타입의 참조변수에 저장되어 hasNext(), next() 같은 메서드를 호출할 수 있다.
여기서 Collection을 선언 타입으로 한 이유는 코드 변경 소요를 최소하하기 위해서이다.
만약, 생성자를 LinkedList로 변경할 경우 뒤에 있는 코드들은 어차피 Collection 타입의 필드일 것이기 때문에 따로 변경할 필요가 없다.
하지만, 선언 타입을 ArrayList로 했을 경우 뒤에 있는 모든 코드를 확인해서 ArrayList에서만 사용할 수 있는 필드가 혹시라도 있지 않나 확인해야 한다.

이 점이 바로 Iterator의 장점이다. Collection을 구현하는 모든 클래스들은 iterator()를 가지기 때문에 선언 타입을 Collection으로 해두고 요소 비교를 Iterator를 사용하면 어떤 구현 클래스를 사용하더라도 선언 부분만 코드를 변경해주면 된다.

이런 의문이 들 수 있다. List 같은 경우에는 get(int index) 사용하면 되지 않나?
하지만, Collection을 구현하는 인터페이스 중 Set 인터페이스를 생각해보자. 저장 순서를 유지하지 않아 for문을 사용한 요소 접근이 불가하다. 이럴 때 iterator()를 사용하면 되는 것이다.

		List list = new ArrayList();
		Set st = new HashSet();
		
		list.add("A");list.add("B");list.add("C");list.add("D");list.add("E");
		st.add("A");st.add("B");st.add("C");st.add("D");st.add("E");
		
		//List의 요소 확인
		for(int i = 0; i < list.size(); i++) {
			System.out.println(list.get(i));
		}	
		//Set의 요소 확인
		Iterator it = st.iterator();
		while(it.hasNext()) {
			System.out.println(it.next());
		}
        //Set은 순서가 유지되지 않기 때문에 Iterator를 이용하여 요소를 읽어 와도 처음에 저장된 순서와 같지 않다
  • boolean hasNext() : 읽어 올 요소가 남아있는지 확인하고 boolean값을 반환
  • Object next() : 다음 요소를 읽어옴
  • void remove() : next()로 읽어온 요소를 삭제. 사용 시 next()로 읽어온 다음에 호출해야 함

여기서 next()는 hasNext()로 다음에 올 요소가 있는지 확인하고 사용해주는 것이 안전하다.


Iterator 외에도 Enumeration, ListIterator가 있는데, Enumeration은 Iterator의 구버전으로 메서드 이름만 다르고 기능은 동일하다.

ListIterator는 Iterator에 양방향으로의 접근 기능을 추가한 인터페이스이다.

Iterator는 요소를 읽을 때 next()를 사용하여 단방향으로만 요소를 읽을 수 있지만 ListIterator는 양방향으로 접근이 가능하다.

다음은 ListIterator의 메서드들이다.

  • void add(Object o) : 컬렉션에 새로운 객체 o 추가
  • boolean hasNext() : 읽어 올 다음 요소가 남아있는지 확인하고 boolean값을 반환
  • boolean hasPrevious() : 읽어 올 이전 요소가 남아있는지 확인하고 boolean값을 반환
  • Object next() : 다음 요소를 읽어옴
  • Object previous() : 이전 요소를 읽어옴
  • int nextIndex : 다음 요소의 index 값 반환
  • int previousIndex : 이전 요소의 index 값 반환
  • void remove() : next()나 previous()로 읽어 온 요소 삭제
  • void set(Object o) : next()나 previous()로 읽어 온 요소를 지정된 객체 o로 변경

주의할 점은 ListIterator는 List 인터페이스를 구현한 컬렉션에서만 사용 가능하다.

5. Arrays

배열을 다룰 때 유용한 클래스이다.
대표적으로 Arrays.sort(Object[] o) 라는 메서드가 있다. Arrays 클래스에 대해서는 자주 사용되는 메서드 위주로 정리해보겠다.

1) 배열 복사

배열의 전체 복사 시 copyOf()를, 특정 범위를 복사 시 copyOfRange()를 사용한다.

copyOf()의 경우 JAVA API 문서를 보면 다음과 같이 정의되어 있다.

  • static boolean[] copyOf​(boolean[] original, int newLength)
  • static byte[] copyOf​(byte[] original, int newLength)
  • static char[] copyOf​(char[] original, int newLength)
  • static double[] copyOf​(double[] original, int newLength)
  • static float[] copyOf​(float[] original, int newLength)
  • static int[] copyOf​(int[] original, int newLength)
  • static long[] copyOf​(long[] original, int newLength)
  • static short[] copyOf​(short[] original, int newLength)

즉, 매개변수로 입력되는 배열 타입에 맞게 리턴 타입도 정해지는 것이다. 뒤에 있는 newLength는 새로 정할 길이인데 이것은 예제를 통해 설명하겠다.

        int[] Arr1 = {1,2,3,4,5,6};
        int[] Arr2 = Arrays.copyOf(Arr1, 7);
        int[] Arr3 = Arrays.copyOf(Arr1, 3);
        System.out.println(Arrays.toString(Arr2)); //[1, 2, 3, 4, 5, 6, 0]
        System.out.println(Arrays.toString(Arr3)); //[1, 2, 3]

Arr1은 길이가 6인 정수 배열인데 Arr2는 전체를 복사하고 새로운 길이를 7로 설정하였다. 그렇다면 Arr2의 마지막 요소는 0으로 채워진다. 즉, 원본보다 길이가 길면 나머지는 0으로, 짧으면 원본의 해당 길이만큼 복사되는 것이다.

copyOfRange() 는 copyOf와 리턴타입, 파라미터가 유사하고 다른 점은 복사할 범위 지정이 가능하다.
예를 들어, 원본의 2번째 인덱스부터 3번째 인덱스까지 복사하려면
Arrays.toString(Original, 2, 4)로 입력하면 된다.
마지막 인덱스는 포함되지 않는 것이다.

2) 배열 채우기

배열의 모든 요소를 지정된 값으로 채우려면 fill(Object[] Array, Object o)를 사용하면 된다.
배열 전체를 지정된 값으로 전부 채우는 것이다.

        String[] Arr2 = {"Java","Python","C","JavaScript","Kotlin"};
        Arrays.fill(Arr2,"Java");
        System.out.println(Arrays.toString(Arr2)); //[Java, Java, Java, Java, Java]

3) 배열 정렬, 검색

배열을 정렬 시 sort() 를 사용하는데 기본적인 정렬 즉, 오름차순 정렬일 경우에는 그냥 사용하면 되고 만약, 내림차순이나 다른 기준에 따른 정렬을 사용하고 싶을 경우 Comparator를 지정해주어야 한다.
Comparator에 대한 것은 뒤에서 다루겠다.

        String[] Arr2 = {"Java","Python","C","JavaScript","Kotlin"};
        Arrays.sort(Arr2);
        System.out.println(Arrays.toString(Arr2)); //[C, Java, JavaScript, Kotlin, Python]

검색 시 사용하는 binarySearch()는 사용 전 반드시 배열이 정렬되어 있는 상태여야 한다. 그래서 sort()로 먼저 정렬 후에 사용한다.
정렬하지 않고 사용할 경우 잘못된 결과가 나올 수 있으므로 항상 정렬이 되어 있는지 확인을 해야 한다.

        String[] Arr2 = {"Java","Python","C","JavaScript","Kotlin"};
        Arrays.sort(Arr2);
        System.out.println(Arrays.toString(Arr2)); //[C, Java, JavaScript, Kotlin, Python]
        System.out.println(Arrays.binarySearch(Arr2, "C")); //0

찾으려는 요소를 입력하면 몇 번째 인덱스에 있는지 정수값을 반환한다.

정렬이 되어 있어야 사용가능하다는 단점이 있지만 검색 범위를 반복적으로 절반씩 줄여가며 검색하기 때문에 큰 배열 검색 시 굉장히 빠르다는 것을 알 수 있다.

4) 배열 비교, 출력

가장 많이 사용하는 출력 메서드인 toString()이다.
하지만, toString()은 1차원 배열만 출력이 가능하고 2차원 이상의 배열에서는 deepToString()을 사용해야 한다.

        int[] intArr = {1,2,3};
        int[][] intArr2 = {{1,2,3},{4,5,6}};
        System.out.println(Arrays.toString(intArr)); //[1, 2, 3]
        System.out.println(Arrays.deepToString(intArr2)); //[[1, 2, 3], [4, 5, 6]]

equals()도 마찬가지로 두 배열의 요소를 비교하여 boolean 값을 리턴하지만, 2차원 이상의 배열의 비교에서는 deepEquals()를 사용해야 한다.
만약, 2차원 배열에 대해서 equals()를 사용하면 저장된 내용이 아닌 배열의 주소값으로 비교하기 때문에 항상 false가 출력된다.

5) List로 변환

asList()를 사용하면 배열을 List로 변환이 가능하다.
하지만, 변환된 List는 크기 변경이 불가능하므로 추가/삭제가 불가능하다.

		int[] intArr = {1,2,3,4,5};
        List list = Arrays.asList(intArr);
        // list.add(8); //UnsupportedOperationException 발생

그렇다면 크기를 변경할 수 있는 방법이 정말 없는 것일까?
ArrayList 생성자 중 ArrayList(Collection c)가 있었다. 생성자 파라미터로 컬렉션을 입력하는 것인데 이 파라미터에 asList로 변환된 List를 넣으면 된다.

        List list = new ArrayList(Arrays.asList(intArr));
        List list = new ArrayList(Arrays.asList(1,2,3,4,5));
        //asList 메서드의 파라미터가 가변인수이기 때문에 저장할 요소들만 나열하는 것도 가능

6. Comparable, Comparator

Arrays.sort()로 배열을 정렬할 때 기본적으로는 오름차순으로 정렬이 된다고 위에서 설명했었다.
이게 바로 Comparable 인터페이스를 구현하고 있기 때문에 가능했던 것인데, Wrapper, String, Date, File 등의 같은 타입의 인스턴스를 비교할 수 있는 클래스들은 Comparable 인터페이스를 구현하고 있어 정렬이 가능하다.

Comparator는 기본적인 정렬 외에 다른 기준으로 정렬하고자 할 때 구현하는 인터페이스이며 compare() 메서드를 구현해야 한다.

  • Comparable : 기본적인 정렬기준을 구현하는데 사용
  • Comparator : 기본 정렬기준 외에 다른 기준으로 정렬할 때 사용
public interface Comparator{
	int compare(Object o1, Object o2); //o1, o2 두 객체를 비교
    boolean equals(Object obj); //equals를 오버라이딩 하라는 뜻
}

public interface Comparable{
	int compareTo(Object o); // 주어진 객체 o를 자신과 비교
}

Comparable에는 compareTo() 메서드가 정의되어 있는데 Comparable을 구현하는 모든 클래스는 compareTo()를 구현하고 있으며 비교대상보다 작은지, 같은지, 큰지에 따라 각각 -1,0,1을 반환한다.

        Comparable c1 = new Integer(1);
        Comparable c2 = new Integer(2);
        Comparable c3 = new Integer(1);
        Comparable c4 = new Integer(0);

        System.out.println(c1.compareTo(c2)); //-1
        System.out.println(c1.compareTo(c3)); //0
        System.out.println(c1.compareTo(c4)); //1

Comparator에 정의되어 있는 compare()도 이름만 다를 뿐 비교대상에 따라 출력되는 값은 동일하다.

이제 Arrays.sort()를 사용하여 기본 순서로 정렬하는 경우와, Comparator를 사용하여 다른 정렬 기준을 적용하는 예시를 보자.

public class Main {
    public static void main(String[] args) {
        String[] Arr = {"cat","Dog","Lion","tiger"};

        //Comparable에 의한 정렬
        Arrays.sort(Arr);
        System.out.println("Arr = " + Arrays.toString(Arr));

        //Comparator에 의한 정렬
        Arrays.sort(Arr, new Descending());
        System.out.println("Arr = " + Arrays.toString(Arr));

        //Comparator에 의한 정렬2
        Arrays.sort(Arr, String.CASE_INSENSITIVE_ORDER); //대소문자 구분없이 오름차순
        System.out.println("Arr = " + Arrays.toString(Arr));
    }
}

class Descending implements Comparator{
    //compare 메서드 구현(내림차순 방식으로)
    public int compare(Object o1, Object o2) {
        if(o1 instanceof Comparable && o2 instanceof Comparable){
            Comparable c1 = (Comparable) o1;
            Comparable c2 = (Comparable) o2;
            // -1을 곱해주어 기존의 오름차순 방식을 내림차순으로 변경
            return c1.compareTo(c2) * -1;
            // c2.compareTo(c1) 으로 해주어도 동일하다
        }
        return 0;
    }
}

내림차순에 따른 정렬을 위해 Descending이라는 클래스를 만들어 Comparator를 구현하고, compare()을 구현하여 기존의 return 값에 -1을 곱해 역순으로 만들어주었다.

3번째 출력은 상수 형태로 Comparator를 제공한 것이고 대소문자 구분을 없애는 기능을 한다.

String의 경우 오름차순, 내림차순이 유니코드 순서에 따라 정렬되며 오름차순의 경우 대문자가 먼저 나오고 그 이후에 소문자가 나온다. String.CASE_INSENSITIVE_ORDER를 사용하면 대소문자 구문이 없어져 유니코드 순서에 따라 정렬된다.

추가적으로 Comparable 인터페이스는 java.lang 패키지에, Comparator 인터페이스는 java.util 패키지에 속한다.

0개의 댓글