'Programing/Java - J2SE'에 해당되는 글 9건

  1. 2006.01.10 DEQUES
  2. 2006.01.10 필터링 JLIST 모델
  3. 2005.11.10 J2ME Mobile 2D Graphics 시작하기
  4. 2003.09.09 AFFINETRANSFORM 이해하기
  5. 2003.09.09 SOCKETCHANNELS을 이용해서 작업하기
  6. 2003.08.19 클래스의 UNLOADING 과 RELOADING
  7. 2003.08.19 VARIABLE CONTENT로 메세지 포맷하기
  8. 2003.08.05 SWING COMPONENTS의 저장과 재구성
  9. 2003.08.05 MIDI SOUND 생성하기

DEQUES

Core Java Technologies Tech Tip에 오신 여러분을 환영합니다
Core Java Technologies
TECHNICAL TIPS
2006년 1월 10일자
출처 : Sun Korea Developer Network


Deque는 double-ended queues의 줄임말이다(디큐가 아니라 데크로 발음함). 큐는 한쪽 엔드에서 추가하고 다른 쪽 엔드에서 제거할 수 있는 반면, 양쪽 엔드에서 추가와 제거가 가능한 double-ended queues는 스택과 큐가 결합된 것처럼 작동한다. Deque 인터페이스는 J2SE 5.0에 도입된 Queue 인터페이스에서 확장되는데, 이 기능은 최근에 Java SE 6의 Java Collections Framework에 추가되었다(이 기능이 최종적으로 포함되려면 JCP의 승인을 받아야 함). 인터페이스 구현에는 LinkedList, ArrayDeque와 이에 수반되는 LinkedBlockingDeque가 포함된다.

LinkedList는 아마도 deque의 가장 전형적인 용례일 것이다. LinkedList는 제한 없이 확장이 가능하고 양쪽 엔드에서 신속하게 추가 및 제거 작업이 가능하다. ArrayDeque는 용량 제한이 없는 또 하나의 전형적인 구현으로, 성능을 최상으로 유지하기 위한 wraparound index 구현을 제공한다. 모든 베이스 컬렉션 구현이 그렇듯이, 어느 쪽도 threadsafe하지 않다. (Vector나 Hashtable 같은 역사적인 컬렉션은 threadsafe하지만 고도의 동시발생적인 액세스를 위해 설계되지는 않았다.) 쓰레드 안전이 필요한 경우에는 LinkedBlockingDeque를 이용하면 되는데, LinkedBlockingDeque 클래스는 Deque에서 확장되는 새로운 BlockingDeque 인터페이스를 구현한다. 이 클래스는 사이즈가 제한되거나 제한되지 않을 수도 있다. 용량이 지정되지 않은 경우, 사이즈 제한은 Integer.MAX_VALUE이다.

다음 메소드 중 하나로 deque에 엘리먼트를 추가할 수 있다.


void addFirst(E e)
void addLast(E e)
boolean add(E e)
add() 메소드는 addLast()와 동등하다고 볼 수 있다. deque의 용량이 부족하면 IllegalStateException이 throw된다. 또한 다음 메소드 중 하나를 통해 추가될 엘리먼트를 제공할 수도 있다.


boolean offer(E e)
boolean offerFirst(E e),
boolean offerLast(E e)
addXXX() 메소드로 엘리먼트를 추가하는 경우와 달리, offerXXX() 메소드가 제공되었을 때 항목을 추가할 수 없으면 메소드는 false를 반환한다.

이 외에도 엘리먼트 제거를 위한 한 쌍의 메소드 세트가 있다.


remove(), removeFirst(), and removeLast()
poll(), pollFirst(), and pollLast()
deque가 비어있을 경우 removeXXX() 메소드는 NoSuchElementException를 throw하고, pollXXX() 메소드는 null을 반환한다. 또한, 아래의 메소드 중 한 가지를 사용하여 특정 개체를 제거할 수 있다(deque가 엔드에서의 추가/제거의 용도로만 의도된 경우라도).


boolean remove(Object o)
boolean removeFirstOccurrence(Object o)
boolean removeLastOccurrence(Object o),
Deque는 엘리먼트를 검사하기 위한 6개의 메소드를 가진다.


element()
getFirst()
getLast()
peek()
peekFirst()
peekLast()
element()는 오래된 Queue 인터페이스로부터 상속한 인터페이스 메소드이기 때문에 get() 메소드가 없다. get 메소드는 removeXXX()와 유사하며 deque가 비어있는 경우 NoSuchElementException을 throw하는데, 이와 대조적으로 peek 메소드는 비어있는 경우 null을 반환한다. 이는 물론 Deque가 null 값의 추가를 허용할 경우 deque의 엔드에 있는 null 항목과 ‘nobody on deck’ 간의 차이를 알 수 없다는 것을 의미한다. 그러나 이 경우에는 size() 메소드를 활용할 수 있다.

Deque는 개념상 이중으로 링크되므로, 어떤 순서로도 엘리먼트들을 traverse할 수 있다. 앞에서 뒤로 traverse하려면 iterator()를 이용하고 역순, 즉 뒤에서 앞으로 traverse하려면 descendingIterator()를 이용한다. 하지만 위치별로 엘리먼트에 액세스할 수는 없다?적어도 Deque 인터페이스로는 불가능하다. LinkedList는 Deque의 구현이지만 함께 구현되는 List 인터페이스를 통해 색인 액세스(indexed access)를 지원한다. 랜덤 액세스 요건이 없다면 Deque 구현이 더 효과적으로 이루어질 수 있다.

왜 deque를 사용하는 것일까? deque는 maze 또는 parsing 소스를 통한 검색과 같은 반복적인 문제에 특히 유용한 데이터 구조로서, path를 따라 이동하면서(path가 양호하다고 판단되는 한) "good" spot을 저장하고 계속해서 데이터를 추가할 수 있다. path가 bad를 반환할 경우에는 bad 비트를 pop off하여 마지막 good spot으로 복귀한다. 이 경우 스택과 같은 동일한 엔드에서 추가와 제거를 수행하게 된다. 일단 길을 찾으면 처음부터 다시 시작하여 반대쪽 엔드에 해당하는 솔루션을 밝혀낸다. 또 다른 전형적인 예로, 운영체제 스케줄러, 그리고 사람들을 속이기를 좋아하는 악질 카드 딜러 등을 들 수 있을 것이다.

다음 프로그램 Blocked는 Deque의 용례, 보다 구체적으로는 용량 제한이 있는 LinkedBlockingDeque를 보여주고 있다. 이는 물론 최상의 deque 용례는 아니지만 API와 용량 제한에 도달했을 때의 상황을 예시해준다. 프로그램은 23개의 달 이름(짧은 이름과 긴 이름 모두)을 취하여 이를 6-엘리먼트 블로킹 deque의 헤드에 한번에 하나씩 추가한다. 또 다른 스레드에서는, 현재 컬렉션 내에 있는 엘리먼트의 수를 토대로 엘리먼트를 deque의 헤드와 테일에서 제거한다.


import java.io.*;
import java.util.*;
import java.util.concurrent.*;

public class Blocked {
public static void main(String args[]) {
Calendar now = Calendar.getInstance();
Locale locale = Locale.getDefault();
final Console console = System.console();
final Map names = now.getDisplayNames(
Calendar.MONTH, Calendar.ALL_STYLES, locale);
console.printf("Starting names: %s%n", names);
final Deque deque =
new LinkedBlockingDeque(6);
// Add one at time to beginning of deque
new Thread() {
public void run() {
Set keys = names.keySet();
Iterator itor = keys.iterator();
String element = null;
while (itor.hasNext() || element != null) {
if (element == null) {
element = itor.next();
console.printf("MapGot: %s%n", element);
}
console.printf("Offering: %s%n", element);
if (deque.offerFirst(element)) {
console.printf("MapRemoving: %s%n", element);
itor.remove();
element = null;
} else {
try {
Thread.sleep(250);
} catch (InterruptedException ignored) {
}
}
}
// Done. Give time to process rest.
try {
Thread.sleep(3500);
} catch (InterruptedException ignored) {
}
System.exit(0);
}
}.start();
while (true) {
if ((deque.size() % 2 == 1)) {
// remove head
console.printf(
"Remove head: %s%n", deque.pollFirst());
} else {
// remove tail
console.printf(
"Remove tail: %s%n", deque.pollLast());
}
// Sleep between loops
try {
Thread.sleep(500);
} catch (InterruptedException ignored) {
}
}
}
}

아래에서 보듯이, 프로그램 실행 시 printf 선언문 때문에 많은 아웃풋이 생성된다. 엘리먼트를 소스 맵에서 가져오거나, 소스 맵에서 제거하거나, deque에 제공하거나, deque에서 제거할 때마다 아웃풋 행이 생성된다. deque가 full 상태일 때 어떻게 제공(offering) 동작이 복수로 발생하는지 유의할 것.


>> java Blocked


Starting names: {Jun=5, March=2, December=11, April=3,
November=10, September=8, October=9, Sep=8, Aug=7, Apr=3,
May=4, June=5, Feb=1, Dec=11, Oct=9, Jan=0, Mar=2, Jul=6,
August=7, January=0, February=1, July=6, Nov=10}
Remove tail: null
MapGot: Jun
Offering: Jun
MapRemoving: Jun
MapGot: March
Offering: March
MapRemoving: March
...

Remove tail: Jul
Remove head: Nov
Remove tail: August
Remove head: July
Remove tail: January
Remove head: February
Remove tail: null

여기서 두 가지 사실에 주목할 필요가 있다. 첫째, 24개가 아닌 23개의 짧고 긴 달 이름이 있다(5월인 "May"의 경우에는 짧은 이름과 긴 이름 모두에 해당하기 때문에 23개가 된다). getDisplayNames() 메소드는 Map을 반환하므로 "May"는 짧은 이름과 긴 이름 등 2개 엔트리에 대한 key가 될 수 없다. 둘째는, 한쪽 엔드에서 추가하고 다른 쪽 엔드에서 제거하는 것이 작업의 전부라면 Deque 대신 컬렉션 프레임워크에서 Queue 구현을 이용하는 편이 더 나을 것이라는 점이다.

최소한, STL(Standard Template Library)에서의 유용한 deque/vector 비교 자료를 보려면 An In-Depth Study of the STLDeque Container를 참조할 것(여기서는 두 컨테이너 타입의 성능 차이에 대해 다루고 있음). 자바 플랫폼 상의 실제 수에는 차이가 있지만 전반적인 개념은 나름대로 정확하다고 볼 수 있다.

필터링 JLIST 모델

Core Java Technologies Tech Tip에 오신 여러분을 환영합니다
Core Java Technologies
TECHNICAL TIPS
2006년 1월 10일자
출처 : Sun Korea Developer Network


2005년 12월 20일자 테크 팁 테이블 정렬 및 필터링에서는 Java SE 6의 새로운 기능을 이용하여 JTable을 정렬하고 필터링을 하는 방법을 살펴보았다. Java SE 6은 JList 정렬과 필터링을 위한 새로운 기능을 제공하지는 않지만 본 팁에서는 J2SE 5.0에서 JList에 대한 유사한 작업을 수행하는 방법을 배우게 될 것이다.

사용자에게 긴 리스트에서 엘리먼트를 필터링하도록 프롬프트하는 데 일반적으로 사용되는 기법으로는 리스트에 JTextField를 표시하는 경우를 들 수 있다. 사용자가 JTextField에 입력을 하면 리스트에 표시된 엘리먼트가 일련의 매칭 엔트리로 단축된다.



JList를 위한 이런 종류의 기능을 구현하려면 일부 텍스트를 토대로 엘리먼트를 필터링하는 모델과 사용자가 필드에 입력할 때 필터링 동작을 트리거하는 텍스트 컴포넌트 등의 두 가지 지원 엘리먼트가 필요하다.

둘 중에서 인풋 필드를 구현하는 쪽이 더 쉬우므로 이를 먼저 살펴보기로 한다. Swing 컴포넌트 세트에서 JTextField를 위한 모델은 Document이다. Document에 대한 인풋을 모니터하려면 모델에 DocumentListener를 첨부한다. 이 리스너에는 이벤트의 인풋, 제거, 변경에 대해 서로 다르게 반응할 수 있도록 해주는 다음의 세 가지가 메소드가 있다.

  • public void insertUpdate(DocumentEvent event)
  • public void removeUpdate(DocumentEvent event)
  • public void changedUpdate(DocumentEvent event)

changeUpdate() 메소드는 모델 내의 속성 변경과 관련이 있는데, 이는 무시해도 무방하다. 다른 두 메소드에 대해서 동일한 필터링 동작이 이루어져야 하기 때문에, 이들은 커스텀 모델에서 생성되어야 할 동일한 메소드를 호출해야 한다. 다음은 필터링 JList에 연결되어야 할 JTextField를 정의한 것이다.

JTextField input = new JTextField();
String lastSearch = "";
DocumentListener listener = new DocumentListener() {
public void insertUpdate(DocumentEvent event) {
Document doc = event.getDocument();
lastSearch = doc.getText(0, doc.getLength());
((FilteringModel)getModel()).filter(lastSearch);
}
public void removeUpdate(DocumentEvent event) {
Document doc = event.getDocument();
lastSearch = doc.getText(0, doc.getLength());
((FilteringModel)getModel()).filter(lastSearch);
}
public void changedUpdate(DocumentEvent event) {
}
};
input.getDocument().addDocumentListener(listener);

JList에 의해 생성되는 JTextField에 사용을 제한하는 대신, 리스너를 주어진 컴포넌트에 연결하는 installJTextField() 메소드와 리스너를 제거하기 위한 언인스톨 메소드가 제공된다. 이 경우, 필터링 리스트 사용자는 기본값을 생성하는 대신 자체의 JTextField를 제공할 수 있게 된다.

public void installJTextField(JTextField input) {
input.getDocument().addDocumentListener(listener);
}

public void unnstallJTextField(JTextField input) {
input.getDocument().removeDocumentListener(listener);
}

다음으로 필터링 모델에서는 DocumentListener에 의해 filter() 메소드가 요구되는데, 이 경우에는 단지 소스 리스트와 필터링된 리스트의 2개 엘리먼트 리스트를 유지하면 된다. AbstractListModel의 도움으로 다음과 같은 메소드를 구현해야 한다.

  • 생성자(constructor)
  • 엘리먼트를 모델에 추가하기 위한 추가 메소드
  • 사이즈를 얻기 위한 getSize()
  • 엘리먼트를 다시 얻기 위한 getElementAt()


생성자는 2개의 List 오브젝트를 생성한다. List 내에 어떤 엘리먼트가 있는지는 문제가 되지 않으므로 다음과 같이 Object 타입의 List로 생성하면 된다.

List list;
List filteredList;

public FilteringModel() {
list = new ArrayList();
filteredList = new ArrayList();
}

