for each, iterable, iterator, ListIterator에 대하여

 Iterator는 콜렉션 프레임워크에 저장된 요소들을 읽어오는 표준화된 방법으로 자바 1.2에서 추가되었다. 거두절미하고 Iterator는 어떻게 사용되는지 코드를 보자.

 

iterator()

List<String> list = new ArrayList<>();
list.add("A");
list.add("B");
Iterator<String> itr = list.iterator();

while(itr.hasNext()) {
	String item = itr.next();
	System.out.println(item);
}

 iterator는 콜렉션 혹은 배열의 데이터를 순차적으로 읽어올 때 구현하는 반복문을 더 쉽게 만들어준다. 콜렉션 객체에서 iterator()를 호출해 반복문에 적용해 사용할 수 있다. hasNext를 호출하여 다음 데이터가 있는지 확인하고 있다면 next()로 다음 위치에 있는 데이터를 읽어오는 것이다.

 

 하지만 위와 같이 사용하는 사람들은 잘 없을 것이다. 다음 코드를 보자.

 

 

for each

List<String> list = new ArrayList<>();
list.add("A");
list.add("B");

for(String str : list)
	System.out.println(str);

 for each로 콜렉션의 데이터를 읽어 오는 방법이다. 아마 자바 콜렉션에서 제공하는 자료구조를 자주 사용해봤다면 이러한 형태의 반복문을 사용해 본 적이 있을 것이다. iterator의 예제를 보여주고 바로 for each를 보여주는 이유for each를 가능하게 해주는 것이 iterator, iterable이기 때문이다. 이렇듯 iterator는 두 가지 방식으로 사용할 수 있는데 for each를 사용하는 편이 더 쉽고 직관적이기 때문에 for each가 일반적으로 사용된다.

 

 일반적으로 for each가 더 많이 쓰이지만 그럼에도 불구하고 iterator를 호출하여 사용하는 경우가 있다. iterator는 기본적으로 데이터를 읽어오는 도중 자료구조에 영향을 끼치는 것을 코드적으로 허용하지 않고 있기 때문에 컬렉션에서 제공하는 add(..), remove(..) 등의 메서드를 호출할 수 없다. 그럼에도 불구하고 반복자로 데이터를 읽는 중, 특정 데이터에 대한 삭제, 수정을 해야할 경우가 있기 때문에 iterator는 데이터 삭제를 위해 파라미터가 없는 remove() 메서드를 제공한다. 

 

 for each와 iterator의 차이는 iterator를 명시적으로 사용하는가, 아닌가에 대한 차이라고 볼 수 있다. for each로 데이터를 읽어올 때에는 개발자가 직접 iterator를 호출하지 않고 컴파일러가 적절하게 hasNext, next를 호출하며 데이터를 읽어오기 때문에 코드가 훨씬 간단해진다는 장점이 있지만 remove 메서드는 컴파일러가 알아서 호출해줄 수 없기에 사용할 수 없다. 반대로 명시적으로 iterator를 읽어올 경우 hasNext와 next 같은 메서드들을 직접 구현해야 한다는 단점이 있지만 remove 같은 메서드를 개발자가 원하는 타이밍에 적절하게 사용할 수 있다는 장점이 있다.

 

 데이터를 오로지 읽기만 한다면 for each가, 특정 데이터를 찾아 삭제까지 필요하다면 iterator 객체를 받아 쓰면 될 것이다.

 

 

https://docs.oracle.com/javase/8/docs/api/java/util/ArrayList.html

 

 컬렉션에서 자주 사용되는 자료구조 중 하나인 ArrayList를 보자. 위 캡쳐는 오라클에서 제공하는 ArrayList에 대한 API 문서 중 일부를 발췌한 것이다. 위 캡쳐본에서 ArrayList가 구현(implements)하고 있는 인터페이스 중 Iterable이 포함되어 있는 것을 볼 수 있다. 컬렉션에서 iterator를 사용하기 위해서는 iterator() 메서드를 호출해 객체를 넘겨받아야 하는데, 이 메서드를 가지고 있는 것이 Iterable interface이며 이 인터페이스가 없다면 for each도 수행할 수 없기 때문에 iterator에 앞서 이 인터페이스를 먼저 구현해야 한다. 

 

 iterator와 iterable은 컬렉션의 데이터를 읽어오는 표준이지만 반드시 컬렉션에서만 사용가능한 것이 아니며 직접 구현하여 사용할 수 있다. 지금부터 iterable과 iterator를 구현하는 것에 대해 알아보자.

 

 


 

 

 Iterator 구현하기

 for each를 적용시킬 클래스에 iterable interface를 구현할 것이다. 실제로 for each를 사용할 때 반환하는 것은 iterator를 구현한 클래스를 객체화하여 넘겨준다. 일반적으로 데이터를 저장, 관리할 메인이 되는 자료구조 클래스와 이 클래스에 적용될 iterator를 구현한 내부 클래스로 작성한다.

 

 implements Iterable

 for each를 구현하기 전에 간단한 테스트를 해볼 것이다. 먼저 아래와 같이 제네릭 타입의 파라미터 1개를 가진 클래스를 생성한 뒤 메인 메서드에서 for each 형태로 호출해 결과를 보자.

public class MyList<E> { }

 

 

error : Can only iterate over an array or an instance of java.lang.Iterable

 

 일단 클래스 내부에 아무 코드도 없기 때문에 데이터를 읽어올 수 없는 것은 당연하다. 에러 메시지를 보면 for each를 사용할 수 있는 것은 배열 혹은 java.lang.iterable을 상속한 클래스만 가능하다는 것을 확인할 수 있다. 그럼 이제 클래스에 Iterable interface를 구현해보자.

 

 

 

import java.util.Iterator;

public class MyList<E> implements Iterable<E>{
	@Override
	public Iterator<E> iterator() {
		// TODO Auto-generated method stub
		return null;
	}
}

 Iterable interface를 구현(implements)하면 클래스에 밑줄이 그이며 아직 구현되지 않은 메서드가 있다고 알려준다. 클래스 명에 마우스를 올리고 "Add unimplemented methods" 메시지를 클릭하면 아래와 같은 메서드가 추가되는 것을 볼 수 있다. for each나 iterator를 호출하면 이 메서드가 호출된다.

 

 이제 코드를 저장한 뒤 다시 메인 메서드로 돌아가보면 에러 메시지가 사라진 것을 확인할 수 있다.

 

 물론 다시 돌아가 for each를 수행하면 에러(java.lang.NullPointerException)가 발생한다. 아무것도 작성하지 않았기 때문이다. 더 정확히 말하자면 iterator() 메서드가 null을 반환하고 있기 때문이다.(확인해 보고 싶다면 try-catch로 감싸고 실행시켜 보자.) 이제 for each가 가능하도록 클래스를 작성해보자.

 

 

 

 implements Iterator

import java.util.ConcurrentModificationException;
import java.util.Iterator;

public class MyList<E> implements Iterable<E>{
	final int DEFAULT_CAPACITY = 10;
	Object[] elementData;
	int size;
	int modCount;
	
	MyList() {
		elementData = new Object[DEFAULT_CAPACITY];
	}
	
	public boolean add(E e) {
		if(size == 10)
			return false;
		
		elementData[size++] = e;
		modCount++;
		
		return true;
	}
	
	@SuppressWarnings("unchecked")
	public E remove(int index) {
		if(index < 0 || index >= size)
			throw new IndexOutOfBoundsException("Index "
				+ index + "out of bounds for length " + size);
		
		E oldElement = (E)elementData[index];
		
		if(index != size-1)
			for(int i = index; i < size-1; i++)
				elementData[i] = elementData[i+1];
		elementData[--size] = null;
		modCount++;

		return oldElement;
	}

	@Override
	public Iterator<E> iterator() {
		return new MyIterator<E>();
	}
	
	class MyIterator<E> implements Iterator<E> {
		int cursor;
		int lastRefer = -1;
		int expectedModCount = modCount;

		@Override
		public boolean hasNext() {
			return cursor != size;
		}

		@SuppressWarnings("unchecked")
		@Override
		public E next() {
			checkForSync();
			return (E)elementData[lastRefer = cursor++];
		}

		@Override
		public void remove() {
			if(lastRefer < 0)
				throw new IllegalStateException();
			checkForSync();
			MyList.this.remove(lastRefer);
			lastRefer = -1;
			expectedModCount = modCount;
		}
		
		private void checkForSync() {
			if(modCount != expectedModCount)
				throw new ConcurrentModificationException();
		}
	}
}

 

 MyList class의 코드는 꼭 필요한 것만으로 최소화하였다. ArrayList처럼 내부에서 데이터를 저장하기 위한 배열 elementData을 사용하고, 객체화될 때 생성자에서 기본 값 10으로 고정적인 크기의 배열을 할당받는다. 그리고 add(E)를 통해 데이터를 10개 까지 삽입할 수 있고, remove(int)를 통해 특정 인덱스의 데이터를 제거할 수 있다.

 

 핵심이 되는 부분은 MyIterator class다. 이 클래스는 for each를 구현하거나 MyList의 iterator()를 호출하였을 때 객체화되어 반환된다. 먼저 각 메서드의 역할에 대해 알아보자. 

 

 hasNext()

 다음 참조할 값이 있는지 판단한다. 판단하는 기준은 현재 데이터 개수(size)와 현재 참조중인 위치(cursor)를 비교하여 같으면 false, 다르면 true를 반환한다.

 

 next()

 단독으로 호출하기 보다는 hasNext()로 true를 리턴받아 데이터가 있다고 판단되었을 때 호출한다. 먼저 checkForSync로 자료구조의 변형을 확인한다. 그 후 다음 반환할 데이터를 찾기 위해 배열을 참조할 때 cursor의 값을 lastRefer에 저장한 뒤 cursor의 값을 후증가로 1증가 시킨다. 이렇게 하는 이유는 데이터 반환이 끝났을 때 lastRefer는 마지막에 참조한 위치를, cursor는 다음 참조할 위치를 가르키게 하기 위함이다. 

 

 remove()

 해당 메서드는 ArrayList에서 사용하던 데이터 삭제 메서드인 remove(int index), remove(Object o)와 달리 파라미터가 없다. 특정 요소를 참조하여 삭제하라고 명시할 수 없다는 것인데, 오라클에서 제공하는 API Document에 따르면 Iterator에서 remove를 사용할 때에는 "마지막에 반환한 데이터를 삭제한다"고 에 명시되어 있다. 

 

 이제 메서드를 들여다보자. next() 메서드를 호출하지 않은 상태에서 remove를 호출하는 경우, remove를 2번 호출하는 경우를 막기 위해서 가장 먼저 lastRefer가 0보다 작은지 확인한다. lastRefer가 0 이상이라는 것은 이전에 참조한 값을 가리키고 있다는 것이다. 그 후 checkForSync를 호출하여 자료구조 변형을 확인한다. 문제가 없을 경우 MyList의 remove(int index)를 호출하여 마지막에 참조한 값을 가리키고 있는 lastRefer를 파라미터로 넘겨 삭제를 수행한다.

 

 이제 후처리를 봐야하는데 cusor에 lastRefer의 값을 복사한다. cursor는 다음 참조할 위치를 가지고 있는데 remove를 수행함으로써 데이터가 모두 1개씩 당겨졌음으로 cursor의 크기도 1 감소할 필요가 있다. 또한 방금 참조한 위치의 값이 사라졌기 때문에 remove()가 연속으로 2번 호출되면 cursor와 lastRefer의 값이 같아져 아직 참조하지도 않은 값을 삭제하거나 범위를 벗어나는 오류가 발생할 수 있기 때문에 -1을 초기화하여 에러를 방지한다.

 

 remove는 Iterator를 수행하는 도중 유일하게 데이터 변경을 허용하는 메서드다. 여기서 expectedModCount 변수를 고려해야 하는데 modCount의 값을 증가시키는 remove(int index)를 호출했기 때문에 expectedModCount와 modCount의 값이 일치하지 않게 된다. 때문에 expectedModCount의 값을 현재 modCount로 바꿔주는 것 까지가 remove 메서드의 역할이다.

 

 클래스를 만들고 iterator를 implements해 보았다면 눈치챘겠지만 이 메서드는 필수 구현 메서드가 아니다. "Override/implements method.." 옵션을 통해 필요하다면 추가할 수 있다. 이 메서드가 선택적 구현인 이유는 위에서 말했듯 for each만을 사용할 경우 remove는 어차피 호출될 일이 없기 때문이다. 

 

 checkForSync()

 next(), remove() 메서드가 호출될 때 지역 초반에 호출되어 자료구조의 변형 여부를 확인한다. 

 


 

 Example) iterator

 백문이 불여일견이니 iterator를 사용한 예제를 보며 역할을 보자. list에 데이터를 5개 삽입한 후 리스트를 읽어오기 위해 list의 iterator() 메서드를 통해 MyIterator 객체를 받아온다. 그리고 while loop를 돌면서 데이터를 읽어오는데, 반복 조건을 판단할 때 hasNext() 메서드로 참조할 위치(cursor)와 현재 데이터 개수(size)를 비교하여 다음 참조할 값이 있는지 판단한다. 값이 있을 경우 next() 메서드를 호출하여 다음 값을 읽어온다. 읽어온 문자열을 비교하여 "banana"라면 삭제하고 이 외는 출력한다.

 

 

 Example) for each

 다음은 for each를 사용한 예제다. iterator와 비교해봐도 명백히 쉽고 간단하다. 예제처럼 데이터를 읽으면 컴파일러가 hasNext와 next를 적절하게 사용해 간편한 대신 remove를 사용할 수 없다.

 


 

 컬렉션이 아닌 클래스에서 Iterator를 구현해 사용하는 것에 대해 알아보았다. iterator를 구현하면  for each로 데이터를 순방향으로 조회하는 것이 가능하고 iterator 객체를 반환받아 사용하는 경우 여기서 추가로 remove가 가능하다. 그리고 여기에 더해 순방향, 역방향으로 조회가 가능하면서 데이터 삽입, 삭제, 수정 등이 추가된 ListIterator interaface가 있다.

 자바에서 사용할 수 있는 반복문으로 for와 while이 있다. Java 5 부터는 for문을 좀 더 쉽게 사용할 수 있도록 for each문이라는 것을 제공한다. 새로운 예약어를 사용하지 않고 기존 for문을 조금 변형하여 사용한다. 

 

 기존 for문과 for each문을 비교해보자.

 

 for