모델에 엘리먼트를 추가하는 작업은 엘리먼트를 소스 모델에 추가한 다음 필터 자체에 모델을 통지함으로써 가능하다. 이는 오직 새로운 엘리먼트를 필터링하기 위해서만 최적화될 수 있지만, 당분간 엘리먼트를 추가하는 작업은 전체 리스트 필터링을 위해 호출되는 것과 동일한 filter() 메소드를 호출한다. (DocumentListener를 통한 Document 입력은 전체 리스트를 필터링하기 위한 filter()를 호출한다는 점에 유의할 것.) 따라서 하나의 엘리먼트를 추가하는 경우라도 전체 리스트를 소거하고 마지막 검색 조건에 일치하는 각각의 엘리먼트를 추가하도록 한다(새로운 리스트의 경우, 비어 있을 수 있음).


public void addElement(Object element) {
list.add(element);
filter();
}

리스트 모델의 사이즈는 소스 사이즈가 아니라 필터링된 리스트 사이즈이다.


public int getSize() {
return filteredList.size();
}

사이즈를 get할 때와 마찬가지로, 엘리멘트를 get할 때는 원래의 소스 리스트가 아니라 필터링된 리스트에서 가져오는데, 이는 리스트의 끝을 지나치지 않는 경우에만 가능하다.


public Object getElementAt(int index) {
Object returnValue;
if (index < filteredList.size()) {
returnValue = filteredList.get(index);
} else {
returnValue = null;
}
return returnValue;
}

마지막 filter() 메소드가 대부분의 작업을 제공하는데, 검색 문자열이 결과 세트를 확대 또는 축소하는지 알 수 없기 때문에 필터링된 리스트를 소거하고 소스 리스트에서 인풋 필드에 일치하는 항목을 추가하는 것이 가장 수월한 방법이라 할 수 있겠다. 매칭은 문자열의 시작 부분이나 텍스트의 어느 곳에서도 가능하다. 다음은 후자 검색 방법의 예제이다. 이 경우 "A"를 이용하여 "A"로 시작하거나 대문자 "A"가 포함된 엘리먼트를 찾을 수 있다.


void filter(String search) {
filteredList.clear();
for (Object element: list) {
if (element.toString().indexOf(search, 0) != -1) {
filteredList.add(element);
}
}
fireContentsChanged(this, 0, getSize());
}

여기 검색에서는 대소문자가 구분된다. 검색을 문자열의 시작으로 변경하는 것 외에도 대소문자를 구분하도록 수정할 수 있다.

또한 필터링된 리스트에 엘리먼트를 추가한 후에 결과를 정렬할 수도 있는데, 이 경우 모델의 내용에 관해 알고 있어야 한다. 현재 검색은 toString() 결과를 이용한다(역시 정렬이 가능한 StringComparable 타입의 엘리먼트가 모델에 포함된 것으로 가정하지 않는다).

다음으로 ListModel을 이너 클래스로 하여 완벽한 필터링 JList가 표시된다. 커스텀 ListModel은 텍스트 컴포넌트를 위한 DocumentListener이기도 하다. 필터링이 모델에 로컬라이즈되기 때문에 처음에는 이것이 이상해 보일 수도 있지만 동작을 정의하기에는 최상의 장소인 것으로 여겨진다.


import javax.swing.*;
import javax.swing.text.*;
import javax.swing.event.*;
import java.util.*;

public class FilteringJList extends JList {
private JTextField input;

public FilteringJList() {
FilteringModel model = new FilteringModel();
setModel(new FilteringModel());
}

/**
* Associates filtering document listener to text
* component.
*/

public void installJTextField(JTextField input) {
if (input != null) {
this.input = input;
FilteringModel model = (FilteringModel)getModel();
input.getDocument().addDocumentListener(model);
}
}

/**
* Disassociates filtering document listener from text
* component.
*/

public void uninstallJTextField(JTextField input) {
if (input != null) {
FilteringModel model = (FilteringModel)getModel();
input.getDocument().removeDocumentListener(model);
this.input = null;
}
}

/**
* Doesn't let model change to non-filtering variety
*/

public void setModel(ListModel model) {
if (!(model instanceof FilteringModel)) {
throw new IllegalArgumentException();
} else {
super.setModel(model);
}
}

/**
* Adds item to model of list
*/
public void addElement(Object element) {
((FilteringModel)getModel()).addElement(element);
}

/**
* Manages filtering of list model
*/

private class FilteringModel extends AbstractListModel
implements DocumentListener {
List list;
List filteredList;
String lastFilter = "";

public FilteringModel() {
list = new ArrayList();
filteredList = new ArrayList();
}

public void addElement(Object element) {
list.add(element);
filter(lastFilter);
}

public int getSize() {
return filteredList.size();
}

public Object getElementAt(int index) {
Object returnValue;
if (index < filteredList.size()) {
returnValue = filteredList.get(index);
} else {
returnValue = null;
}
return returnValue;
}

void filter(String search) {
filteredList.clear();
for (Object element: list) {
if (element.toString().indexOf(search, 0) != -1) {
filteredList.add(element);
}
}
fireContentsChanged(this, 0, getSize());
}

// DocumentListener Methods

public void insertUpdate(DocumentEvent event) {
Document doc = event.getDocument();
try {
lastFilter = doc.getText(0, doc.getLength());
filter(lastFilter);
} catch (BadLocationException ble) {
System.err.println("Bad location: " + ble);
}
}

public void removeUpdate(DocumentEvent event) {
Document doc = event.getDocument();
try {
lastFilter = doc.getText(0, doc.getLength());
filter(lastFilter);
} catch (BadLocationException ble) {
System.err.println("Bad location: " + ble);
}
}

public void changedUpdate(DocumentEvent event) {
}
}
}

이 시점에서 테스트 프로그램이 필요한데, 프로그램의 주요 부분은 다음의 6개 행이다. 이 행들은 JList를 생성하여 JScrollPane에 추가한 다음 연결된 인풋 필드와 함께 화면에 추가한다.


FilteringJList list = new FilteringJList();
JScrollPane pane = new JScrollPane(list);
frame.add(pane, BorderLayout.CENTER);
JTextField text = new JTextField();
list.installJTextField(text);
frame.add(text, BorderLayout.NORTH);

대부분의 테스트 프로그램은 엘리먼트를 모델에 추가하는데, 여기서 모델은 12일간의 크리스마스 선물, 산타의 순록들 이름, 런던 지하철 노선, 그리스 알파벳 등으로 구성된다.


import javax.swing.*;
import java.awt.*;
import java.awt.event.*;

public class Filters {
public static void main(String args[]) {
Runnable runner = new Runnable() {
public void run() {
JFrame frame = new JFrame("Filtering List");
frame.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);

FilteringJList list = new FilteringJList();
JScrollPane pane = new JScrollPane(list);
frame.add(pane, BorderLayout.CENTER);
JTextField text = new JTextField();
list.installJTextField(text);
frame.add(text, BorderLayout.NORTH);

String elements[] = {
"Partridge in a pear tree",
"Turtle Doves",
"French Hens",
"Calling Birds",
"Golden Rings",
"Geese-a-laying",
"Swans-a-swimming",
"Maids-a-milking",
"Ladies dancing",
"Lords-a-leaping",
"Pipers piping",
"Drummers drumming",
"Dasher",
"Dancer",
"Prancer",
"Vixen",
"Comet",
"Cupid",
"Donner",
"Blitzen",
"Rudolf",
"Bakerloo",
"Center",
"Circle",
"District",
"East London",
"Hammersmith and City",
"Jubilee",
"Metropolitan",
"Northern",
"Piccadilly Royal",
"Victoria",
"Waterloo and City",
"Alpha",
"Beta",
"Gamma",
"Delta",
"Epsilon",
"Zeta",
"Eta",
"Theta",
"Iota",
"Kapa",
"Lamda",
"Mu",
"Nu",
"Xi",
"Omikron",
"Pi",
"Rho",
"Sigma",
"Tau",
"Upsilon",
"Phi",
"Chi",
"Psi",
"Omega"
};

for (String element: elements) {
list.addElement(element);
}

frame.setSize(250, 150);
frame.setVisible(true);
}
};
EventQueue.invokeLater(runner);
}
}

FilteringJList와 Filters 클래스를 컴파일한 다음 Filters를 실행한다. 필터링 JList가 표시되어야 한다.



필터링 JList 및 연결된 JTextField를 위한 이 접근법은 여러분의 리스트 엘리먼트가 "양호한" toString() 표현을 가지는 경우에만 통용된다. 리스트 엘리먼트가 복잡한 경우에는 필터링 작업을 위해 모델로 패스될 수 있는 Filter 인터페이스를 정의하는 것이 좋다.

이 예제에서 다루어지지 않은 항목이 바로 선택의 관리인데, 기본적으로 모델 내용 변경 시 JList는 선택 사항을 변경하지 않는다. 원하는 동작에 따라, 필터링은 선택된 엘리먼트를 보존하려 할 수도 있고 항상 리스트의 첫 번째 항목으로 리셋할 수도 있다.

기본적인 JList 컴포넌트는 직접적으로 필터링을 지원하지는 않는 대신 유용한 미리 입력(type-ahead) 옵션을 제공한다. 기본값 동작이 마음에 들지 않으면 getNextMatch() 메소드(이 메소드는 J2SE 1.4에 추가되었음)를 오버라이드하면 된다.

J2ME Mobile 2D Graphics 시작하기

개요
본 글에서는 J2ME(Java 2 Platform, Micro Edition) 프로파일과 함께 사용할 수 있는 옵션 패키지인 Scalable 2D Vector Graphics API를 소개하도록 한다. 컴팩트한 확장식 2차원(2D) 그래픽을 렌더링하고 변환하는데 편리하게 사용할 수 있는 이 API는 Java Community Process (JCP)JSR 226으로 명시되어 있다. 우선 API와 전형적인 용례를 간략하게 살펴보고, 이 용례를 코드로 예시해 보도록 한다.
 
개관
JSR 226은 자바가 탑재된 모바일 애플리케이션에서 벡터 기반 2D 그래픽을 렌더링/재생할 수 있게 해준다. 한편, 모바일 업계를 주름잡는 Nokia를 위시한 JSR226의 전문가 그룹은 모두 자사의 장치에 벡터 기반 멀티미디어 컨텐츠를 도입하는 일에 관심을 가지고 있다. 지금 이 글을 쓰고 있는 현 시점에서야 겨우 스펙이 승인되었고, 모두들 레퍼런스가 구현되기를 학수고대하고 있는 상태이다.

모바일 애플리케이션 개발자들에게 있어서 벡터 그래픽은 컴팩트하면서도 확장성이 높다는 두 가지 큰 이점을 가지고 있다. 먼저 아래의 간단한 이미지를 살펴보자.
 
그림 1: 간단한 예제 이미지

GIF 포맷인 경우 이 이미지에는 7,386바이트가 사용되는 반면, 벡터 포맷인 경우에는 사이즈가 693바이트로 전자 경우의 1/10에 불과하다. 그렇다면 이런 축소는 어떻게 가능한 것일까?

GIF 같은 래스터(Raster) 기반 이미지 포맷은 이미지를 구성하는 직사각형 영역의 각 픽셀에 대한 색상 정보를 인코딩하지만 벡터 기반 이미지에서는 픽셀의 색상을 결정하는 드로잉 명령만 포함된다. 이미지의 벡터 표현은 훨씬 더 컴팩트하기 때문에 리소스의 제약을 받는 모바일 장치에 상당히 유리하다.

확장성은 또 하나의 중요한 이점으로, 벡터 이미지는 드로잉 명령어가 해상도의 영향을 받지 않기 때문에 매끄럽게 변환될 수 있다. 그림 2는 앞의 이미지를 축소하고, 뒤집어(flip) 회전시킨 것이다.
 
그림 2: 변환된 예제 이미지

벡터 이미지의 측정 단위는 추상적(arbitrary)이고 상대적이며, 몇 가지 상수 요소로 곱하거나 나누어 이미지를 화면 크기에 맞도록 변환할 수 있다. 즉, 좌표에 간단한 수학적 연산을 적용함으로써 뒤집기, 회전, 확대, 기울이기(skewing) 등을 포함한 거의 무한한 효과를 낼 수 있다. 또한, 애플리케이션 개발자가 벡터 그래픽을 이용할 경우 화면의 해상도가 서로 다른 장치를 지원하기 위해 별도의 이미지 및 아이콘 세트를 생성할 필요가 없다.

벡터 그래픽의 간결성과 확장성은 애니메이션 분야에서 특히 빛을 발한다. 래스터 기반 애니메이션의 경우에는 웹 페이지의 애니메이션 GIF나 MIDP 게임의 애니메이션 스프라이트처럼 애니메이션의 각 프레임마다 수고스럽게 전체 이미지를 포함시켜야 하지만 벡터 기반 애니메이션에는 변화될 특정 이미지 요소와 그 시점을 지시하는 명령어만 포함하면 된다. 벡터 이미지가 래스터 이미지보다 한 등급 작다면, 결국 벡터 애니메이션이 래스터 애니메이션보다 두 등급이 더 작은 것이기 때문에 모바일 장치에서 멀티미디어 컨텐츠를 전송하고 표시하는 데 이상적이다.

특허권이 있는 Macromedia Flash 플레이어를 기반으로 한 벡터 그래픽과 애니메이션이 웹 상에서 인기를 얻었다면, SVG(Scalable Vector Graphics) 파일 포맷은 그 밖의 분야에서 개방 표준에 특허권이 없는 대안으로 부상한 바 있다. 이 포맷은 W3C(World Wide Web Consortium)에 의해 표준화되며, SVG-Tiny는 SVG의 전체 기능 중에서 모바일 장치에 맞게 조정된 일부 기능을 제공한다. JSR 226은 J2ME 벡터 그래픽을 위한 공식 파일 포맷으로 SVG-Tiny 버전 1.1을 채택하고 있다. W3C는 SVG-Tiny를 ‘프로파일’이라고 부르지만 여기서는 J2ME 프로파일과의 혼동을 피하기 위해 ‘포맷’이라는 용어를 사용하고자 한다.

SVG-Tiny 표준은 또한 애니메이션을 지원하는 구현을 가능케 해준다. 이미지 파일 자체에 내장된 명령어는 타이머 이벤트와 사용자 인풋에 응답하여 이미지 요소들의 위치와 속성을 수정할 수 있다.

좀더 복잡한 상호작용을 위해, JSR 226은 SVG 1.2의 풀 DOM(Document Object Model)의 서브세트인 microDOM과 호환이 되는 API를 채택하는데, 자바 기반 프로그램은 이 API를 이용하여 벡터 이미지 및 애니메이션을 즉석에서 생성/수정할 수 있다.

SVG는 public schema로 잘 문서화되고 코드화된 XML(eXtensible Markup Language) 포맷이며, SVG-Tiny 파일은 작기는 하지만 여전히 SVG 파일이므로 당연히 XML을 포함하고 있다. 다음은 그림 1 ‘brave world’ 이미지를 풀 SVG-Tiny로 표현한 것이다.
 
<?xml version="1.0" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1 Tiny//EN" 
    "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11-tiny.dtd">
<svg preserveAspectRatio="xMidYMid meet" 
        viewbox="10 10 130 55" width="150.0" height="75.0">

    <rect x="25" y="25" transform="translate(75 36.375) 
        rotate(15) translate(-70 -35.375)" fill="#00007E" 
        width="100" height="22.75" stroke="#000000" stroke-width=".5"/>

    <rect x="25" y="25" fill="#FF9800" width="108.5" 
        height="22.75" stroke="#000000" stroke-width=".5"/>

    <text x="30" y="40" fill="#00007E" stroke="#FFFFFF" 
        stroke-width=".33" xml:space="preserve">Hello brave world.</text>