public static void main(String args[]) {
	String[] fruit = {"apple", "banana", "melon", "kiwi", "orange"};
		
	for(int i = 0; i < fruit.length; i++)
		System.out.println(fruit[i]);
}

 

 

for each

public static void main(String args[]) {
	String[] fruit = {"apple", "banana", "melon", "kiwi", "orange"};
		
	for(String s : fruit)
		System.out.println(s);
}

 

 String 타입의 배열에 있는 값을 모두 출력시키는 코드다. 한 눈에 보기에도 간결해진 것을 알 수 있다. 기존의 for문을 보면 초기식, 반복조건, 증감식의 형태를 가지는데 for each 문을 보면 반복되는 동안 가져올 값을 저장할 변수와 값을 꺼내 올 대상을 지정하는 것만으로 반복을 수행할 수 있다. 위 예제는 배열을 대상으로 하였는데 자바에서 제공하는 컬렉션을 대상으로도 for each를 사용할 수 있다.

 

 


 

 

 for each와 Collection

 컬렉션이 for each를 수행할 수 있는 이유는 Iterable interface를 구현하고 있기 때문이다. 반복 수행 방법은 배열과 다르지 않지만 컬렉션의 경우 List, Set, Queue, Map 중 Map은 단일 객체가 아닌 Key, Value 쌍으로 데이터를 저장하고 있기 때문에 사용 방법이 조금 다르다.

 

 

 ArrayList, HashSet

public static void main(String args[]) {
	List<String> list = new ArrayList<>();
	Set<Integer> set = new HashSet<>();
    
	// add elements..(skip)
        
	for(String s : list) // ArrayList
		System.out.println(s);
        
	for(int n : set)
		System.out.println(n);
}

 

 HashMap

public static void main(String args[]) {
	Map<String, Integer> map = new HashMap<>();
    
	// add elements..(skip)
        
	// entrySet() 메서드를 사용하는 경우
	for(Map.Entry<String, Integer> e : map.entrySet())
		System.out.println("K : " + e.getKey() + ", V : " + e.getValue());
        
	// keySet() 메서드를 사용하는 경우
 	for(String key : map.keySet()) {
		int value = map.get(key);
		System.out.println("K : " + key + ", V : " + value);
	}
}

 

 Map의 경우 일반적인 for each와 달리 위와 같이 사용한다. Key와 Value가 모두 필요한 경우 entrySet() 메서드를, Key만 필요한 경우 keySet을 사용한다.

 

 


 

 

 for와 for each 적절하게 쓰기

 기존 for문은 익숙하지 않으면 반복 조건의 비교문이나 반복조건의 증감식을 작성할 때 잘못된 식을 적어 오류를 일으키는 경우가 종종 있는데 for each문은 그러한 오류를 미연에 방지할 수 있다는 장점이 있다. 하지만 더 쉽게 쓸 수 있는 for each가 등장했다고 해서 기존 for문을 사용하지 않는 것은 아니다. for each는 현재 접근 중인 위치를 모르기 때문에 순회 중 발견한 데이터를 수정/삭제하기가 까다롭기 때문에 데이터를 찾아서 수정/삭제하는 목적이라면 기존 for문을 사용하는 것이 더 나을 수 있다.

 BufferedWriter, FileWriter와 같이 중간에 버퍼를 두고 사용하는 클래스는 버퍼를 비우기 위한 목적으로 flush() 메서드를 제공한다. 스트림을 통해 데이터를 출력할 때 마지막에 데이터를 누락시키지 않으려면 flush가 꼭 필요하다고 생각했는데 막상 버퍼를 두는 클래스의 예제를 찾아 보면 flush를 사용하지 않는 경우도 많았다. 사용하고 안하고의 차이가 궁금했기에 flush에 대해 조사해봤다. 

 

 

 스트림으로 연결된 두 지점이 있을 때 사용자의 입력이 발생할 경우 즉시 데이터를 출력하지 않고 데이터를 축적하다가 일정량만큼 쌓이면 출력한다. 여기서 데이터를 축적하는 곳이 버퍼이며 한정된 자원인 메모리에 데이터를 무작정 계속 쌓을 수는 없기에 초기의 버퍼의 크기를 정해놓는다. 버퍼의 크기만큼 데이터가 축적되면 내부에서 자동으로 버퍼를 비우도록 되어 있기 때문에 사용 중에 버퍼가 얼마나 찼는지 고려할 필요가 없다. 다만 축적되는 데이터의 총량이 버퍼의 크기로 딱 나누어 떨어질 확률은 거의 없기 때문에 스트림 사용이 끝났을 때 버퍼에 데이터가 남아 있을 확률이 매우 높다. 이러한 경우 flush()를 호출하여 남아 있는 데이터를 방출하는 것이다.

 

 

 flush()를 사용하지 않는 경우는 무엇일까? 바로 close()를 호출하는 것이다. 스트림은 네트워크, 파일, 입출력을 수행할 때 연결하는 데이터를 주고 받는 통로라고 볼 수 있다. 그리고 스트림은 운영체제의 자원을 가져다 쓰는 것이므로 사용하고 나면 반납을 해야 하는데 이 때 close() 메서드를 호출하여 사용한 자원을 해제한다. 버퍼를 사용하는 클래스들도 마찬가지로 스트림을 사용하기 때문에 마지막에 close()를 호출하는데 이 때 내부적으로 flush()를 호출하여 버퍼에 남아 있을지도 모르는 데이터를 강제적으로 방출한다.

 

 

 다시 한 번 버퍼가 비워지는 시점을 정리하면 다음과 같다.

  1. 버퍼의 크기만큼 데이터가 쌓였을 때
  2. 코드에서 flush()를 호출했을 때
  3. 코드에서 close()를 호출했을 때

 

 

 정리하자면 flush를 사용하지 않았던 예제들은 어차피 스트림을 닫으면 close()에서 자동으로 flush()를 호출할 것이니 flush()를 굳이 호출하지 않아도 된다는 생각이었던 것이다.(단순히 까먹었을 수도 있다..)

 

 

 하지만 작업이 끝나는 시점과 close()를 호출하는 시점이 바로 이어지지 않고 중간에 다른 작업을 수행하게 되면 어떻게 될까? 다음 같은 상황을 가정해보자.

BufferedWriter bw = null;

try {
	bw = new BufferedWriter(new OutputStreamWriter(System.out));
	bw.write("A");
	bw.flush();
	bw.write("B");
	System.out.print("C");
} catch(Exception e) {
	e.printStackTrace();
} finally {
	try {
		if(bw != null)
			bw.close();
	} catch(Exception e) {
		e.printStackTrace();
	}
}

 

 

결과는 다음과 같다.

ACB

 

 

 단순히 순서상으로 보면 "ABC" 가 나와야 할 것 같지만 코드를 실행시켜보면 "ACB"가 나온다. 코드를 보면 "A"를 write 한 다음 flush를 해주었으니 버퍼에서 대기하던 "A"가 가장 먼저 출력된다. 그리고 "B"를 write 하였는데 이전에 막 버퍼를 비웠기 때문에 퍼버가 자동으로 비워질 일도 없고 flush(), close()도 호출해주지 않았기 때문에 "B"는 버퍼에 남아 있는 상태로 유지된다. 그리고 "C"를 출력한 뒤 try{..} 지역의 코드가 끝나고 마지막으로 finally에서 스트림을 close() 해준다. close에서 자원을 정리하기 전에 버퍼에 남아 있던 "B"를 출력했기 때문에 결과로 "ACB"가 나온 것이다.

 

 


 

 

 flush()를 호출하지 않아도 close()에서 버퍼를 비워주는 것은 맞다. 하지만 위 예제에서 알 수 있듯이 스트림 사용이 끝나는 시점과 close()를 호출하는 시점이 바로 이어지지 않을 경우 의도한 것과 다른 결과가 나올 수 있다는 것이다. 매번 중간에 다른 작업이 있는가에 대해 신경쓸 바에야 flush()랑 close()를 항상 사용해주는 습관을 들이는 것이 좋겠다.

 

 

 윈도우 탐색기에서 마우스로 파일을 선택하여 프로그램으로 드래그하는 것으로 파일을 읽는 것을 Drag and Drop 라고 한다. 간단하게 구현 방법을 알아보자.

 

Drag and Drop

 

DropTarget

public DropTarget(Component c, DropTargetListener dtl)

 드래그 앤 드롭을 적용할 Component와 드롭되었을 때 적절한 처리를 해줄 DropTargetListener를 생성자 파라미터로 받는다.

 

DropTargetListener

Method 설명
public void drop(DropTargetDropEvent dtde) 파일이 드래그 된 후 타겟 위에서 마우스 동작을 종료했을 때 호출
public void dragEnter(DropTargetDragEvent dtde) 파일이 타겟 위에 드래그 된 시점에서 호출
public void dragOver(DropTargetDragEvent dtde) 파일이 드래그 된 후 타겟 위에서 마우스 포인터가 존재할 때 호출
public void dropActionChanged(DropTargetDragEvent dtde) 파일이 드래그 된 후 제스쳐가 변경되었을 때 호출
public void dragExit(DropTargetEvent dte) 파일이 드래그 된 상태에서 포인터가 타겟에서 벗어났을 때 호출

 드래그 앤 드롭 과정에서 발생하는 콜백 메서드들이다. 다른 메서드는 그다지 쓰이지 않고 drop을 구현하는 것이 일반적이다.

 

 

Code

import java.awt.Color;
import java.awt.datatransfer.DataFlavor;
import java.awt.dnd.DnDConstants;
import java.awt.dnd.DropTarget;
import java.awt.dnd.DropTargetDragEvent;
import java.awt.dnd.DropTargetDropEvent;
import java.awt.dnd.DropTargetEvent;
import java.awt.dnd.DropTargetListener;
import java.io.File;
import java.util.List;

import javax.swing.JFrame;
import javax.swing.JTextArea;

public class GUI extends JFrame implements DropTargetListener{
	JTextArea jta_log;
	
	public GUI () {
		setTitle("Swing");
		setBounds(10, 10, 400, 300);
		setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);
		getContentPane().setBackground(Color.gray);
		setLayout(null);
		
		DropTarget dropTarget = new DropTarget(this, this);
		setDropTarget(dropTarget);
		