</svg>

 
이 문서는 2개의 직사각형과 몇 가지 텍스트를 선언한다. 첫 번째 직사각형은 해당 rect 태그의 transform 속성에 포함된 일련의 변환에 의해 중심 축에서 15도 회전되고, 두 번째 직사각형은 첫 번째 직사각형 위에, 텍스트는 두 번째 직사각형 위에 그려진다.

원본 XML 텍스트는 그다지 컴팩트한 포맷은 아니지만 이 문서는 여전히 압축된 래스터 표현보다도 훨씬 작을 뿐 아니라 육안 판독도 가능하다. XML 텍스트는 압축이 잘 되기 때문에 SVG 문서는 애플리케이션의 JAR 파일에서 상대적으로 작은 공간을 차지한다. JSR 226은 gzip으로 압축된 SVG 문서를 지원하는 구현을 필요로 하며, 확장자 .svgz는 gzip 압축문서를 나타낸다.
 
모바일 2D API
JSR 226은 주로 SVG-Tiny 포맷의 기능들과 밀접하게 동조하는 클래스들의 서브세트인 M2G(Mobile 2D Graphics) API를 정의하는 역할을 수행하는데, 그렇다고 해서 이것을 J2SE의 Graphics2D 클래스가 제공하는 것과 같은 일반 2D 드로잉 툴킷으로 보기는 어렵다. 분명한 것은 M2G API가 SVG 컨텐츠의 재생과 런타임 조작에 초점을 맞추고 있다는 점이다.

M2G는 벡터 이미지 생성 및 렌더링을 위한 하이 레벨 클래스와 벡터 이미지의 XML 컴포넌트를 DOM(Document Object Model) 트리의 일부로 조작하기 위한 로우 레벨 클래스로 구성된다. 벡터 이미지는 ScalableImage의 인스턴스로, 여러분은 이 인스턴스를 해당 클래스의 정적 createImage() 메소드로부터 얻고, ScalableGraphics의 인스턴스를 이용하여 MIDP Graphics 컨텍스트 상에 ScalableImages를 그릴 수 있다. SVGImage는 이벤트 핸들링을 위한 후크(hooks)와 기본적인 DOM 문서에 대한 액세스를 제공하는 ScalableImage의 서브클래스이다.

SVGAnimator 클래스는 애니메이션 SVG 컨텐츠를 렌더링하는 커먼 케이스(common case)를 더 편리하게 해준다. MIDP 애플리케이션의 경우, SVGAnimator는 애니메이션 이벤트에 응답하는 화면 업데이트와 이미지에 대한 프로그램적 수정을 자동으로 처리하는 Canvas 오브젝트를 생성?제어하고, 아울러 애니메이션의 재생을 제어하기 위해 비디오 플레이어와 같은 인터페이스를 제공한다.

이 클래스들은 표 1에 요약한 javax.microedition.m2gorg.w3c.dom.svg 패키지에 정의되어 있으며 JSR 226 specification에서 상세히 다루고 있다. 한편, 표준 org.w3c.domorg.w3c.dom.events 패키지에 정의된 기본 DOM 클래스와 인터페이스는 SVG DOM 클래스에 의해 확장된다.
 
 
Class or Interface
Description
"
javax.microedition.m2g
ScalableGraphics
2D 렌더링의 기본이 되는 클래스
SVGAnimator
이 클래스는 SVGImage의 업데이트와 애니메이션을 대상 사용자 인터페이스 컴포넌트로 자동 렌더링한다.
SVGEventListener
이 인터페이스는 플랫폼별 이벤트를 애플리케이션으로 전송하는 데 사용된다.
SVGImage
이 클래스는 W3C SVG Tiny 1.1 Profile 에 따른 SVG 이미지를 나타낸다.
ExternalResourceHandler
이 인터페이스는 SVG 컨텐츠 로드에 필요한 외부 리소스를 동기적으로 로드하는데 사용된다.
org.w3c.dom.svg
SVGAnimationElement
이 인터페이스는 애니메이션 요소를 나타내며 애니메이션의 타이밍을 제어하는 메소드를 포함한다 .
문서 트리의 SVG 요소를 나타낸다 .
SVGLocatableElement
이 인터페이스는 외형 (shape), 이미지 , 텍스트 등의 그릴 수 있는 SVG 요소를 나타낸다 .
SVGMatrix
이 인터페이스는 번역을 수반한 선형 변환에 해당하는 어파인 변환 (affine transformation) a€“ 에 의해 식별되는 ‘ SVG matrix ’ 데이터 유형을 나타낸다 .
SVGPath
경로 지오메트리를 정의하는 데 사용되는 ‘ SVG path ’ 데이터 유형을 나타낸다 .
SVGPoint
x 및 y 컴포넌트에 의해 식별되는 ‘ SVG point ’ 데이터 유형을 나타낸다 .
SVGRect
최소 X, 최소 Y, 폭 , 높이 값으로 구성되는 ‘ SVG rectangle ’ 데이터 유형을 나타낸다 .
SVGRGBColor
적 , 녹 , 청 컴포넌트로 구성되는 ‘ SVG RGB color ’ 데이터 유형을 나타낸다 .
SVGSVGElement
SVG 문서 트리의 요소를 나타낸다 .
"


M2G 애플리케이션 작성하기
M2G 애플리케이션을 작성하려면 먼저 JSR 226의 실질적인 구현이 필요한데, 현재로서는 스펙만 마무리된 상태이고 이를 구현하는 장치도 없고 공식 레퍼런스 구현을 이용할 수도 없는 형편이다. 여러분은 mpowerplayer developer toolkit에 추가 패키지로 제공되는 한정된 시험판을 다운로드하여 사용할 수 있다.

아울러, SVG-Tiny 컨텐츠가 필요하다. 이 글에서는 위의 ‘brave world’ 이미지를 사용하지만 모빌리티 개발 툴메이커 TinyLine에서 더 많은 SVG-Tiny 예제를 구할 수 있다. 여러분은 또한 Sketsa라 불리는 자바 기반의 상용 애플리케이션으로 직접 컨텐츠를 작성할 수도 있는데, 이 애플리케이션은 일러스트레이션 툴처럼 작동하며 SVG를 파일 포맷으로 사용한다.

SVG 컨텐츠를 화면에 표시하는 방법으로는 여러 가지가 있는데, 이중 가장 간단한 방법은 이미지에 대한 SVGAnimator를 생성하고 그 Canvas를 화면에 표시하는 것이다. 또 다른 방법은 ScalableImage 인스턴스를 생성하고 ScalableGraphics의 인스턴스를 이용하여 이를 자체 Canvas 또는 CustomItem의 Graphics 컨텍스트 상에 드로잉하는 것이다. 이렇게 하면 프레젠테이션을 제어하기가 더 쉽다는 장점이 있지만 애니메이션 또는 사용자 인터랙션의 결과로 인풋 이벤트와 리페인트(repaints)를 처리해야 하는 책임이 따르게 된다.
 
SVG 애니메이터를 이용하여 2D Graphics 표시하기
정적 createAnimator() 메소드로부터 SVGAnimator의 인스턴스를 획득하여 자신만의 SVGImage를 제공할 수 있다. 애니메이터는 동적 컨텐츠를 위한 모든 사용자 인터랙션과 애니메이션을 처리하기 때문에 SVGAnimator를 이용하는 것은 탁월한 선택이 될 수 있다. play(), pause(), stop(), setTimeIncrement() 메소드는 애니메이션의 재생을 프로그램적으로 제어할 수 있게 해주는데, 이 구현을 통해 동적 행동을 지시하기 위한 원시(native) 사용자 인터페이스를 제공할 수도 있다.

일단 SVGAnimator를 획득하면 이를 애플리케이션에 끼워 넣기는 어렵지 않다. getTargetComponent()를 호출하면 화면에 표시할 수 있는 Canvas의 인스턴스를 반환한다. 다음은 관련 예제이다.
 
public void animationTest()
    {
        // create an animator to load the content
        SVGAnimator animator = 
			SVGAnimator.createAnimator( image );
            
        // add our custom event listener
        animator.setEventListener( 
			new CustomEventListener( animator ) );
        
        // get the Canvas for this player; requires a cast
        Canvas canvas = (Canvas) animator.getTargetComponent();
        
        // add a "back" command
        canvas.addCommand( backCommand );
        canvas.setCommandListener( this );
        
        // show it
        Display.getDisplay(this).setCurrent( canvas );
        
        // start it
        animator.play();
    }


 
MIDP 환경에서 getTargetComponent()로부터 반환되는 값은 MIDP Canvas인데, 비(非) MIDP 환경에서는 이 결과가 native windowing 시스템에 적합할 것으로 사료된다(예: AWT 환경에서 Component). 환경에 적합한 선택이 둘 이상인 경우에는 원하는 클래스의 이름을 createAnimator()에 대한 두 번째 아규먼트로 제공하도록 한다.

그림 3은 폼이 화면에 표시된 모습인데, 구현 방식에 따라 그리고 스타일러스 또는 기타 포인팅 디바이스의 사용 여부에 따라 애니메이터는 사용자와 컨텐츠 간의 상호작용을 허용할 수도 있다. ‘brave world’ 예제의 경우, 동적 컨텐츠는 없지만 텍스트는 선택이 가능하다는 점에 유의한다.

 
그림 3: SVGAnimator에 의해 제어되는 Canvas

애니메이터와의 상호작용
‘brave world’ 이미지는 애니메이션이 아니지만, 약간의 프로그래밍으로 상호작용을 추가할 수 있다.

JSR 226의 DOM 지원은 프로그램이 이미지의 구조를 변경할 수 있게 해준다. SVGImage도 다른 XML 문서와 마찬가지로 DOM 오브젝트 트리로 표현되며, M2G API는 친숙한 XML 조작 기법을 사용하여 이미지를 수정할 수 있게 해준다. 트리의 각 노드는 SVGElement이며 인접한 부모 및 자식 노드로 네비게이트하기 위한 메소드를 제공한다. 노드를 수정하려면 자식 노드를 추가/제거하거나, 해당 요소 클래스에 캐스팅하고 그 클래스의 메소드를 호출하여 변경하면 된다.

SVGEventListener 인터페이스는 애플리케이션이 SVGAnimatorCanvas로부터 사용자 인풋 이벤트를 수신할 수 있게 해준다. 다음 예제는 DOM API를 이용하여 사용자의 화살표 키를 통한 인풋에 응답하여 이미지를 확대/축소하고 회전시키는 경우이다.
 
{
    int gameAction = canvas.getGameAction( keyCode );
    switch ( gameAction )
    {
        case Canvas.UP:
            scale( 0.10f );
            break;
        case Canvas.LEFT:
            rotate( -10.0f );
            break;
        case Canvas.DOWN:
            scale( -0.10f );
            break;
        case Canvas.RIGHT:
            rotate( 10 );
            break;
        default:
            // leave unchanged
    }
}

private void rotate( final float delta )
{
    // put ourselves on the animator's thread
    animator.invokeLater( new Runnable()
    {
        public void run()
        {
            // execute the transformation
            Document document = svgImage.getDocument();
            SVGSVGElement root = (SVGSVGElement) 
				document.getDocumentElement();
            root.setCurrentRotate( root.getCurrentRotate() + delta );
        }
    } );
}

private void scale( final float delta )
{
    // put ourselves on the animator's thread
    animator.invokeLater( new Runnable()
    {
        public void run()
        {
            // execute the transformation
            Document document = svgImage.getDocument();
            SVGSVGElement root = (SVGSVGElement) 
				document.getDocumentElement();
            root.setCurrentScale( root.getCurrentScale() + delta );
        }
    } );
}

 
SVGImage.getDocument()는 애니메이터가 렌더링하는 문서에 레퍼런스를 제공한다. 플레이어가 애니메이션을 실행하거나 다른 동적 컨텐츠를 업데이트 중일 수도 있기 때문에, 변경내용을 애니메이션 쓰레드와 동기화할 때 주의하지 않으면 예기치 않은 장애가 발생할 수도 있다. SVGAnimator's invokeAndWait()invokeLater() 메소드는 애니메이션 쓰레드 상에서 코드가 실행되도록 하기 위해 AWT의 경우와 비슷하게 작동한다.
 
2D 컨텐츠 직접 렌더링하기
SVG 이미지의 렌더링 위치와 방식을 철저히 제어하고 싶다면 ScalableGraphics 클래스를 이용하여 Canvas, Layer, 또는 CustomItem 인스턴스의 paint() 메소드로 전달된 Graphics 오브젝트 상에 직접 ScalableImage를 드로잉할 수 있다.

정적 createInstance() 메소드를 인보크하여 ScalableGraphics의 인스턴스를 생성한다. 드로잉 전에 먼저 bindTarget() 메소드를 이용하여 확장 그래픽스 인스턴스를 페인트 메소드로 전달된 그래픽스 오브젝트에 바인딩해야 한다. 작업이 완료된 후에는 releaseTarget()을 호출하는 것을 잊지 않도록 하고, 일단 바인딩이 설정되면 render() 메소드를 이용하여 각각의 이미지를 드로잉하면 된다.

아래의 예제에서는 render()에 대한 각 호출 전에 setViewportHeight()setViewportWidth() 메소드를 호출하여 ‘brave worl’ 이미지를 다양한 크기로 반복해서 드로잉한다.
 
private static class M2GCanvas extends Canvas
{
    // retain a reference the specified image
    ScalableImage scalableImage;
     
    // retain an instance of a scalable graphics
    ScalableGraphics scalableGraphics;
    
    public M2GCanvas( ScalableImage inImage )
    {
        scalableImage = inImage;

        // create the scalable graphics instance
        scalableGraphics = ScalableGraphics.createInstance();
    }

    public void paint( Graphics g )
    {
        // clear the display
        g.setColor( 255, 255, 255 );
        g.fillRect( 0, 0, getWidth(), getHeight() );
        
        // bind our scalable graphics to the given graphics
        scalableGraphics.bindTarget( g );

        // render at fixed position and size
        scalableImage.setViewportWidth( 50 );
        scalableImage.setViewportHeight( 75 );
        scalableGraphics.render( 5, 50, scalableImage );

        // again at different position and size
        scalableImage.setViewportWidth( 100 );
        scalableImage.setViewportHeight( 150 );
        scalableGraphics.render( 80, 5, scalableImage );

        // again at size that varies with the canvas size
        scalableImage.setViewportWidth( getWidth()-20 );
        scalableImage.setViewportHeight( getHeight()-20 );
        scalableGraphics.render( 0, 0, scalableImage );

        // release the graphics context
        scalableGraphics.releaseTarget();
    }
}

 
이 간단한 Canvas 서브클래스는 이미지를 서로 다른 위치와 크기로 세 차례에 걸쳐 표시하는데, 세 번째 이미지는 캔버스의 크기를 꽉 채운 경우이다. 화면 크기를 조절할 수 있는 장치에서는 이미지가 적절하게 확대/축소된다. 그림 4는 캔버스가 표시되었을 때의 결과를 보여주며, 제공된 구현에서는 창의 크기 조절에 따라 큰 쪽의 이미지도 확대/축소된다.
 