		jta_log = new JTextArea();
		jta_log.setBounds(10, 10, 360, 200);
		add(jta_log);
	}
	
	@Override
	public void drop(DropTargetDropEvent dtde) {
		jta_log.append("파일이 드롭되었습니다.\n\n");
		try {
			dtde.acceptDrop(DnDConstants.ACTION_MOVE);
			List<File> files = (List<File>)dtde.getTransferable().getTransferData(DataFlavor.javaFileListFlavor);
			for(File file : files) {
				jta_log.append(file.getPath() + "\n");
			}
		} catch(Exception e) {
			e.printStackTrace();
		}
	}

	@Override
	public void dragEnter(DropTargetDragEvent dtde) { }

	@Override
	public void dragOver(DropTargetDragEvent dtde) { }

	@Override
	public void dropActionChanged(DropTargetDragEvent dtde) { }

	@Override
	public void dragExit(DropTargetEvent dte) { }
}

 

 예제는 JTextArea를 배치하여 드롭된 파일 목록을 출력한다. DropTargetListener는 이름에서 짐작할 수 있듯이 파일을 드래그해 올 때 이벤트를 감지하고 처리하는 인터페이스이고, DropTarget은 파일을 드롭시킬 Target Component와 이벤트를 설정할 수 있다. JFrame을 Target으로 하고 이벤트도 현재 클래스에서 implements 했으므로 (this, this)를 넘겨주었다.

 

 파일을 Drop하게 되면 Drop 메서드에서 File 타입 객체들을 List로 읽어올 수 있다. 바로 처리할 것이 아니라면 File 타입의 리스트를 멤버 변수로 생성해두고 파일이 드래그될 때마다 누적시켜서 처리하는 것이 좋다. 

 ArrayList는 자바를 공부하면서 가장 많이 사용되는 자료구조 중 하나다. 본 포스팅에서는 ArrayList가 왜 필요한지, 내부적으로 어떻게 동작되는지에 대한 이론을 다룬다. ArrayList는 배열을 기반으로 하기 때문에 먼저 배열에 대한 이해를 필요로 하기 때문에 배열에 대해 간단히 리뷰한 뒤 ArrayList에 대해 알아보고 다음 포스팅인 구현으로 넘어 가겠다.

 

 

 

 배열(Array), 그리고 문제점

 프로그래밍을 공부하다 보면 초기에 배열(Array)이라는 것을 배우게 된다. 변수가 여러 개 필요할 때 일일이 "int a, b, c.. etc"와 같이 선언해야 한다면 접근하기도 관리하기도 쉽지 않다. 변수가 2, 3개라면 모를까 10개 20개, 몇 백 개가 된다사실상 관리가 불가능하기 때문에 같은 성질의 변수가 여러 개일 경우 배열을 사용하게 된다.

 

 변수를 할당받으면 메모리 영역 중 스택에 할당받게 되는데, 변수는 변수명으로만 접근할 수 있기 하기 때문에 변수의 수가 많을 수록 유기적으로 접근하기가 어렵다. 특히 반복문을 통해 같은 성질의 데이터들 탐색하는 것은 엄청난 노가다가 될 것이다. 하지만 배열의 경우 Heap 영역에 메모리를 연속적으로 할당받은 뒤 배열의 시작 주소를 스택에 저장해서 접근할 수 있다. 메모리 상에 데이터가 연속적으로 할당되어 있다는 것은 가장 앞 주소만 기억하고 있다면 다음 데이터가 있는 위치는 배열의 자료형 크기를 더해주는 것으로 주소를 알아낼 수 있다는 것을 의미한다. 이러한 구조는 성격이 비슷한 변수들의 관리를 용이하게 해주고, 간단한 주소 계산을 통해 배열의 어느 요소든 빠른 접근을 가능하게 한다.

 

 하지만 장점이 있으면 단점도 존재한다. 배열은 한 번 할당받으면 크기를 줄이거나 늘일 수 없고, 저장된 데이터가 정렬되어 있다고 할 때 중간의 데이터를 삽입 및 삭제하려고 하면 해당 인덱스를 기준으로 데이터를 밀거나 당기는 작업이 필요해진다. 또한 배열을 초기화할 때 할당받은 메모리를 전부 사용한 상황에서 새로운 데이터를 삽입하려고 하면 공간 부족의 문제가 발생한다. 공간이 부족할 수 있으니 미리 넉넉하게 할당받아 놓는다는 생각을 할 수 있지만 추후 데이터 삽입 여부에 따라 공간 낭비로 이어질 수 있다.

 

 데이터의 개수가 정해져 있고 삭제/삽입이 일어나지 않는 환경이라고 한다면 접근이 빠른 배열이 유리하지만 데이터 삭제/삽입이 빈번하고 데이터의 개수가 유동적인 상황이라면 배열은 결코 좋은 해결 방법이 될 수 없다.

 

 그렇다면 빠른 접근이라는 장점을 유지한 채로 정적인 배열을 동적으로 사용할 수 없을까? 

 

 


 

 

 ArrayList

 위에서 배열의 개념과 장단점을 살펴봤다. 배열은 데이터 삽입/삭제가 잦고 데이터 개수가 유동적인 환경에 적합하지 않다.

 

 ArrayList를 한마디로 표현하면 "사이즈 변경이 가능한 배열"이라고 할 수 있다. ArrayList는 배열의 요소에 접근할 때 대괄호([n])를 사용하지 않고 메서드를 통해서 보다 직관적인 데이터 삽입/삭제/변경을 할 수 있다. 또한 배열에 있는 데이터를 초기화하거나 배열 내 데이터 검색 등 사용자에게 편리한 메서드를 제공한다. 이러한 메서드들의 존재는 사용자가 직접 작성하면서 발생할 수 있는 자잘한 오류를 방지해주는 효과도 있다. 하지만 처음에 언급했듯이 가장 큰 장점은 내부적으로 배열을 사용함에도 불구하고 데이터 개수 제한에 구애받지 않고 얼마든지 데이터를 삽입할 수 있다는 점이다.(여기서 메모리 크기의 한계는 생각하지 않는다.) ArrayList는 이름에서 유추할 수 있듯 내부를 들여다보면 배열을 사용하고 있다. 내부적으로 배열을 사용함에도 불구하고 어떻게 데이터를 무한히 삽입할 수 있는 것일까?

 

 아래 소스코드는 실제 ArrayList 클래스의 일부분을 가져온 것인데 Object형 배열 elementData가 실제로 우리가 add를 호출해서 데이터를 삽입하면 데이터가 저장된다. 소스를 이해하라는 것이 아닌 내부에 배열을 쓰고 있다는 것만을 보고 가면 되겠다.