그림 4: Canvas에서 ScalableImages 확대/축소하기

렌더링하고 있는 이미지가 동적인 경우에는 사용자와 이미지 간 상호작용을 허용해야 한다. SVGAnimator 없이 렌더링하는 경우에는 사용자의 포인터 이벤트를 탐지, 이미지를 어느 위치에서 클릭하는지 확인하고, SVGImage 상의 dispatchMouseEvent()를 호출하여 스크립트된 행동을 트리거해야 한다. CustomItem의 경우에는 form traversal이 SVGImageactivate()focusOn() 메소드를 이용하여 활성화를 허용하고 이미지 내의 요소들에 대한 네비게이션을 포커스해야 한다.

프로그램은 기존 SVGImage의 DOM을 조작할 수 있을 뿐 아니라 스크래치로부터 이미지를 구축할 수도 있다. SVGImage의 정적 createEmptyImage() 메소드는 비어있는 문서 뼈대를 가지는 이미지를 반ㅇ환하는데, 여기 상에 프로그램적으로 생성되는 외형과 변환(shapes and transformations)을 나타내는 SVGElements를 바로 파퓰레이트할 수 있다. 한편, 이 기법을 ScalableGraphics와 연계하여 사용하면 일반화된 2D 드로잉 툴킷의 효과를 얻을 수는 있지만, 앞에서 지적했듯이 이러한 용도로 활용하는 것을 권장하지는 않는다.
 
결론
J2ME용 Scalable 2D Vector Graphics API는 벡터 기반 이미지 및 애니메이션을 렌더링하고 조작할 수 있게 해주며, 컴팩트하고 확장성이 뛰어난 벡터 이미지는 모바일 애플리케이션에 특히 안성맞춤이다. JSR 226의 Mobile 2D Graphics API는 애플리케이션이 MIDP 및 AWT 툴킷을 이용하여 SVG 컨텐츠를 생성, 표시, 수정할 수 있게 한다.

AFFINETRANSFORM 이해하기




사용자가 유사변환(affine transformation)을 수행할 수 있도록 해주는 java.awt.geom.AffineTransform클래스는 Java 2D 클래스에 속한다. 유사변환은 2차원 이미지의 좌표를 평행선이 평행을 유지하는 것과 같은 방법으로 변환한다. 가령, 사용자가 직사각형으로 시작하더라도, 유사변환을 이용해서 다른 위치에 평행사변형을 생성할 수가 있다. 이때에 라인은 라인을 남기면서 평행관계는 유지되게 된다. 유사변환을 이용하면 변환(translations), 회전(rotations), (플립을 포함한) 크기조정(scaling), 전단(shears)이 가능하다.

AffineTransform의 documentation을 보면,

 
 그러한 좌표변환은 마지막 행에 [0 0 1]을 수반하는 3행3열 매트릭스로 나타낼 수가 있다. 
이 매트릭스는 원시좌표(source coordinates)와 목적좌표(destination coordinates)를 
열벡터(column vector)로 만든 후 다음과 같이 매트릭스를 좌표벡터에 곱해가는 방법으로 
원시좌표(x,y)를 목적좌표인 (x',y')로 변환한다.

   [ x']   [ m00  m01  m02 ]  [ x ]   [ m00x + m01y + m02 ]
   [ y'] = [ m10  m11  m12 ]  [ y ] = [ m10x + m11y + m12 ]
   [ 1 ]   [  0    0    1  ]  [ 1 ]   [         1         ]

이번 테크팁에서는 사용자가 2차원 공간의 유사변환을 표현하기 위해서 3행3열 매트릭스를 이용해야 하는 이유를 설명하면서, 기본변환을 위한 매트릭스를 AffineTransform 클래스에서의 함수호출과 연관시키고자 한다.

이 글에서는 사용자가 AffineChecker라는 다음의 프로그램을 실행시키면서 AffineTransform변환을 행렬의 곱(matrix multiplications)과 비교하게 될 것이다. 이 프로그램에서 Point2D.Double의 3개 인스턴스 변수들을 잘 보아두자. 이 변수들은 (2,3)의 좌표를 갖는 시작점과 각각의 변환으로부터 생긴 점들에 해당한다.

   import java.awt.geom.Point2D;
   import java.awt.geom.AffineTransform;

   public class AffineChecker {
      private static Point2D startingPoint
                             = new Point2D.Double(2,3);
      private static Point2D affineResult
                               = new Point2D.Double();
      private static Point2D matrixResult
                               = new Point2D.Double();

      public static void matrixMultiply(
                               double m00, double m01,
                               double m10, double m11){
        double x = m00 * startingPoint.getX() +
                   m01 * startingPoint.getY();
        double y = m10 * startingPoint.getX() +
                   m11 * startingPoint.getY();
        matrixResult.setLocation(x,y);
      }

      public static void perform(AffineTransform at){
        at.transform(startingPoint,affineResult);
      }

      public static void report(){
        System.out.println("Affine Result = ("
                          + affineResult.getX() + " , "
                          + affineResult.getY() + ")");
        System.out.println("Matrix Result = ("
                          + matrixResult.getX() + " , "
                          + matrixResult.getY() + ")");
        if (affineResult.distance(matrixResult)<.0001){
          System.out.println("    No real difference");
        }
      }
   }

AffineCheckermatrixMultiply() 메소드는 표준 행렬의 곱(standard matrix multiplication)을 보여준다.

   [ m00 m01 ] [ x ]   [ m00x + m01y]
   [ m10 m11 ] [ y ] = [ m10x + m11y]

이 프로그램의 report()메소드는 변환의 결과와 행렬의 곱(matrix multiplication)의 결과값을 출력하고, 결과들이 본질적으로 같다고 볼 수 있을 만큼 충분히 가까운지를 비교한다.

첫번째 테스트로, 시작점에 아무것도 하지말고 비교를 해보자. 이를 위해 다음 프로그램을 돌려본다.

   import java.awt.geom.AffineTransform;

   public class NothingChecker {
      public static void main(String[] args){
        System.out.println(
                       "The result of doing nothing:");
        AffineChecker.perform(
          AffineTransform.getRotateInstance(0));
        AffineChecker.matrixMultiply(1,0,
                                     0,1);
        AffineChecker.report();
      }
   }

NothingChecker는 새로운 회전변환을 생성하고, 스태틱 메소드 호출을 이용해서 회전되기 위해서 각도(degree)를(이 예제의 경우엔 0) 넘겨준다.

   AffineTransform.getRotateInstance(0)

그리고 나서 NothingCheckerAffineCheckerperform() 메소드를 통해 transform()을 리턴된 AffineTransform에 호출한다. 여기서 주목할 만한 것은 점(2,3)을 변환하고 affineResult에 결과점(resulting point)을 저장하기 위해 startingPoint affineResulttransform()메소드에 넣어주고 있다는 것이다. 다음으로 NothingCheckerAffine Result를 2행2열 항등행렬(identity matrix)을 곱한 값과 비교한다.

  [ 1 0 ]
  [ 0 1 ]

NothingChecker을 실행시킬 때, 사용자는 이하의 내용을 보게 되고,

   The result of doing nothing:
   Affine Result = (2.0 , 3.0)
   Matrix Result = (2.0 , 3.0)
       No real difference

사용자는 두 변환의 결과가 모두 점(2,3)이라는 것을 발견하게 된다.

x와 y방향으로의 크기조정은 AffineTransform의 스태틱 메소드인 getScaleInstance()을 이용한다. X방향으로 4, y 방향으로 5, 이렇게 두 요소를 삽입하여 크기조정을 해보자. 여기 크기조정의 방법을 보여주는 행렬의 곱(matrix multiplication)이 있다.

  [ 4 0 ]  [ x ]   [ 4x ]
  [ 0 5 ]  [ y ] = [ 5y ].

다음의 프로그램은 getScaleInstance() 호출을 보여주는데, 사용자는 변환과 행렬의 곱(matrix multiplication)을 비교하기 위해 다음 프로그램을 실행할 수 있다.

   import java.awt.geom.AffineTransform;

   public class Scale4Xand5YChecker {
      public static void main(String[] args) {
        System.out.println(
                       "The results of scaling four " +
                       "times in the x direction " +
                       "and five times in the y:");
        AffineChecker.perform(
          AffineTransform.getScaleInstance(4,5));
        AffineChecker.matrixMultiply(4,0,
                                     0,5);
        AffineChecker.report();
      }
   }

결과는 다음과 같다.

 
   The results of scaling four times in the x direction and five 
   times in the y:
   Affine Result = (8.0 , 15.0)
   Matrix Result = (8.0 , 15.0)
       No real difference

결과값이 또 다시 같은 것을 볼 수 있다.

사용자는 크기조정 계수 중의 하나에 음수를 넣어봄으로써 x와 y축에 대해서 이해할 수가 있는데, 예를 들면,

  [ -1 0 ]  [ x ]   [ -x ]
  [  0 1 ]  [ y ] = [  y ]

여기에서 x방향의 값이 바뀐 것을 볼 수 있다. 밑의 프로그램에서는 위의 값을 넣어 수행하고 그것을 행렬의 곱(matrix multiplication)과 비교하고 있다.

   import java.awt.geom.AffineTransform;

   public class FlipChecker {
      public static void main(String[] args) {
         System.out.println(
                       "The results of flipping over" +
                       "the y-axis:");
       AffineChecker.perform(
          AffineTransform.getScaleInstance(-1,1));
        AffineChecker.matrixMultiply(-1, 0,
                                     0, 1);
        AffineChecker.report();
      }
   }

결과는,

   The results of flipping over the y-axis:
   Affine Result = (-2.0 , 3.0)
   Matrix Result = (-2.0 , 3.0)
       No real difference   

회전을 시키기 위해선 디그리(degree)가 아닌 라디안(radian)인 인수(argument)를 넣어 줘야 한다. 물론 이미 알고 있겠지만, 디그리는 pi를 곱하고 이것을 180디그리로 나눔으로써 라디안으로 쉽게 전환할 수가 있다. 예를 들면 30도는 pi/6과 같고, Pi/6라디안만큼 회전시키기 위해서는 다음과 같은 회전행렬 (rotation matrix)을 곱한다.

  [ cos pi/6  -sin pi/6 ]
  [ sin pi/6   cos pi/6 ]

사용자는 회전행렬(rotation matrices)의 결정요소가 1이라는 것을 생각해 낼 수가 있다. 이하와 같은 방법으로 결과를 체크하자.

   import java.awt.geom.AffineTransform;

   public class RotateThirtyChecker {
      public static void main(String[] args) {
        System.out.println(
            "The results of a thirty degree"
            + " counter clockwise rotation:");
        AffineChecker.perform(
          AffineTransform.getRotateInstance(
                                           Math.PI/6));
        AffineChecker.matrixMultiply(
            Math.cos(Math.PI/6), -Math.sin(Math.PI/6),
            Math.sin(Math.PI/6),  Math.cos(Math.PI/6));
        AffineChecker.report();
      }
   }

RotateThirtyChecker을 실행하면 다음과 같은 결과를 얻을 수 있다.

   The results of a thirty degree counter clockwise 
   rotation:
   Affine Result = 
              (0.23205080756887764 , 3.598076211353316)
   Matrix Result = 
              (0.23205080756887764 , 3.598076211353316)
       No real difference   

전단변환은 변환된 객체의 형태를 바꾼다. 계수 shx는 y좌표의 요소로서 x 방향(direction)의 변화량을, shy는 x좌표의 요소로서 y 방향(direction)의 변화량을 나타내는데, 이것이 바로 직사각형을 평행사변형으로 바꾸는 변환이고 행렬의 형태는 다음과 같다.

[  1  shx ]  [ x ]   [ x + y (shx) ]
[ shy  1  ]  [ y ] = [ y + x (shy) ].

변환을 하고 그것을 행렬의 곱(matrix multiplication)과 비교하는 코드는 다음과 같다.

   import java.awt.geom.AffineTransform;

   public class ShearChecker {
      public static void main(String[] args) {
         System.out.println(
            "The results of shearing:");
          AffineChecker.perform(
            AffineTransform.getShearInstance(5,6));
          AffineChecker.matrixMultiply(1, 5,
                                       6, 1);
          AffineChecker.report();
      }
   }

ShearChecker을 실행하면,

   
   The results of shearing:
   Affine Result = (17.0 , 15.0)
   Matrix Result = (17.0 , 15.0)
       No real difference

이제 남아있는 것은 고정량에 의해 형태를 옮기는 가장 쉬운 변환이다. x방향의 tx와 y 방향의 ty를 움직이기 위해 다음 덧셈을 수행하자.

  [ tx ]   [ x ]   [ tx + x ]
  [ ty ] + [ y ] = [ ty + y ].

하지만 문제는 지금까지 모든 변환을 2행2열 매트릭스의 곱셈으로 표현했다는 것이다. 때문에 위의 매트릭스를 이제까지의 아이디어에 충족하도록 만드는 한가지 방법은 모든 변환들을 다음과 같이 적는 것이다.

  [ m00 m01 ] [ x ]   [ tx ]   [ m00x + m01y + tx ]
  [ m10 m11 ] [ y ] + [ ty ] = [ m10x + m11y + ty ]

여기에서 다음과 같은 3번째 행을 추가 시키면 사용자는 좌측의 내용을 좀 더 간단하게 적을 수 있을 것이다.

  [ m00  m01  tx ]  [ x ]   [ m00x + m01y + tx ]
  [ m10  m11  ty ]  [ y ] = [ m10x + m11y + ty ]
  [  0    0    1 ]  [ 1 ]   [         1        ]

이상의 모든 구성요소변환(building block transformations)은 6개의 계수 m00, m01, m10, m11, tx, and ty, 로 환산하여 쓰여질 수 있다. (순서에 주의하자.) 이것은 왼쪽 위의 2행2열 매트릭스를 적용해서 이동을 수행한 것에 해당한다. 다음의 메소드를 AffineChecker에 추가하자.

   private static void translate(double tx, double ty){
        matrixResult.setLocation(
                             matrixResult.getX() + tx,
                             matrixResult.getY() + ty);
   }

   public static void matrixTransform(
                                double m00, double m01,
                                double m10, double m11,
                                double tx,  double ty){
      matrixMultiply(m00,m01,m10,m11);
      translate(tx,ty);
   }

자, 이제 matrixTransform()를 사용하면 어떤 변환이라도 호출할 수가 있다. 가령, ShearChecker에서 matrixMultiply(1,5,6,1) 호출을 새로운 메소드인 matrixTransform(1,5,6,1,0,0) 호출로 대체할 수가 있다는 것이다. 뿐만 아니라 다음과 같은 방법으로 matrixTransform() 메소드를 호출할 수도 있다.

   import java.awt.geom.AffineTransform;

   public class TranslationChecker {
      public static void main(String[] args) {
         System.out.println("The results of translating " +
                           "by (-2,5):");
        AffineChecker.perform(
          AffineTransform.getTranslateInstance(-2,5));
        AffineChecker.matrixTransform(1,0,0,1,-2,5);
        AffineChecker.report();
      }
   }

TranslationChecker을 실행하면 다음과 같은 결과를 얻게 된다.(위에서 설명한 2개의 함수가 AffineChecker에 추가되었는지 확인하자.)

   The results of translating by (-2,5):
   Affine Result = (0.0 , 8.0)
   Matrix Result = (0.0 , 8.0)
       No real difference

이러한 기본적인 연산을 결합해서 AffineTransform클래스의 간편한 메소드안에 포착되는 복잡한 결과값을 얻어낼 수 있다.

AffineTransform에 대한 더 자세한 정보는 자바튜토리얼 "Transforming Shapes, Text, and Images"에서 찾을 수 있다.

SOCKETCHANNELS을 이용해서 작업하기




Web Services API나 혹은 높은 수준의 API까지 다뤄야 하는 네트워킹 태스크를 해야 할 일이 생긴다면, 소켓을 이용해서 태스크를 적은 비용으로 좀 더 간단하게 완수할 수 있는 방법을 찾아보자. 이번 Tech Tip에서는 네트워크화 된 간단한 애플리케이션을 생성하기 위해 java.nio packageSocketChannelServerSocketChannel 클래스를 사용하게 될 것이다.

사용자가 특정 웹 페이지를 보기 위해 브라우저를 띄울 때, 페이지의 세부정보는 감춰지게 된다. 브라우저를 열고 다음을 입력하자.

http://developer.java.sun.com/developer/JDCTechTips/

결과적으로, 이 엔트리는 사용자를 웹 사이트의 80포트에 연결시켜주며, 보고자 하는 페이지의 이름을 포함하는 특정(particular) HTTP 요구(request)도 보내게 된다

자 이제 SocketChannel을 이용해서 다음의 액션을 명시적으로 실행해보자. TechTipReader프로그램은 페이지를 검색하기 위해서 SocketChannel를 사용한다. 이 프로그램의 getTips() 메소드를 주목해서 보면, 먼저 SocketChannel을 실행하고 난 후, 그것을 developer.java.sun.com의 80포트에 연결하기 위해서 인스턴스를 사용하는 것을 알 수 있다. 그리고 나서 JDCTechTips 페이지를 검색하기 위해 표준(standard) HTTP "GET"요구(request)를 보낸다.

서버로부터의 응답이 standard out에 출력된다.

   import java.nio.channels.SocketChannel;
   import java.nio.charset.Charset;
   import java.nio.ByteBuffer;
   import java.net.InetSocketAddress;
   import java.io.IOException;

   public class TechTipReader {

      private Charset charset = 
        Charset.forName("UTF-8");
      private SocketChannel channel;

      public void getTips() {
        try {
          connect();
          sendRequest();
          readResponse();
        } catch (IOException e) {
          e.printStackTrace();
        } finally {
          if (channel != null) {
            try {
              channel.close();
            } catch (IOException e) {}
          }
       }
      }

      private void connect() throws IOException {
         InetSocketAddress socketAddress =
           new InetSocketAddress(
             "developer.java.sun.com", 80);
         channel = SocketChannel.open(socketAddress);
       }

      private void sendRequest() throws IOException {
          channel.write(charset.encode("GET "
                            + "/developer/JDCTechTips/"
                            + "\r\n\r\n"));
        }

      private void readResponse() throws IOException {
        ByteBuffer buffer = ByteBuffer.allocate(1024);
        while ((channel.read(buffer)) != -1) {
          buffer.flip();
          System.out.println(charset.decode(buffer));
          buffer.clear();
        }
      }

      public static void main(String[] args) {
        new TechTipReader().getTips();
      }
   }

TechTipReader 프로그램을 자세히 들여다보면, connect() 메소드가 InetSocketAddress를 생성할 때, 도메인과 표준(standard) HTTP 80포트를 나타내는 스트링, 이렇게 두 개의 매개변수(parameters)를 받고 있는 것을 발견하게 될 것이다. 그리고 스태틱 메소드인 SocketChannel.open()을 호출하면 InetSocketAddress를 매개변수(parameter)로 갖는데, 이 호출은 다음 2개의 과정에 상당한다.

   channel = SocketChannel.open();
   channel.connect(socketAddress);

sendRequest() 메소드는 첫번째 라인에 "GET /developer/JDCTechTips/", 두번째와 세번째 라인은 비어있는3줄짜리 요구(request)를 보낸다. channel.write() 호출은 ByteBuffer를 인수(argument)로 갖기 때문에 사용자는 스트링을 변환해야 한다. 사실 SocketChannels을 이용해서 작업할 때, ByteBuffers를 변환하는 데에 대부분의 시간을 할애하게 된다. 변환하는 방법에는 여러 가지가 있을 수 있지만 이 예제에서는 java.nio.charset.Charset클래스의 encode()메소드를 사용한다.

readResponse()메소드는 SocketChannel로 리턴되는 응답을 처리하는 역할을 하며, SocketChannel을 통해서 ByteBuffer를 읽어들인다. 전송되는 파일의 끝에 도달하지 않는 한, readResponse()메소드는 버퍼를 읽기상태에서 쓰기준비 상태로 뒤집는다. ByteBuffer의 내용은 charset.decode()메소드에 의해 스트링으로 변환되고, 이 스트링은 standard out으로 보내진다.

TechTipReader 프로그램을 실행할 때, 디스플레이 된 페이지의 HTML을 봐야 한다.

자 이제 두 번째 예제를 보자. 클라이언트와 서버를 동반한 이 예제에서는 2개의 숫자가 더해진다. 클라이언트가 더해질 2개의 숫자를 서버에 보내면 서버는 덧셈을 수행하고 합계를 낸다. 이 예는 SocketChannel API을 기반으로 하지만, 웹서비스나 RMI 혹은 서블릿을 이용하는 다른 솔루션도 가능하다.

여기에서 ByteBuffer는 2개의 int값을 갖는데 이들은 사용자가 ByteBuffer을 8byte로 제한하고, ByteBuffer에 대한 뷰(view)의 역할을 하는 IntBuffer을 생성하도록 한다.

  private ByteBuffer buffer = ByteBuffer.allocate(8);
  private IntBuffer intBuffer = buffer.asIntBuffer();

SumClient라는 다음의 프로그램은 예제의 클라이언트부분인데 이를 실행하기 위해서는 이 글의 뒷부분에서 보게 될 예제의 서버부분(SumServer)을 먼저 시작해야 한다.

   import java.nio.channels.SocketChannel;
   import java.nio.ByteBuffer;
   import java.nio.IntBuffer;
   import java.io.IOException;
   import java.net.InetSocketAddress;

   public class SumClient {

      private SocketChannel channel;
      private ByteBuffer buffer = ByteBuffer.allocate(8);
      private IntBuffer intBuffer = buffer.asIntBuffer();

      public void getSum(int i, int j) {
        try {
          channel = connect();
          sendSumRequest(i, j);
          receiveResponse();
        } catch (IOException e) {
          // add exception handling code here
          e.printStackTrace();
        } finally {
          if (channel != null) {
            try {
              channel.close();
            } catch (IOException e) {
              // add exception handling code here
              e.printStackTrace();
            }
          }
        }
      }

      private SocketChannel connect() 
                                   throws IOException {
        InetSocketAddress socketAddress =
          new InetSocketAddress("localhost", 9099);
        return SocketChannel.open(socketAddress);
      }

      private void sendSumRequest(int i, int j)
                                   throws IOException {
        buffer.clear();
        intBuffer.put(0, i);
        intBuffer.put(1, j);
        channel.write(buffer);
        System.out.println("Sent request for sum of "
                           + i + " and " + j + "...");
      }

      private void receiveResponse() 
                                   throws IOException {
        buffer.clear();
        channel.read(buffer);
        System.out.println(
                       "Received response that sum is "
                       + intBuffer.get(0) + ".");
      }

      public static void main(String[] args) {
        new SumClient().getSum(14, 23);
      }
   }

SumClientgetSum()메소드는 SocketChannel을 미리 예정된 주소로 연결한다. connect()메소드는 TechTipReader 예제에서 보았던 것과 본질적으로 동일하다. connect() 메소드를 호출하면, getSum() 메소드는 2개의 int값(14, 23)을 차례대로 sendSumRequest() 메소드로 넘기는데, 이 때 sendSumRequest()ByteBuffer의 내용을 SocketChannel에 입력하는 역할을 한다. 그러면 receiveResponse() 메소드는 리턴된 메소드의 내용을 가지고 와서 그것을 standard out에 출력하게 된다.

사용자가 SumClient를 실행시키면, 다음 사항이 화면에 표시된다. (SumServer를 먼저 실행해야 하는 것을 잊지 말자.)

   Sent request for sum of 14 and 23...
   Received response that sum is 37.

SumClient를 자세히 보면, sendSumRequest()가 같은 ByteBuffer의 2개의 다른 뷰(view)를 사용하는 것을 알 수 있다. 먼저, buffer.clear()는 사실상 버퍼를 비우고 ByteBufferintBuffer 핸들의 사용으로 인해 2개의 int값을 갖는 버퍼로 간주된다. sendSumRequest()의 첫번째 파라미터로 넘겨진 int값은 intBuffer.put(0,i)을 이용하여 첫번째 슬롯에 놓여지고, 두번째 파라미터 또한 유사한 방법으로 두번째 슬롯에 놓여진다. SocketChannelwrite()메소드는 ByteBuffer를 갖기 때문에 버퍼의 첫번째 뷰(view)가 필요하다.

receiveResponse()메소드는 sendSumRequest()와 같다(parallel). 버퍼의 내용이 또다시 비워지고 SocketChannel의 내용은 버퍼의 ByteBuffer 뷰(view)로 해석된다. IntBuffer는 첫번째 위치에서 int값을 도출하기 위해 사용된다.

솔루션의 서버부분인 SumServer로 넘어가기 전에, SocketChannel 객체의 read() write() 메소드를 호출하는 장소에 추가적인 안전장치를 넣길 원할 수도 있다. 이 메소드들은 읽히거나 혹은 입력되는 바이트의 수와 함께 long값을 리턴하는데, 이번의 경우에는 8바이트라고 생각하면 된다. sendSumRequest()의 다음 라인을 찾아보고:

   channel.write(buffer);

입력되는 버퍼의 사이즈를 확인하는 다음의 내용으로 바꾸자.

   if (channel.write(buffer)!= 8){
       throw new IOException("Expected 8 bytes.");
}

receiveResponse()메소드의 channel.read() 호출주변에 비슷한 가드를 넣자.

이제 SumServer를 보자. 이 예제를 실행하기 위해서, 사용자는 동의한 위치에 착신요구(incomingrequest)를 처리하게 될 서버를 셋업해야 한다. 밑의 openChannel() 메소드에서는 9099포트가 사용되었다. ServerSocketChannel의 static method인 open()이 호출되면, 그 다음 라인은 명시된 포트에 소켓을 묶는다. 그리고 channel.isOpen() 메소드가 트루(true)를 리턴하는대로 확인메시지가 standard out으로 보내진다.

   import java.nio.ByteBuffer;
   import java.nio.IntBuffer;
   import java.nio.channels.ServerSocketChannel;
   import java.nio.channels.SocketChannel;
   import java.io.IOException;
   import java.net.InetSocketAddress;

   public class SumServer {

      ByteBuffer buffer = ByteBuffer.allocate(8);
      IntBuffer intBuffer = buffer.asIntBuffer();
      ServerSocketChannel channel = null;
      SocketChannel sc = null;

      public void start() {
        try {
          openChannel();
          waitForConnection();
        } catch (IOException e) {
          e.printStackTrace();
        }
      }

      private void openChannel() throws IOException {
        channel = ServerSocketChannel.open();
        channel.socket().bind(
                          new InetSocketAddress(9099));
        while (!channel.isOpen()) {
        }
        System.out.println("Channel is open...");
      }

      private void waitForConnection() 
      throws IOException {
        while (true) {
          sc = channel.accept();
          if (sc != null) {
            System.out.println(
                           "A connection is added...");
            processRequest();
            sc.close();
          }
        }
      }

      private void processRequest() throws IOException {
        buffer.clear();
        sc.read(buffer);
        int i = intBuffer.get(0);
        int j = intBuffer.get(1);
        buffer.flip();
        System.out.println("Received request to add "
                           + i + "  and " + j);
        buffer.clear();
        intBuffer.put(0, i + j);
        sc.write(buffer);
        System.out.println("Returned sum of " +
                           intBuffer.get(0) + ".");
      }

      public static void main(String[] args) {
        new SumServer().start();
      }

   }

사용자가 SumServer를 시작하면, 다음 메시지가 나타난다.

   Channel is open...

   Sent request for sum of 14 and 23...
   Received response that sum is 37.  

SumClient를 실행시키면, SumServer는 이하의 내용을 화면에 표시한다.

   
   A connection is added...
   Received request to add 14  and 23
   Returned sum of 37. 

waitForConnection() 메소드에서는 ServerSocketChannel에 접속하려는 요구가 도착할 때까지 애플리케이션이 주기를 갖고 반복한다. 이 요구가 충족되면, channel.accept()SocketChannel을 리턴하기 때문에 변수 sc는 더 이상 null이 아니다. 착신요구(incoming request)가 처리되면 SocketChannel는 종료되고, 서버는 다시 SumServer를 시작해서 다른 접속요청을 대기한다. 이 간단한 예제에서 요구(requests)들은 한번에 완전히 처리된다는 것을 상기하자. 동시다발적인 요구를 수행할 수 있는 좀 더 심화된 서버를 위해서는 다른 구조가 필요하지만, 이것은 이번 테크팁이 커버하는 범위를 벗어나기 때문에 다루지 않겠다.

processRequest() 메소드는 SumClient의 대응 메소드(corresponding methods)와 매우 흡사하다. 버퍼가 지워지면 채널이 버퍼로 읽혀지고 IntBuffer 뷰(view)는 버퍼에서 파생된 2개의 int값을 검색하는데 사용된다. 사용 후 버퍼가 제거되고 합계는 IntBuffer 뷰(view)의 사용 중에 로드되는데, 그리고 나서 버퍼는 SocketChannel로 다시 쓰여지게 된다. SocketChannel관한 자세한 정보는 테크니컬 아티클인 "New I/0 Functionality for Java 2 Standard Edition 1.4"을 참고한다.

클래스의 UNLOADING 과 RELOADING




지난 2003/7/22 Tech Tip인 "Compiling Source Directly From a Program"에서는 자바 프로그램으로부터 소스 파일을 직접적으로 컴파일하는 방법에 대해 설명하며, 클래스의 이름을 짓고 소스를 제공할 수 있는 간단한 편집기를 소개했다. 이 접근방법에는 별 문제가 없지만,여기서 제공된 클래스는 프로그램의 실행 중에 사용자가 컴파일하고 로드했던 클래스가 아니라는 것을 알아야 한다. 만약 이미 클래스를 컴파일하고 로드한 상태라면, 지난 Tech Tip 에서 소개했던 접근방법을 이용해서 클래스를 편집하고 리컴파일하는 것은 불가능하다. 이번 Tech Tip 에서는 underlying class loader 에 관해서 이야기 하고자 한다. 시스템 클래스 로더를 통해서 로드된 모든 클래스는 절대로 unload될 수 없기 때문에 프로그램 상에서 클래스의 소스를 재편집한 후, 리컴파일(recompile)하고 재생하고자 한다면, 다른 클래스 로더(class loader)를 사용해야 한다. 이 글에서는 이미 컴파일된 클래스(또는 어떤 클래스라도)를 로드하게끔 해주는 커스텀 클래스 로더의 생성법을 알아보고, 클래스를 컴파일하고, 이 컴파일된 클래스가 로드되었을 때, 기존의 클래스를 버리는 방법에 대해서 배우게 될 것이다.

모든 클래스의 로딩을 책임지는 것은 java.lang 패키지 내에 있는 ClassLoader클래스의 한 인스턴스이다. 시스템 클래스의 경우에는 ClassLoadergetSystemClassLoader메소드를 통해서 클래스 로더를 사용할 수 있고, 사용자 클래스의 경우에는 이미 클래스가 로드되어 있는 상태라면, Class 클래스의 getClassLoader 메소드를 통해 ClassLoader에게 요청할 수가 있다.

지난 Tech Tip의 RunIt 프로그램에서는 클래스의 인스턴스를 만들어 내는 클래스 데이터를 로드하기 위해 forName 메소드를 사용하였다. 하지만 forName 메소드가 아닌 다른 클래스 로더를 사용하고자 한다면, ClassLoaderloadClass 메소드를 사용할 수 있다.

다시 말해서,

   String className = ...;
   Class aClass = Class.forName(className);   

위의 코드 대신에,
다음과 같이 코딩한다.

   String className = ...;
   Class aClass = loader.loadClass(className);   

위의 두 코드가 같은 클래스 로더를 통해 로딩되었을 때에는 기능적으로 동일하지만, 첫번째 블럭은 코드가 위치해 있는 로더를 통해서 클래스를 로드하는 반면, 두번째 블럭은 로더 변수가 명시한 클래스 로더를 통해 클래스를 로드하게 된다. 이 두 경우 모두, 클래스의 인스턴스를 생성하기 위해서는 newInstance와 같은 메소드를 호출해야 한다.

호출하는 클래스의 클래스 로더가 변하지 않는다는 가정하에, Class.forName메소드를 반복호출하면 같은 클래스를 로드하게 된다. 마찬가지로 loader.loadClass를 반복적으로 호출해도 같은 클래스를 로드한다. 하지만 두번째 블럭은 클래스를 리로드할 수 있게 한다. 이를 위해서 새로운 ClassLoader를 생성한다.

   String className = ...;
   // create new loader instance
   ClassLoader loader = ...; 
   Class aClass = loader.loadClass(className);

위의 코드 블럭에서는 loadClass의 호출들 사이에 새로운 로더가 만들어진다. 만약 호출 중에 className 의 클래스 정의가 바뀐다면 두번째 호출에서는 클래스의 새로운 버전이 로드된다.

이 메카니즘을 사용하기 위해 RunIt 프로그램을 변경하면, 프로그램을 컴파일, 실행한 후에 소스를 편집하는 것이 가능하고, 이 때 출력값은 이전의 소스가 아닌 새로운 소스에 부합하는 값이다.

이제 남은 것은 어디에서 추상 클래스인 ClassLoader를 받느냐 하는 것이다. permission support가 추가된 java.securitySecureClassLoaderjava.netURLClassLoader는 미리 정의된 로더들이다. 이렇게 미리 정의된 두개의 로더 중에서 URLClassLoader 만이 그것의 생성자(constructor) 또는 static newInstance 메소드를 통해서 public construction을 지원하게 된다. URLClassLoader 에 관한 자세한 정보를 원하면 documentation을 참고한다.

URLClassLoader를 생성하는 것은 URL객체들의 배열의 생성을 수반한다. 이 URL객체들은 커스텀 클래스 로더가 클래스를 찾기 위해 사용하는 장소의 역할을 한다. 배열의 요소를 지정할 때에는 CLASSPATH 환경변수의 패스 요소(path elements)를 지정했던 것과 비슷한 방법을 써야 한다. (이때 패스 요소는 윈도우에서는 a ; , 유닉스에서는 a : 로 분리된다.) URL 배열의 개개의 요소는 지역적으로 혹은 원거리 호스트에 위치시킬 수 있다. "/"으로 끝나는 모든 것들은 디렉토리로 추정할 수 있으며, 이 외의 모든 것들은 JAR 파일로 추정된다.

가령, 현재 디렉토리에서만 찾아지는 디폴트 클래스패스(classpath)와 같은 역할을 하는 ClassLoader를 만들고 싶다면, 다음과 같이 코딩하면 된다.

   File file = new File(".");
   ClassLoader loader = new URLClassLoader(
     new URL[] {file.toURL()}
   );

이 코딩의 첫번째 라인은 현재 디렉토리를 참고해서 File 객체를 만들고, 두번째 라인은 URLClassLoader 생성자를 호출하고 있다. 이렇게 넘겨진 생성자는 URL 객체(File에 대한URL)의 배열이 생성자의 인수로 넘겨진다.

RunIt프로그램에 다음의 코드를 포함하여 리컴파일하고 실행시키면, RunIt프로그램은 이를 실행하고 리로드하는 중에 로드된 클래스를 버릴 것이다. 지난 Tech Tip에서 소개했던 RunIt프로그램과는 다르게 밑의 코드는 메인메소드를 불러내는 클래스의 인스턴스를 생성하지 않는다. 왜냐하면 메인메소드가 static이기 때문에 클래스의 인스턴스를 만들 필요가 없기 때문이다.

   // Create new class loader 
   // with current dir as CLASSPATH
   File file = new File(".");
   ClassLoader loader = new URLClassLoader(
     new URL[] {file.toURL()}
   );
   // load class through new loader
   Class aClass = loader.loadClass(className.getText());
   // run it
   Object objectParameters[] = {new String[]{}};
   Class classParameters[] =
     {objectParameters[0].getClass()};
   Method theMethod = aClass.getDeclaredMethod(
     "main", classParameters);
   // Static method, no instance needed
   theMethod.invoke(null, objectParameters);

다음은 RunItReload라고 새롭게 이름붙인 코드이다.

   import java.awt.*;
   import java.awt.event.*;
   import javax.swing.*;
   import java.io.*;
   import java.net.*;
   import java.lang.reflect.*;

   public class RunItReload extends JFrame {
     JPanel contentPane;
     JScrollPane jScrollPane1 = new JScrollPane();
     JTextArea source = new JTextArea();
     JPanel jPanel1 = new JPanel();
     JLabel classNameLabel = new JLabel("Class Name");
     GridLayout gridLayout1 = new GridLayout(2,1);
     JTextField className = new JTextField();
     JButton compile = new JButton("Go");
     Font boldFont = new java.awt.Font(
                                  "SansSerif", 1, 11);

     public RunItReload() {
       super("Editor");
       setDefaultCloseOperation(EXIT_ON_CLOSE);
       contentPane = (JPanel) this.getContentPane();
       this.setSize(400, 300);
       classNameLabel.setFont(boldFont);
       jPanel1.setLayout(gridLayout1);
       compile.setFont(boldFont);
       compile.setForeground(Color.black);
       compile.addActionListener(new ActionListener() {
         public void actionPerformed(ActionEvent e) {
           try {
             doCompile();
           } catch (Exception ex) {
             System.err.println(
                   "Error during save/compile: " + ex);
             ex.printStackTrace();
           }
         }
       });
       contentPane.add(
                    jScrollPane1, BorderLayout.CENTER);
       contentPane.add(jPanel1, BorderLayout.NORTH);
       jPanel1.add(classNameLabel);
       jPanel1.add(className);
       jScrollPane1.getViewport().add(source);
       contentPane.add(compile, BorderLayout.SOUTH);
     }
     public static void main(String[] args) {
       Frame frame = new RunItReload();
       // Center screen
       Dimension screenSize =
         Toolkit.getDefaultToolkit().getScreenSize();
       Dimension frameSize = frame.getSize();
       if (frameSize.height > screenSize.height) {
         frameSize.height = screenSize.height;
       }
       if (frameSize.width > screenSize.width) {
         frameSize.width = screenSize.width;
       }
       frame.setLocation(
         (screenSize.width - frameSize.width) / 2,
         (screenSize.height - frameSize.height) / 2);
       frame.show();
     }
     private void doCompile() throws Exception {
       // write source to file
       String sourceFile = className.getText() + ".java";
       FileWriter fw = new FileWriter(sourceFile);
       fw.write(source.getText());
       fw.close();
       // compile it
       int compileReturnCode =
         com.sun.tools.javac.Main.compile(
             new String[] {sourceFile});
       if (compileReturnCode == 0) {
         // Create new class loader 
         // with current dir as CLASSPATH
         File file = new File(".");
         ClassLoader loader = 
         new URLClassLoader(new URL[] {file.toURL()});
         // load class through new loader
         Class aClass = loader.loadClass(
                                  className.getText());
         // run it
         Object objectParameters[] = {new String[]{}};
         Class classParameters[] =
                     {objectParameters[0].getClass()};
         Method theMethod = aClass.getDeclaredMethod(
                              "main", classParameters);
         // Static method, no instance needed
         theMethod.invoke(null, objectParameters);
       }
     }
   }

이 프로그램을 컴파일하고 실행할 때는 지난 Tech Tip 의 RunIt 프로그램에서 했던 것과는 약간 다른 방법을 써야 한다. 왜냐하면 커스텀 클래스 로더 는 현재 디렉토리를 리로드 가능한 클래스들의 출처가 되는 장소로 사용하고 있기 때문에 사용자가 같은 클래스패스로부터 실제 RunItReload 클래스를 로드할 수가 없다. 그렇지 않으면, 시스템클래스 로더는 같은 위치로부터 온 클래스 로더와 컴파일된 클래스를 로드하게 될 것이다. 따라서 사용자는 컴파일러가 RunItReload를 위해서 컴파일된 클래스를 다른 위치로 보낼 수 있도록 해줘야 한다. 또한 "."을 포함하지 않은 클래스패스의 지점에서 프로그램을 실행시켜야 한다. 클래스패스를 컴파일하기 위해서는 tools.jar 파일을 포함시켜야 함을 잊지 말자. 밑의 명령문은 RunItReload 의 컴파일된 클래스 파일들을 XYZ 서브디렉토리로 보내고 있다. 여기서 subdirectory name은 마음대로 지정해 줘도 상관없다. (여기서는 커맨드 라인이 여러줄로 보이지만 실제로는 한줄에 기입되도록 한다.)

윈도우에서는,

    mkdir XYZ
    javac -d XYZ -classpath 
     c:\j2sdk1.4.2\lib\tools.jar RunItReload.java

유닉스에서는,

    mkdir XYZ
    javac -d XYZ -classpath 
     /homedir/jdk14/j2sdk1.4.2/lib/tools.jar 
     RunItReload.java

homedir을 실제 홈 디렉토리로 대체하라.

이 때 시스템이 지정된 패스를 찾을 수 없다는 에러가 뜨면, 컴파일 전에 XYZ 디렉토리를 생성했었는지 확인하라.

방금 전에 했던 것처럼 런타임 클래스패스에 tools.jar파일을 포함시키고 실제 RunItReload 프로그램을 위한 XYZ 디렉토리 또한 포함시켜라. 프로그램을 실행하기 위해서는, 다음과 같은 커맨드를 첨부한다. (다시, 여기서는 커맨드 라인이 여러줄로 보이지만 실제로는 한줄에 기입되도록 한다.)

윈도우에서는,

    java -classpath 
     c:\j2sdk1.4.2\lib\tools.jar;XYZ RunItReload

유닉스에서는,

   java -classpath 
      /homedir/jdk14/j2sdk1.4.2/lib/tools.jar:
      XYZ RunItReload

XYZ 디렉토리는 이전의 javac 과정으로부터 영향을 받는다. 따라서 컴파일하기 위한 타겟 디렉토리(-d뒤에 명시된 디렉토리)는 반드시 런타임 클래스패스와 매치되어야 한다.

이 프로그램은 실행되면서 GUI를 디스플레이하기 때문에 다음과 같은 사항을 할 수가 있다.

  1. JTextField안에 클래스를 컴파일하고자 한다면, Sample2 와 같은 클래스의 이름을 입력한다.
  2. JTextArea 에 소스코드를 입력한다. 여기 Sample2의 소스코드를보자.
        public class Sample2 {
          public static void main(String args[]) {
            System.out.println(new java.util.Date());
            // System.out.println("Hello, World!");
          }
        }
    
  3. Go 버튼을 클릭한다.

Sample2

출력값은 콘솔창으로 보내진다. 가령 Sample2는 다음과 같은 출력값을 산출한다.

  Tue Aug 19 11:25:16 PDT 2003

날짜를 프린트하는 라인을 주석에 달고, "Hello World"를 프린트하는 라인의 주석을 없앤다. 그리고 Go버튼을 클릭하면 콘솔창에 다음을 보게 된다.

  Hello, World!

이전에 로드된 클래스를 unload한 새로운 클래스 로더가 생성되었기 때문에 여러분은 다른 라인이 디스플레이되는 것을 보게 되는 것이다.

VARIABLE CONTENT로 메세지 포맷하기



국제화(Internalization)는 다양한 언어와 지역에서 애플리케이션이 작동할 수 있도록 디자인하는 프로세스이다. 이것은 text label을 다른 언어로 번역하는 것 뿐만 아니라 날짜나 시간과 같은 정보를 특정지역의 형식에 맞게 표시하는 것을 뜻한다.

text label과 메시지를 국제화 하는 첫번째 과정은 모든 정보를 리소스 번들로 옮기는 것이다. 이 때 각각의 인용된 스트링을 사용자가 볼 수 있게 하기 위해서는, 우선 리소스 번들에 엔트리를 만들고, text label과 메시지가 사용자가 위치한 지역에 따라 다이내믹하게 변하도록 코드를 바꿔줘야 한다. 이것을 제대로 했을 때, 미국에 있는 사용자는 Help를, 스페인에 있는 사용자는 Ayuda를 도움 메뉴를 나타내는 라벨로 이해할 것이다.

이러한 기법은 전체가 하나의 메시지로 되어 있어서 text에서 text로의 번역이 가능한 경우에는 완벽하게 들어맞지만, 여러 개의 메시지로 이루어진 하나의 긴 메시지의 경우에는 그렇지 않다. 다음의 예를 보자.

   Hello, John. Good luck.

사용자는 여기에서 단순하게 연결된 스트링(string concatenation)을 떠올리며, 다음과 같이 여러 개의 스트링을 덧붙이는 방법으로 합성된 메시지를 만들려고 할 것이다.

   System.out.println(
     "Hello, " +
     name +
     ". Good luck.")

혹은, HelloGood luck 스트링을 리소스 번들로 옮겨 가면서 메시지를 합성시키려는 생각을 할 지도 모른다. 사실 이 방법은 잘 돌아갈 수도 있다. 하지만 인사말의 형태가 다음과 같이 나타나는 현상이 일어난다.

   Hello and good luck, John.

그렇다면 이번엔 리소스 번들 스트링을 이름 앞, 뒤의 접두사와 접미사 부분으로 구별해서 쪼개는 방법을 생각해 보자. 하지만 이 때에는 번역가가 어떤 것들이 같이 쓰이는지 그 용법을 모두 알고 있어야만 하기 때문에 일을 더욱 복잡하게 만들 뿐이다. 따라서 이보다 나은 접근방법은 가운데에 이름 변수를 넣을 수 있는 텍스트 스트링(text string)을 만들어 주는 것이다.

영어의 경우에는 다음과 같다.

   Hello, {0}. Good luck.

이렇게해서 생성된 스트링을 본다면, 스페인어 번역가는 다음과 같이 hello 와 good luck 을 함께 넣어버리는 것이 더 낫다는 것을 깨닫게 될 것이다.

   Hola y buena suerte, {0}.

이 메시지가 실제적으로 디스플레이 될 때에는 java.text 패키지의 MessageFormat 클래스가 변수들을 대체하기 위해 사용된다. 이 때 MessageFormat은 한 세트의 객체들을 가져와서 그것들을 포맷하고, 포맷된 스트링을 "Hello, {0}. Good luck." 과 같은 패턴에 삽입한다.

MessageFormat을 사용하기 위해서는 우선 formatter를 생성해야 한다.

   String pattern = ...; // from bundle
   Locale aLocale = ...; // the Locale

   MessageFormat formatter = new MessageFormat(pattern);
   formatter.setLocale(aLocale);

각각의 패턴을 만들기 위해서는 그에 대응하는 각각의 다른 MessageFormat 객체를 생성할 수도 있지만, 새로운 패턴 템플릿에 applyPattern 메소드를 호출함으로써 또 다른 패턴을 가진 MessageFormat 객체를 재사용 할 수도 있다. 이것은 지역정보(locales)를 바꾼 후에 해야 하는 작업임을 기억하자.

   formatter.setLocale(aNewLocale);
   formatter.applyPattern(aPatternForNewLocale);

포맷터를 만든 후에는 출력 메시지를 생성해야 한다. 이를 위해서는 인수(argument)들의 배열(array)을 넘겨 주어야 하는데, 배열안의 인덱스값에 따라서 패턴안의 각 {#}을 이 인수들의 배열이 교체해주게 된다. "Hello, {0}. Good luck." 이라는 패턴을 위해서는 하나의 요소를 갖는 배열이 필요하고, 이 배열안의 요소는 스트링의 위치0에 삽입될 텍스트를 포함한다는 것이다. 앞서 설명한 패턴에 "John" 이라는 스트링값을 삽입한 한개의 요소를 갖는 배열의 예가 있다.

  Object messageArgs[] = {"John"};

출력값을 생성하기 위해선, message arguments을 지정하여 MessageFormat의 포맷 메소드를 호출한다.

   System.out.println(formatter.format(messageArgs));

아래의 HelloGoodLuck 프로그램은 MessageFormat이 어떻게 사용되는지 보여주고 있으며, 여기에서는 프로그램을 간단하게 유지하기 위해 리소스 번들을 사용하지 않는다.

   import java.text.*;
   import java.util.*;

   public class HelloGoodLuck {
     public static void main(String args[]) {
       String pattern = "Hello, {0}. Good luck.";
       Locale aLocale = Locale.US;
       MessageFormat formatter = new MessageFormat(
                                   pattern, aLocale);
       Object messageArgs[] = {"John"};
       System.out.println(
                        formatter.format(messageArgs));
       // Pass in command line args
       if (args.length != 0) {
         System.out.println(formatter.format(args));
       }
     }
   }

커맨드 라인에 이름값을 주면 HelloGoodLuck프로그램은 두번째 메시지를 산출한다.
예를 들어, 다음과 같은 명령을 주고 프로그램을 실행했을 때,

   java HelloGoodLuck Spot

이와 같은 출력값을 보게 될 것이다.

   Hello, John. Good luck.
   Hello, Spot. Good luck.

MessageFormat을 사용하면 텍스트를 대입하는 기능뿐만 아니라, NumberFormat 이나 DateFormat 클라스를 사용하지 않고서도 숫자나 날짜를 포맷할 수가 있다. javadocMessageFormat 클라스에서 가능한 모든 지원에 대해 설명하고 있으니 이를 참고하길 바란다.

{#}의 argument index 뒷부분에는 포맷의 타입과 스타일을 컴마로 구분해서 지정할 수 있다. 예를 들어 날짜의 경우, short, medium, long, full 등의 타입을 지정하여 DateFormat 상수(constants)에 매핑할 수 있다. 만약 이때 인수의 타입이 Date이고, MessageFormat이 "{1,date,long}"을 인수로 갖는다면, 출력값으로 지역에 맞는 포맷에 long 타입으로 디스플레이 된 날짜를 보게 될 것이다. 또한 똑같은 short, medium, long, full 옵션을 이용해서 날짜를 "시간"과 함께 디스플레이할 수도 있다. 여기에서 숫자값에는 정수(integer), 통화(currency), 퍼센트(percent) 스타일도 가능하다. 만약 이렇게 미리 짜여진 스타일이 싫다면, 또 SimpleDateFormatDecimalFormat의 패턴 스트링을 이미 알고 있다면, 정보를 직접 명시해서 사용해도 된다.

시간, 날짜, 숫자를 사용하는 MessageFormat 패턴의 예를 보자.

   At the tone, the time is now {0, time, short} 
   on {0, date, long}. 
   You now owe us {1, number, currency}.

입력 인수로 DateNumber를 주었다면, 미국과 독일 지역에 맞는 출력값을 보게 될 것이다.

ExtendedFormat

다음은 위의 출력값을 생성한 프로그램이다. 단순한 프로그램을 위해서는 리소스 번들을 사용하지 말고, 대신에 패턴변수와 germanPattern 변수의 스트링을 리소스 번들에 위치시켜야 한다.

   import java.text.*;
   import java.util.*;
   import java.io.*;
   import java.awt.*;
   import javax.swing.*;

   public class ExtendedFormat {
     public static void main(String args[]) {
       String pattern = 
         "At the tone, the time is now {0, time, short}" + 
         " on {0, date, long}." +
         " You now owe us {1, number, currency}.";
       String germanPattern =
         "Beim Zeitton ist es {0, time, short} Uhr" +
         " am {0, date, long}." +
         " Sie schulden uns jetzt {1, number, currency}.";
       
       StringWriter sw = new StringWriter(100);
       PrintWriter out = new PrintWriter(sw, true);
       MessageFormat formatter = 
                 new MessageFormat(pattern, Locale.US);
       Object messageArgs[] = 
                     {new Date(), new Double(9000.12)};
       out.println(formatter.format(messageArgs));
       formatter.setLocale(Locale.GERMAN);
       // Need to reset pattern after changing locales
       formatter.applyPattern(germanPattern);
       out.println(formatter.format(messageArgs));
       out.close();
       // Put output in window
       JFrame frame = new JFrame("Extended Format");
       frame.setDefaultCloseOperation(
                                 JFrame.EXIT_ON_CLOSE);
       JTextArea ta = new JTextArea(sw.toString());
       JScrollPane pane = new JScrollPane(ta);
       frame.getContentPane().add(
                            pane, BorderLayout.CENTER);
       frame.setSize(500, 100);
       frame.show();
     }
   }

MessageFormat 을 사용하는 것 말고도 애플리케이션을 국제화하는 방법은 매우 많다. 스트링 패턴의 출처인 리소스 번들의 사용에 관한 자세한 사항은 "Resource Bundles" 1998/5/21 Tech Tip을, 날짜와 시간 스트링 포맷에 관한 자세한 정보는 "Internationalizing Dates, Times, Months, and Days of the Week" 2003/6/24 Tech Tip을 참고하길 바란다.

SWING COMPONENTS의 저장과 재구성



J2SE 1.4 의 릴리즈 이전에는 모든 스윙 컴포넌트는 "이후 릴리즈 될 스윙은 long term persistence 을 지원할 것이다."라고 약속했다. 이는 "모든 자바빈의 long term storage에 대한 지원은 1.4버전에서 java.beans 패키지에 추가되었다."는 메세지로 바뀌었다.

java.beans 패키지의 XMLEncoder클래스는 XML 파일에 자바빈을 존속시키고 스윙 컴포넌트를 저장하는 방법을 제공한다. 스윙 컴포넌트를 저장한 후에는 java.beans패키지의 XMLDecoder클래스를 이용해서 컴포넌트를 재구성할 수가 있다.

FrameCreator프로그램은 JFrame을 생성하고 사용자 설정을 하고 있다. main()메소드는 XMLEncoder객체를 생성하고, 이것을 이용해서 JFrame의 XML버전을 Frame.xml파일에 저장한다.

   import javax.swing.*;
   import java.awt.*;
   import java.awt.event.ActionListener;
   import java.beans.XMLEncoder;
   import java.beans.EventHandler;
   import java.io.BufferedOutputStream;
   import java.io.FileOutputStream;

   public class FrameCreator {
      public static JFrame createFrame() {
        JFrame jFrame = new JFrame();
        jFrame.setTitle("My Frame");
        jFrame.setSize(200, 100);
        JPanel jPanel = new JPanel();
        jFrame.getContentPane().add(jPanel);
        Controller controller = new Controller();
        JButton button1 = new JButton("Hello, world");
        button1.addActionListener(
          (ActionListener) EventHandler.create(
            ActionListener.class, controller,
            "helloWorld"));
        JButton button2 = new JButton(
            "Goodbye, cruel world");
        button2.setBackground(Color.RED);
        button2.addActionListener(
          (ActionListener) EventHandler.create(
            ActionListener.class, controller,
            "exit"));
        jPanel.add(button1);
        jPanel.add(button2);
        jFrame.setVisible(true);
        return jFrame;
      }

      public static void main(String[] args)
                                    throws Exception {
        XMLEncoder encoder = new XMLEncoder(
            new BufferedOutputStream(
                new FileOutputStream("Frame.xml")));
        encoder.writeObject(createFrame());
        encoder.close();
      }
   }

FrameCreator는 non-GUI클래스인 Controller.java에 정의된 메소드를 호출한다. 제어 Controller 클래스는;

   public class Controller {
        public void helloWorld() {
            System.out.println("Hello, world");
        }

        public void exit() {
            System.exit(0);
        }
   }

Frame.xml 컨텐츠를 보면 FrameCreator클래스에 설정되어 있는 속성들을 쉽게 확인할 수 있다. JFrame의 디멘존(dimension)이 저장되는 것을 주목할 필요가 있다. JFrame안에 저장된 JPanel에 관한 정보들과 JPanel의 구성 요소인 2개의 Jbuttons(Controller클래스의 인스턴스에 링크되어 있다.)이 파일안에 저장되어 있다.

   <?xml version="1.0" encoding="UTF-8"?>
   <java version="1.4.1_01" class="java.beans.XMLDecoder">
     <object class="javax.swing.JFrame">
      <void property="size">
       <object class="java.awt.Dimension">
        <int>200</int>
        <int>100</int>
       </object>
      </void>
      <void property="contentPane">
       <void method="add">
        <object class="javax.swing.JPanel">
         <void method="add">
          <object class="javax.swing.JButton">
           <string>Hello, world</string>
           <void method="addActionListener">
            <object class="java.beans.EventHandler"
                    method="create">
             <class>java.awt.event.ActionListener</class>
             <object id="Controller0" class="Controller"/>
             <string>helloWorld</string>
            </object>
           </void>
          </object>
         </void>
         <void method="add">
          <object class="javax.swing.JButton">
           <string>Goodbye, cruel world</string>
           <void property="background">
            <object class="java.awt.Color">
             <int>255</int>
             <int>0</int>
             <int>0</int>
             <int>255</int>
            </object>
           </void>
           <void method="addActionListener">
            <object class="java.beans.EventHandler"
                    method="create">
             <class>java.awt.event.ActionListener</class>
             <object idref="Controller0"/>
             <string>exit</string>
            </object>
           </void>
          </object>
         </void>
        </object>
       </void>
      </void>
      <void property="name">
       <string>frame0</string>
      </void>
      <void property="title">
       <string>My Frame</string>
      </void>
      <void property="visible">
       <boolean>true</boolean>
      </void>
     </object>
   </java>

이를 이용해 Frame.xml파일로부터 형성된 JFrame를 놀랍도록 쉽게 재구성할 수 있다. 다음의 FrameRecreator프로그램을 보면 XMLDecoderFrame.xml파일을 읽고 있는 것을 알 수 있고, 이를 실행하면 FrameCreator에서 생성/ 구성되었던 것과 같은 JFrame를 보게 된다.

   import java.beans.XMLDecoder;
   import java.io.BufferedInputStream;
   import java.io.FileInputStream;

   public class FrameRecreator {

      public static void main(String[] args)
                                    throws Exception {
        XMLDecoder decoder = new XMLDecoder(
            new BufferedInputStream(
                new FileInputStream("Frame.xml")));
        decoder.readObject();
        decoder.close();
      }
   }
"Frame

XMLEncoderXMLDecoder을 이용해서 JavaBeans을 저장하고 복구하는 방법을 살펴보았다. 하지만 XMLEncoderXMLDecoder는 이 예제에세 다루는 것보다 훨씬 더 광범위하게 사용된다. XMLEncoder는 모든 프리미티브 데이터타입, 날짜, 스트링, 어레이, 리스트,hashmaps(primitive data types, dates, strings, arrays, lists, )그리고 JavaBeans이 아닌 J2SE SDK 의 클래스들을 저장하기 위해 미리 구성된다. JavaBeans이 아니더라도, 사용자만의 자바 클래스들을 저장하기 위해 XMEncoder를 형성할 수도 있다. 이 테크닉에 대해 좀 더 알고 싶다면, 스윙 커넥션의 다음 아티클을 참고한다.

사용자의 persistence techniques에 XMLEncoder를 추가하려면, Core Java Technologies Tech Tips의 "Using the Preferences API" July 15 issue에서 설명했던 Preferences API를 참고한다.

MIDI SOUND 생성하기



자바 데스크탑 애플리케이션에 오디오 큐(audio cues)를 추가하면 세련된 느낌을 줄 뿐만 아니라 사용성을 극적으로 증가시킬 수가 있다. Musical Instrument Digital Interface (MIDI)는 장치간에 뮤지컬 이벤트를 주고 받을때 사용되는 커뮤니케이션 프로토콜이다. MIDI파일은 실제 오디오가 아닌 오디오 명령어를 포함한다. MIDI 가 사운드를 재현하기 위해 사운드 엔진에 명령어를 기술하는 반면, 오디오는 사운드의 디지탈 표현(digital representation)이다. 이 TechTip에서는 J2SE 1.3이후의 자바 플랫폼에서 지원하는 javax.sound.midi 패키지를 사용하여 MIDI사운드를 생성하는 3가지 방법을 소개한다.

첫번째 테크닉은, MidiChannel객체를 직접 호출해서 사운드를 생성하는 것이다. 이 객체는 MIDI전송을 위한 하나의 MIDI 채널을 나타낸다. 채널을 통해서 연주를 시작하기 위해서는 MidiChannel객체의 noteOn()메소드를 사용하고, 멈추기 위해서는 noteOff()메소드를 사용한다.

noteOn()메소드는 2개의 int값을 필요로 한다. 첫번째 int는 연주될 음을 지정하는데, 다음 SingleNoteChannel프로그램에서 noteOn()메소드에 넘겨진 int 60은 가운데 도(middle C)를 위한 표준 MIDI 음의 번호이다. 위아래의 정수는 반음에 해당하고 12 반음은 한 옥타브를 이룬다.

noteOn()메소드의 두번째 매개변수는 키가 눌려질 속도를 지정한다. 사실 이 매개변수는 흔히 속도를 나타내지만, 그것을 제어볼륨(volume control)으로 봐도 무방하다. SingleNoteChannel에서 noteOn()메소드의 두번째 매개변수는 70이다. 속도를 나타내는 70을 다른 숫자로 바꿔서 실험을 해보자.

noteOff()메소드는 두개의 signature를 갖는데, 하나는 noteOn()에서와 동일한 두개의 매개변수이고, 다른 하나는 연주될 음을 나타내는 번호이다.

음을 연주하기 전에, SingleNoteChannel생성자에 필요한 셋업을 해야한다. 사운드를 생성하기 위해서는 Synthesizer객체가 필요하다. 이 객체는 MIDI 1.0 명세(specification)에서 규정하는 각각의 16개 채널을 말하는 MidiChannel들의 컬랙션을 보여준다. SingleNoteChannel생성자는 MidiSystem클래스의 팩토리 메소드를 이용해서 Synthesizer에 대한 핸들을 받고, Synthesizeropen()메소드를 호출한 후, getChannels()메소드를 호출함으로써 가능한 MidiChannels의 array를 얻어낸다. 그리고는 index 0자리에 첫번째 MidiChannel을 넣는다.

   import javax.sound.midi.MidiChannel;
   import javax.sound.midi.Synthesizer;
   import javax.sound.midi.MidiSystem;
   import javax.sound.midi.MidiUnavailableException;

   public class SingleNoteChannel {

      private MidiChannel channel;

      public SingleNoteChannel() {
        try {
          Synthesizer synth = 
                          MidiSystem.getSynthesizer();
          synth.open();
          channel = synth.getChannels()[0];
        } catch (MidiUnavailableException e) {
          e.printStackTrace();
        }
      }

      public void playNote(int note) {
        channel.noteOn(note, 70);
        try {
          Thread.sleep(1000);
        } catch (InterruptedException e) {
          e.printStackTrace();
        }
        channel.noteOff(note, 70);
      }

      public static void main(String[] args) {
        new SingleNoteChannel().playNote(60);
      }
   }

SingleNoteChannel프로그램의 playNote()메소드는 MidiChannel noteOn()메소드를 호출해서 음을 연주하기 시작한다. 연주는 쓰레드가 휴면하는 1초동안 지속되다가 MidiChannel noteOff()메소드가 호출됨으로 해서 끝나게 된다. 또한 버튼이나 키 혹은 on-screen event를 누르거나 떼서 특정 음들을 결합시킬 수도 있다.

MIDI사운드를 생성하기 위한 두번째 테크닉은, Synthesizer로 결합된 Receiver객체를 사용하는 것이다. 이 때에 특정 MidiChannel 대신 Receiver에 대한 핸들을 받아야 한다. 사용자는 ShortMessage타입의 MIDI메세지를 만들고 사용자설정을 한 후에 Receiver객체의 send()메소드를 호출해서 연주를 시작할 수 있다.

ShortMessage객체는 많아야 2데이터 바이트를 갖는 MIDI 메세지를 포함한다. MIDI 메세지의 매개변수를 설정하거나, 채널 메세지를 위한 메세지 매개변수를 설정하기 위해서 setMessage() 메소드를 사용할 수 있다. SingleNoteSynthesizer프로그램의 setMessage()메소드는 채널 메세지를 위한 메세지 매개변수를 설정하기 위해서 4개의 int값을 포함하는 signature를 갖는다. 첫번째 int는 NOTE_ON NOTE_OFF중 보내질 코맨드를, 두번째 int는 타겟 채널을 지정한다. (앞의 예제와 일관되게, index 0을 조건으로 지정했다.) 마지막 두개의 int값은 음 식별자와 속도이다. SingleNoteSynthesizer에서 noteOn()noteOff()에 넘겨지는 int값은 SingleNoteChannel예제에서와 동일하다.

   import javax.sound.midi.ShortMessage;
   import javax.sound.midi.InvalidMidiDataException;
   import javax.sound.midi.Receiver;
   import javax.sound.midi.Synthesizer;
   import javax.sound.midi.MidiSystem;
   import javax.sound.midi.MidiUnavailableException;

   public class SingleNoteSynthesizer {

      private ShortMessage message = 
                                    new ShortMessage();
      private Receiver receiver;

      private SingleNoteSynthesizer() {
        try {
          Synthesizer synth = 
                           MidiSystem.getSynthesizer();
          synth.open();
          receiver = synth.getReceiver();
        } catch (MidiUnavailableException e) {
          e.printStackTrace();
        }
      }
  
      public void playNote(int note) {
        setShortMessage(note, ShortMessage.NOTE_ON);
        receiver.send(message, -1);
        try {
          Thread.sleep(1000);
        } catch (InterruptedException e) {
       e.printStackTrace();
        }
        setShortMessage(note, ShortMessage.NOTE_OFF);
        receiver.send(message, -1);
      }

      private void setShortMessage(
                               int note, int onOrOff) {
        try {
          message.setMessage(onOrOff, 0, note, 70);
        } catch (InvalidMidiDataException e) {
          e.printStackTrace();
        }
      }

      public static void main(String[] args) {
        new SingleNoteSynthesizer().playNote(60);
      }
   }

지금까지는 디폴트 악기에서 하나의 음을 연주하는 예만을 살펴보았다. 그렇다면 다른 악기에서의 연주는 어떨까? 세번째 테크닉에서 이 물음의 답을 보게 될 것이다. 실험을 통해 시작해보자. 연주 가능한 악기의 리스트를 생성하는 메소드가 있다.

   public void listAvailableInstruments(){
      Instrument[] instrument =
                     synth.getAvailableInstruments();
      for (int i=0; i<instrument.length; i++){
        System.out.println(i + "   "
                          + instrument[i].getName());
      }
   }

위의 메소드를 호출하면 다음과 같은 아이템 리스트를 보게 된다.

0   Piano
1   Bright Piano
2   Electric Grand
3   Honky Tonk Piano
4   Electric Piano 1
5   Electric Piano 2
6   Harpsichord
7   Clavinet
8   Celesta
9   Glockenspiel
10   Music Box
11   Vibraphone
12   Marimba
13   Xylophone
14   Tubular Bell
15   Dulcimer
16   Hammond Organ
17   Perc Organ
18   Rock Organ
19   Church Organ
20   Reed Organ
21   Accordion
22   Harmonica
23   Tango Accordion
24   Nylon Str Guitar
25   Steel String Guitar
26   Jazz Electric Gtr
27   Clean Guitar
28   Muted Guitar
29   Overdrive Guitar
30   Distortion Guitar

SingleNoteSynthesizer2는 다른 악기들을 사용하여 하나 이상의 음을 연주하기 위해 SingleNoteSynthesizer를 보강한 프로그램이다. 하지만 이를 실행해보면, 시간을 맞추기 위해 Thread가 여전히 휴면하는 것을 볼 수 있다. 이는 음악을 만들고 연주하기 위한 좋은 방법이 아닐 뿐더러, AWT Event Dispatch thread에서 이를 실행했다면 GUI가 이벤트를 처리하거나 보수하는 것을 막게 될 것이다. 이 부분은 마지막 테크닉을 설명할 때 다루기로 하겠다.

SingleNoteSynthesizer2를 보면, 사용자가 만든 악기 리스트에서 사용하고 싶은 악기의 int값을 찾아 MidiChannelprogramChange()메소드에 넣고, 이를 호출해서 연주될 악기를 바꿀 수가 있다. 예에서 교회 오르간을 나타내는 int는 19이다.

startNote()stopNote()메소드가 각각 NOTE_ON명령어와 NOTE_OFF명령어를 이용해서 메세지를 생성할 수 있도록 코드를 추가한 것을 주목하자. playNote()메소드는 startNote()를 호출하고, duration에 지정된 밀리초(milliseconds)동안 휴면한 후, stopNote()를 호출한다.

   import javax.sound.midi.ShortMessage;
   import javax.sound.midi.Synthesizer;
   import javax.sound.midi.Receiver;
   import javax.sound.midi.MidiSystem;
   import javax.sound.midi.MidiUnavailableException;
   import javax.sound.midi.InvalidMidiDataException;

   public class SingleNoteSynthesizer2 {
      private ShortMessage message = new ShortMessage();
      private Synthesizer synth;
      private Receiver receiver;

      public SingleNoteSynthesizer2() {
        try {
          synth = MidiSystem.getSynthesizer();
          synth.open();
          receiver = synth.getReceiver();
        } catch (MidiUnavailableException e) {
          e.printStackTrace();
        }
      }

      public void startNote(int note) {
        setShortMessage(ShortMessage.NOTE_ON, note);
        receiver.send(message, -1);
      }

      public void stopNote(int note) {
        setShortMessage(ShortMessage.NOTE_OFF, note);
        receiver.send(message, -1);
      }

      public void playNote(int note, int duration){
        startNote(note);
        try {
          Thread.sleep(duration);
        } catch (InterruptedException e) {
          e.printStackTrace();
        }
        stopNote(note);
      }

      public void setInstrument(int instrument){
        synth.getChannels()[0].programChange(
                                           instrument);
      }

      private void setShortMessage(
                               int onOrOff, int note) {
        try {
          message.setMessage(onOrOff, 0, note, 70);
        } catch (InvalidMidiDataException e) {
          e.printStackTrace();
        }
      }

      public void playMajorChord(int baseNote){
        playNote(baseNote,1000);
        playNote(baseNote+4,1000);
        playNote(baseNote+7, 1000);
        startNote(baseNote);
        startNote(baseNote+4);
        playNote(baseNote+7,2000);
        stopNote(baseNote+4);
        stopNote(baseNote);
      }

      public static void main(String[] args) {
        SingleNoteSynthesizer2 synth
                        = new SingleNoteSynthesizer2();
        synth.setInstrument(19);
        synth.playMajorChord(60);
      }
   }

주요 화음은 한개의 기본 음, 기본음으로부터 4개 반음 올린 음, 3개 반음 선간(spaces)을 올린 음으로 구성된다. SingleNoteSynthesizer2에서, playMajorChord()메소드는 먼저 하나씩 3개의 음를 연주한 뒤, 이들을 동시에 연주한다. 타이밍은 Threads를 이용해서 맞추는데, 타이밍이 중요할 경우에는 Sequencer를 이용하는 것이 좋다.

Sequencer객체는 MIDI 이벤트의 타이밍을 좀 더 효과적으로 컨트롤 할 수 있는 메소드를 포함한다. 전에는 MidiSystem의 팩토리 메소드를 사용했던 반면, 여기서는 Sequencer를 이용한다. Sequencer를 사용한 예는 다음 예제인 SequencerSound에서 볼 수 있다. 앞선 예와 비교해서 여기서는 코드에 몇몇 주요한 변경이 있음을 유의해야 한다. 먼저 startNote()stopNote()메소드는 생성되는 메세지가 일어나는 시점을 나타내는 int값을 갖는다. createTrack()메소드는 8분음표마다 4번의 똑딱 소리를 내서 하나의 Sequence를 생성한다. 예를 들면, 4/4박자는 4개의 8분음표 혹은 16번의 똑딱 소리로 나뉘어 질 수 있는 것처럼, 박자를 나눌 때 이 메소드를 사용할 수 있다.

setShortMessage()메소드는 setMessage()를 호출한후, ShortMessage에 기반하고, 똑딱 소리로 결합된 MidiEvent객체를 생성한다. 그리고는 마지막으로 트랙에 MidiEvent를 추가한다. 트랙을 재생할 때, startSequencer()메소드는 새롭게 생성된 Sequence를 기존 Sequence에 할당하고, start()메소드를 호출해서 재생시킬수가 있다. 또한 setTempoInBPM()메소드를 이용해서 템포를 분당 16 비트(BPM)으로 설정할 수가 있다.

   import javax.sound.midi.Track;
   import javax.sound.midi.Sequencer;
   import javax.sound.midi.Sequence;
   import javax.sound.midi.MidiSystem;
   import javax.sound.midi.MidiUnavailableException;
   import javax.sound.midi.InvalidMidiDataException;
   import javax.sound.midi.ShortMessage;
   import javax.sound.midi.MidiEvent;

   public class SequencerSound {
      private Track track;
      private Sequencer sequencer;
      private Sequence sequence;

      public SequencerSound() {
        try {
          sequencer = MidiSystem.getSequencer();
          sequencer.open();
        } catch (MidiUnavailableException e) {
          e.printStackTrace();
        }
        createTrack();
        makeScale(20);
        startSequencer();
      }

      private void startSequencer() {
        try {
          sequencer.setSequence(sequence);
        } catch (InvalidMidiDataException e) {
          e.printStackTrace();
        }
        sequencer.start();
        sequencer.setTempoInBPM(60);
      }

      private void createTrack() {
        try {
          sequence = new Sequence(Sequence.PPQ, 4);
        } catch (InvalidMidiDataException e) {
          e.printStackTrace();
        }
        track = sequence.createTrack();
      }

      public void startNote(int note, int tick) {
        setShortMessage(
                     ShortMessage.NOTE_ON, note, tick);
      }

      public void stopNote(int note, int tick) {
        setShortMessage(
                    ShortMessage.NOTE_OFF, note, tick);
      }

      private void setShortMessage(
                    int onOrOff, int note, int tick) {
        ShortMessage message = new ShortMessage();
        try {
          message.setMessage(onOrOff, 0, note, 90);
          MidiEvent event = new MidiEvent(
                                       message, tick);
          track.add(event);
        } catch (InvalidMidiDataException e) {
          e.printStackTrace();
        }
      }

      public void makeScale(int baseNote) {
        for (int i = 0; i < 13; i++) {
          startNote(baseNote + i, i);
          stopNote(baseNote + i, i + 1);
          startNote(baseNote + i, 25 - i);
          stopNote(baseNote + i, 26 - i);
        }
      }

      public static void main(String[] args) {
        new SequencerSound();
      }
   }

이 글은 MIDI 파일을 생성하고 재생하는 방법을 설명했다. 개발자는 사운드뱅크가 J2SE SDK에 따라서 자동적으로 인스톨되기 때문에 음악을 들을 수가 있다. 사운드 뱅크는 악기 설정을 보여주고, 사운드를 종합하기 위해 필요하다. (사운드뱅크에 대해서는 후에 더 자세히 다루도록 하겠다.) 비 개발자들은 생성한 파일을 들으려면 사운드뱅크를 따로 설치해야 될 것이다. J2SE 1.4.1 혹은 이후버전에 JRE를 인스톨할 때, 사용자는 사운드뱅크를 포함시키도록 설정해야 한다.

MIDI파일의 저장과 재생관련 정보는 javax.sound.midi의 documentation를, 이 팁에 대한 자세한 내용은 Java Sound API home page를 참고한다.

prev 1 next