public class ArrayList<E> extends AbstractList<E>
        implements List<E>, RandomAccess, Cloneable, java.io.Serializable
{
    @java.io.Serial
    private static final long serialVersionUID = 8683452581122892189L;

    private static final int DEFAULT_CAPACITY = 10;
    private static final Object[] EMPTY_ELEMENTDATA = {};
    private static final Object[] DEFAULTCAPACITY_EMPTY_ELEMENTDATA = {};
    transient Object[] elementData;
    ...
}

 

 


 

 

 ArrayList에서 배열 크기 문제를 해결하는 방법 

final int DEFAULT_CAPACITY = 5;
int[] array = new int[DEFAULT_CAPACITY];

 크기 5의 배열 array를 선언하였다. 만약 위 배열에 데이터가 5개 삽입되었다고 가정한다면 배열의 최대 크기만큼 데이터가 삽입되었기 때문에 더 이상 데이터가 삽입될 수 없는 상태가 된다. 일반적인 배열이라면 여기서 기존 데이터를 유지한 채 새로운 데이터를 삽입하는 방법은 없다.

 

 

 

Memory copy

 ArrayList의 경우를 보자. ArrayList는 초기에 일정한 크기의 배열을 할당받아 놓는다. 사용자가 초기 배열의 크기를 지정해 줄 수도 있지만 지정해주지 않아도 기본값으로 배열이 생성된다. 예를 들어 기본값이 5라고 하면 크기 5의 배열이 생성될 것이다. 그리고 그 배열에 5개의 데이터가 삽입되었다고 하면 더 이상 데이터를 추가할 수 없는 상태가 될 것이다. 이것은 배열의 본질이기 때문에 변하지 않는다. 하지만 ArrayList는 가지고 있는 배열에 데이터를 한계까지 삽입한 경우 더 큰 크기의 새로운 배열을 만드는 것으로 문제점을 극복했다. 현재 배열의 크기보다 더 큰 배열을 만들어 현재 데이터를 모두 이동시킨 후 이제부터 새로 만든 배열을 사용하는 것이다.(이전 배열은 Unreachable Object가 되어 언젠가 가비지 콜렉터에게 수거당한다.) 소스코드로 보면 아래와 같다.

 

 

Object[] newArray = new Object[array.length * 2];
		
for(int i = 0; i < array.length; i++) {
	newArray[i] = array[i];
	array[i] = null;
}
		
array = newArray;

 새로운 배열을 생성하고 기존 데이터를 새로운 배열로 이동시키는 코드다. 데이터를 옮기는 작업이 끝나면 멤버 변수로 가지고 있던 메인 배열의 주소를 새로운 배열로 바꿔준다.

 

 

 


 

 

 

 ArrayList 삽입, 삭제

 그 외에 데이터 삽입, 삭제, 수정 등의 메서드도 지원한다. 배열을 사용하기 때문에 삽입, 삭제는 우리가 흔히 알고 있는 데이터를 뒤로 밀거나 앞으로 당기는 것으로 이루어진다. 메서드의 종류가 너무 많기에 간단히 삽입, 삭제에 대한 메서드만 소개하겠다.

 

 

 add(Object value)

 명실상부 ArrayList에서 가장 많이 사용되는 메서드로, 데이터를 삽입할 때 호출하며 파라미터로 전달한 값을 배열의 가장 마지막 위치에 삽입한다.

 

 


 

 

 add(int index, Object value)

 데이터를 삽입할 때 배열의 끝이 아니라 특정 위치에 데이터를 삽입해야 할 때가 있다. 만약 "ArrayList.add(2, 12)"라는 코드를 호출하게 되면 배열의 2번째 index에 12라는 값을 삽입하라는 뜻인데 해당 메서드가 호출될 경우 index 위치로부터 배열의 끝까지 데이터를 모두 뒤로 한칸씩 미루고 파라미터로 받은 값을 index 위치에 삽입한다.

 

 


 

 

 remove(int index)

 remove(Object value)

 데이터를 삭제하는 방법은 크게 두 가지가 있다. 먼저 첫 번째 방법은 값으로 삭제할 데이터를 찾는 방법이다. remove 메서드의 파라미터로 Object를 넘긴 뒤 ArrayList가 가진 배열의 첫 번째 요소부터 마지막 데이터까지 비교해가며 해당 파라미터와 일치하는 값을 찾는다. 일치하는 값이 있을 경우 해당 위치를 기준으로 뒤에 있는 데이터를 한 칸씩 앞으로 당긴다. 

 

 두 번째 방법은 파라미터로 삭제할 데이터의 위치를 특정한 index를 받는 것이다. 삭제할 위치를 알고 있다면 배열의 장점인 index에 의한 접근으로 빠르게 데이터를 삭제할 수 있다. 값으로 데이터를 찾는 방법에 비해 탐색 시간은 없지만 뒤에 있는 값들을 하나씩 앞으로 당긴다는 점은 동일하기 때문에 어느 정도 부담이 가는 작업이다. 

 

 또한 값으로 데이터를 삭제하려할 때 배열에 해당하는 값이 없다면 null을 반환하지만 인덱스로 접근하는 경우 배열의 크기와 벗어난 경우 예외(Exception)이 발생하니 주의가 필요하다.

 

 

 

 

 


 

 

 

 이 외에 데이터 수정(set), 배열 비우기(clear), 데이터 찾기 등 여러 기능을 지원하지만 여기서는 다루지 않고 다음 포스팅인 구현부에서 소스 코드와 함께 설명하겠다.

 

 

 ArrayList

 

[자바/자료구조] ArrayList 직접 구현하기

 본 포스팅에서는 ArrayList에 대한 보다 깊은 이해와 코딩 능력 상승을 위해 ArrayList를 구현합니다. ArrayList가 내부적으로 어떻게 동작하는지 모르신다면 코드를 보기 전에 ArrayList에 대한 이론을

ku-develog.tistory.com

 

문제가 발생한 예
클릭을 해야만 정상적으로 화면이 표시된다.

 Swing으로 화면을 구성했을 때 Frame에 배치한 컴포넌트들이 보이지 않고 마우스로 해당 자리를 클릭해야만 컴포넌트가 표시되거나 클릭해도 표시되지 않을 때가 있다.

 

 

public class Main {
	public static void main(String args[]) {
		GUI gui = new GUI();
	}
}

class GUI extends JFrame {
	public GUI () {
		..
		this.setVisible(true);
		..
    }
}

 JFrame을 상속한 GUI 클래스가 있을 때 해당 클래스 내부에서 setVisible을 호출할 경우 위와 같은 현상이 발생한다.

 

 

public class Main {
	public static void main(String args[]) {
		GUI gui = new GUI();
		gui.setVisible(true);
	}
}

 내부에서 setVisible을 호출하지 않고 객체화한 후 호출하도록 하자.

 자바에서 입출력이 이루어지는 과정에 대해 알아보자. 주로 Stream, Buffer에 대하여 다룬다. 입출력에 대해 이해하기 위해서는 먼저 Stream을 알아야 하고, 입출력 과정에서 Buffer를 사용하는 것과 사용하지 않는 것의 차이를 아는 것이 중요하다. 자바에서 입출력은 Stream을 이용하는 단방향 통신의 IO와 Channel을 이용하는 양방향 통신의 NIO가 있다. Stream의 경우 단방향이기 때문에 입력 혹은 출력을 하기 위해서는 용도에 맞는 스트림을 각각 열어야만 하고 입력 스트림은 입력으로만, 출력 스트림은 출력으로만 사용가능하다. Channel의 경우 Stream과 달리 하나의 채널로 양방향으로 통신이 가능하다. 본 포스팅에서는 Stream에서 출력이 이루어지는 과정에 대해 알아본다.

 

 

 

 Stream

 Stream 이란 입력 장치와 프로그램 간에 데이터가 이동하는 가상의 통로로, 데이터를 입력받는 InputStream과 데이터를 출력하는 OutputStream으로 나뉜다. Consol 입출력, 파일 입출력, 네트워크 통신 등 스트림은 다양하게 사용되며 자바에서는 각 상황에 맞는 다양한 Stream 클래스를 제공하고 있다. Stream은 FIFO 특성을 가지며 데이터를 입력하는 순서대로 들어오고 들어온 순서대로 도착한다. 자바에서 모든 IO는 Stream을 통해서 이루어진다. 

 

 

출처 : https://story.stevenlab.io/38

 

 자세히 살펴보면 알 수 있듯이 IO Stream은 항상 Read(input), Write(Output) 두 가지가 존재한다. 즉 단방향 통신이라는 이야기다. 입력을 받지 않고 출력만을 한다면 OutputStream에 관한 클래스만 사용해도 되고, 입력받아서 처리한 후 출력할 내용이 없다면 InputStream만 써도 된다는 뜻이다. 

 

 입출력에 있어서 Stream 개념이 중요한 이유는 우리가 입력하고 출력하는 데이터들이 화면에 표시되기 전에 이 Stream이라는 통로를 거치기 때문이다. 이 개념을 이해하지 못하면 입출력이 이루어지는 과정을 이해하는데 어려움이 있기 때문에 잘 숙지해야 한다.

 

 

 

 Buffer

 Stream은 입력 장치와 프로그램 간의 데이터 통로라고 했다. 사용자가 키보드에서 'a'를 입력하면 그 즉시 프로그램에 'a'가 전달된다. 사용자의 입력이 프로그램에 즉시 전달되는 것은 성능에 나쁜 영향을 준다. 하지만 실제로 처리되는 과정에서 대량의 데이터가 입출력되는 와중에 하나의 문자를 계속해서 전달하는 것은 굉장히 비효율적이다. n 개의 문자가 있을 때 n번 데이터 전송이 이루어진다는 것이다.

 이 문제를 해결하기 위해서 buffer라는 개념이 등장한다. 데이터를 계속해서 보내지 말고 어딘가에 쌓아두다가 일정량만큼 쌓이면 데이터를 전송하자는 것이다. 

 

 

 버퍼를 사용하지 않는 예와 버퍼를 사용하는 예를 보자. 중간에 화살표가 Stream이라고 생각하자.

 

 

버퍼를 사용하지 않는 예) 문자열 "Hello World" 를 입력했을 경우

 

 버퍼를 사용하지 않고 "Hello world"를 입력하는 경우 위와 같은 일이 일어난다. "Hello world"라는 타이핑하는데 3초도 걸리지 않음에도 문자가 입력될 때마다 프로그램에 전송하고 있는 것을 볼 수 있다.

 

 

 

버퍼를 사용한 예) 문자열 "Hello world"를 입력했을 경우

 

 버퍼를 사용하여 "Hello world"를 입력할 때 버퍼를 두게 되면 버퍼에 입력하는 데이터가 쌓이게 된다. 그리고 버퍼가 다 찼거나 사용자가 임의적으로 데이터를 출력하면 버퍼에 있는 데이터가 Program으로 전송된다. 단순히 생각해봐도 버퍼의 크기가 1,024char(2,048byte)일 때 버퍼를 사용하지 않는 경우 1024번 데이터가 전송되는 것을 버퍼를 사용함으로써 1번으로 줄일 수 있다는 것을 알 수 있다.

 

 위는 입력받는 과정을 나타냈지만 출력도 동일하다. 화살표 방향만 바꾸어 보면 감이 올 것이다.

 

 


 

 

 Buffer는 반드시 사용해야 할까?

 그렇다면 "버퍼를 사용하지 않고 입력을 받는 것은 잘못된 것인가?" 라고 묻는다면 그렇지는 않다. 다만 대부분의 경우 효율성이 떨어진다는 이야기다.

 문자 1개만을 입력받는 상황을 예로 들어보자. 이러한 상황에서 버퍼는 도움이 될까? BufferedReader 클래스를 사용할 때 기본 Buffer 크기는 8192char(16,384byte)다. 문자 1개만을 입력받고자 할 때 굳이 8,192의 크기를 할당받는 것이 맞는 것일까? 출력할 때에는 BufferedWriter를 사용할 수 있다. 마찬가지로 기본 버퍼 크기가 8,192인데 문자 하나를 출력한다고 하면 버퍼가 무슨 소용이 있겠는가?

 물론 실제 상황에서는 문자 하나, 숫자 하나를 입력받고 끝나는 케이스보다는 긴 문자열을 입력받는 경우가 커먼 케이스일 것이다. 다만 알고리즘처럼 어떤 상황에서든 최고의 성능을 내는 알고리즘은 없듯이 이 또한 필요한 상황이 있고 필요없는 상황이 있다. 필요에 따라서 적절하게 구현해서 사용해야 한다. 물론 그러기 위해선 제대로 된 이해가 동반되어야 할 것이다.

 

 마치며

 알고리즘 문제를 해결할 때 문제에서 요구하는 결과를 얻어도 실행시간이 초과되어 실패하는 경우가 종종 있다. 알고리즘이 비효율적이기 때문일 수도 있지만 입출력 속도에 문제가 있을 때도 있다. 알고리즘을 해결하는 것도 중요하지만 해결했을 때 다른 개발자 분들이 해결한 알고리즘에 비해서 속도가 현저히 떨어진다면 어느 부분에서 속도가 차이나는 것인지 또한 생각해보는 것이 좋겠다. 

 코딩 문제 사이트에서 문제를 풀다가 이 클래스에 대해 알게 되었다. 단계별로 준비된 문제를 풀다가 입출력 문제를 풀게 되었는데 내 코드의 실행 시간과 상위권 유저의 실행 시간이 너무나도 크게 차이났다. 도대체 뭐가 얼마나 차이나는 것인지 궁금해서 공개된 코드를 보게 되었는데 나는 학교에서 배운대로 Scanner를 사용한 반면 상위권 유저들은 모두 System.in으로 입력을 받고 있었다. 출력 시간을 줄이는 노력은 해본적 있지만 입력 시간을 줄인다는 것은 생각해본 적 없었기에 이 클래스가 궁금해졌다.

 

System.in

 System.in은 자바에서 데이터를 입력받는 표준 클래스지만 일반적인 상황에서는 단독으로 사용할 일이 잘 없다. 자바에서 입력을 받아야 하는 상황이라면 대부분 Scanner 혹은 BufferedReader를 사용하기 때문인데 그 이유는 단순히 System.in 클래스를 사용하는 것보다 다른 두 클래스가 사용하기 편하기 때문이다.

 

System.in.read(byte)

 System.in을 사용할 경우 가장 많이 사용되는 메서드로 1byte 크기의 데이터 하나를 입력받을 수 있다. 바꿔 말하면 영문 대소문자, 숫자, 특수문자 등을 입력받을 수 있고 한글은 입력받을 수 없다. 

 

int c = System.in.read();
System.out.println(c);

ex) [ 숫자(문자) 0~9 : 48~57, 대문자 A~Z : 65~90, 소문자 a~z : 97~122 ]

 

 read 메서드는 위와 같이 사용할 수 있다. 위 코드를 실행해서 문자를 입력받으면 어떤 값(숫자, 영문자 등)을 입력해도 정수형으로 반환되는데 이를 이해하려면 아스키 코드를 알아야 한다. 콘솔에서 입력한 값은 아스키 코드표에 해당하는 값으로 저장되기 때문이다. 숫자 0을 입력했다면 변수 c에 48이, 문자 'a'를 입력했다면 97이 초기화될 것이다.

 

 Scanner를 사용하면 숫자, 문자, 문자열 등 원하는 값을 즉시 입력받을 수 있는 메서드들이 존재하지만 System.in을 사용하면 이 메서드 하나로 숫자, 문자를 모두 입력받아야 한다. 이 메서드 자체로는 아스키 코드값밖에 얻을 수 없으니 숫자나 문자를 저장하고 싶으면 그에 맞는 가공 작업이 필요하다.

 

 

문자로 입력받기

char c = (char)System.in.read();
System.out.println(c);

 입력받은 문자를 그대로 출력하기 위한 방법 중 하나는 형 변환(Type Casting)이다. 입력을 받을 때 강제로 형 변환을 시키면 아스키 코드값에 맞는 문자가 변수에 초기화된다.

 

 

숫자로 입력받기

int num = System.in.read() - 48;
System.out.println(num);

 숫자 0을 입력받았다고 가정할 때 숫자 0은 아스키 코드표에서 48에 해당하기 때문에 num에는 48이 저장된다. 이 값을 숫자로 사용하려면 입력받은 값에 -48 연산을 해주면 된다.

 

 

10이상의 수 입력받기

int num = System.in.read();
System.out.println(num);

 위 코드를 실행하고 "12"를 입력하면 "49"가 출력된다. 앞서 말한 것처럼 read()는 1바이트 단위로 입력을 받기 때문에 입력한 값 중 먼저 입력한 '1'의 아스키 코드값인 49만 저장된 것이다.

 

 

public class Main {
	public static void main(String args[]) throws Exception {
		int num = readInt();
		System.out.println(num);
	}
	
	public static int readInt() throws Exception {
		InputStream is = System.in;
		int sum = 0;
		
		while(true) {
			int n = is.read();
			
			if(n == '\n')
				return sum;
			else if (n >= 48 && n <= 57)
				sum = (sum*10) + (n-48);
		}
	}
}

 두 자리 이상의 수를 저장하려면 키보드에서 입력이 들어올때 마다 입력된 값이 숫자인지를 판별하여 값을 누적시켜야 한다. 입력되는 수가 늘어날수록 자릿수가 하나씩 증가하기 때문에 누적된 값에 *10을 하고 입력되는 값을 더해주는 것으로 10 이상의 숫자를 입력받을 수 있다.

 

 


 

 

 System.in은 Scanner, BufferedReader와 비교하면 입력하는 값에 따라 그에 맞는 구현을 해줘야한다는 것, 입력에서 발생할 수 있는 예외를 처리해줘야 한다는 단점이 있다. 이런 이유로 System.in으로 입력받는 방법을 알게 된 후에도 코딩 문제를 풀 때를 제외하면 사용할 일이 크게 없는 것 같다.

+ Recent posts