제공: 한빛 네트워크
저자 : Maurice Naftalin, Philip Wadler / 이대엽 역
원문 : http://www.onjava.com/pub/a/onjava/excerpt/javagenerics_chap05/index.html
[편집자주]
Java Generics and Collections의 저자인 Maurice Naftalin과 Philip Wadler는 그들의 신간에서 여러분이 제네릭과 컬렉션에 관해 심층적으로 다룰 것으로 기대하고 있을 자바 5.0 제네릭의 문법과 의미에 대한 완전한 소개를 제공하고 있다. 그러나 그들은 실세계에 대한 고찰, 즉 여러분의 업무에 제네릭을 사용하는 것에 대한 실용적인 고찰을 통해 한걸음 더 나아간다. 만약 여러분의 프로젝트를 자바 5.0에 기반하여 시작하지 않았다면 일반적으로 제네릭을 사용하지 않는 레거시(legacy) 코드 기반을 가질 가능성이 있다. 그것들을 어떤 한 릴리즈 버전에서 모두 제네릭을 사용하도록 일괄변환(bulk-converting)하는 것이 현실적인 대안일까? 아마 그렇지는 않을 것이고 여러분은 점진적으로 제네릭을 도입하는 하는 것에 대해 고려해 보아야 할 필요가 있다. 다행히도 ONJava에 다음 2주에 걸쳐 발췌할 Java Generics and Collections의 5장 “Evolution, Not Revolution(변혁이 아닌 진화)”에서 설명하는 것과 같이 제네릭의 구현체는 이를 훌륭히 실용적으로 만들어준다.
자바의 제네릭 설계의 근간이 되는 한 가지 모토는 “변혁이 아닌 진화”이다. 대량의 기존 코드를 급격히, 한번에 모든 것을 변경(변혁)할 필요 없이 점진적으로(진화) 제네릭을 사용하도록 마이그레이션 할 수 있어야 한다는 것이다. 제네릭의 설계는 여러분의 코드가 절반은 낡은 라이브러리를 필요로 하고 나머지 절반은 새로운 라이브러리를 필요로 하는 난감한 상황을 피해 낡은 코드도 새로운 자바 라이브러리에 대해 컴파일 될 수 있도록 보장한다.
진화를 위한 요구사항은 일반적인 하위 호환성을 지키는 것에 비해 훨씬 더 강하다. 단순한 하위 호환성을 지키는 경우 각 애플리케이션에 대해 레거시 버전과 제네릭 버전 모두를 제공하는 방법이 있는데, 예를 들어 C#이 정확히 이런 경우이다. 만약 여러분이 다수의 제공자에 의해 제공되는 코드의 최상위 수준에서 애플리케이션을 구축하고 있다면 몇몇 누군가는 레거시 컬렉션을 사용하고 다른 누군가는 제네릭 컬렉션을 사용할 것이며 이는 순식간에 여러분을 버전관리의 악몽에 이르게 할 것이다.
우리가 필요로 하는 것은 “동일한” 클라이언트 코드가 레거시 버전의 라이브러리와 제네릭 버전의 라이브러리 모두에서 동작하게끔 하는 것이다. 이는 곧 제공자와 라이브러리 클라이언트가 언제 레거시 코드에서 제네릭 코드로 옮겨갈지에 대해 완전히 독립적인 선택을 할 수 있다는 것을 의미한다. 이는 하위 호환성에 비해 훨씬 더 강한 요구사항인데, 이를 “마이그레이션 호환성” 혹은 “플랫폼 호환성”이라 부른다.
자바는 erasure를 통해 제네릭을 구현하는데 이는 몇몇 타입에 관한 보조정보(auxiliary information)를 제외하고는 레거시 버전과 제네릭 버전이 일반적으로 동일한 클래스 파일을 생성함을 보장한다. 따라서 어떠한 클라이언트 코드의 변경 혹은 심지어 재컴파일 없이도 레거시 클래스 파일을 제네릭 클래스 파일로 대체하는 것이 가능해 진다. 이를 “바이너리 호환성”이라 부른다.
우리는 이러한 것들을 “바이너리 호환성은 마이그레이션 호환성을 보장한다” 혹은 좀 더 정확히 말해 “erasure는 진화를 용이하게 한다”라는 모토로 요약할 수 있겠다.
이 섹션에서는 기존 코드에 어떻게 제네릭을 추가하는지에 대해 보여줄 텐데 컬렉션 프레임워크를 확장하는 스택(Stack) 라이브러리 및 관련 클라이언트까지 포함하는 자그마한 예제를 살펴볼 것이다. 먼저 레거시 스택 라이브러리와 클라이언트(제네릭 이전의 자바로 작성된)로 시작한 다음 대응되는 제네릭 라이브러리와 클라이언트(제네릭이 추가된 자바로 작성된)를 보여줄 것이다. 예제 코드의 양이 적기 때문에 한번에 모두 제네릭으로 변경하는 것도 쉽지만 실무에서는 라이브러리와 클라이언트의 수와 양이 훨씬 더 클 것이므로 그것들을 개별적으로 진화시킬 필요가 있을 수도 있다. 이는 파라미터화된 타입(parameterized types)에 대한 레거시 대응체(legacy counterpart)인 로타입(raw types)을 토대로 이루어진다.
프로그램의 각 부분들은 어느 순서로도 진화시킬 수 있다. 제네릭 라이브러리에 레거시 클라이언트가 있을 수 있는데, 이는 레거시 코드에 자바 5의 컬렉션 프레임워크를 사용하는 이들에게 일반적인 경우이다. 아니면 제네릭 클라이언트에 레거시 라이브러리가 있을 수도 있는데, 이는 여러분이 전체 라이브러리를 모두 새로 작성할 필요 없이 라이브러리에 제네릭 서명(signature)을 제공하고자 할 경우이다. 이를 수행하기 위해서 3가지 방법을 생각해 볼 수 있는데, 최소한의 소스변경(minimal changes to the source), 스텁 파일(stub files), 그리고 래퍼(wrappers)가 그것이다. 첫 번째 방법은 여러분이 소스에 접근했을 때 유용한 방법이고, 두 번째 방법은 여러분이 소스에 접근할 수 없을 때 유용한 방법이다. 그러나 우리는 세 번째 방법을 권장한다.
실무에서는 라이브러리와 클라이언트가 수많은 인터페이스와 클래스를 가질 것이며 따라서 라이브러리와 클라이언트간의 명확한 구분조차 없을지도 모른다. 하지만 여기에서 논의했던 원칙들은 여전히 동일하게 적용되며 다른 부분과 독립적으로 프로그램의 각 부분을 발전시키는데 사용될 수 있을 것이다.
레거시 라이브러리에 레거시 클라이언트가 있는 경우
[예제 5-1]에 나와있는 것처럼 간단한 스택 라이브러리와 연관된 클라이언트로 시작해 보도록 하겠다. 이것은 자바 1.4와 자바 1.4 버전의 컬렉션 프레임워크에 맞게 작성된 레거시 코드이다. 우리는 컬렉션 프레임워크와 같이 Stack(List와 같은) 인터페이스, 그것의 구현 클래스인 ArrayStack(ArrayList와 같은), 그리고 유틸리티 클래스인 Stacks(Collections와 같은)로 라이브러리를 만들었다. Stack 인터페이스는 empty, push, pop의 3개의 메소드만을 제공한다. 구현 클래스인 ArrayStack은 하나의 인자를 받아들이지 않는 생성자를 제공하며, 리스트(List)에 대하여 size, add, remove 메소드를 이용하여 empty, push, pop 메소드들을 구현한다. pop 메소드의 본문은 값을 변수에 할당하는 대신 바로 리턴 시킴으로써 좀 더 짧게 작성될 수 있었으나 코드가 진화함에 따라 변수의 타입이 어떻게 바뀌는지 알아보는 것도 재미있을 것아 그렇게 작성하였다. 유틸리티 클래스는 단지 reverse라는 하나의 메소드만을 제공하는데, 이 메소드는 반복적으로 한 스택에서 값을 꺼내어(pop) 다른 스택으로 삽입(push)하는 역할을 한다.
[예제 5-1] 레거시 라이브러리에 레거시 클라이언트가 있는 경우
l/Stack.java:
interface Stack {
public boolean empty();
public void push(Object elt);
public Object pop();
}
l/ArrayStack.java:
import java.util.*;
class ArrayStack implements Stack {
private List list;
public ArrayStack() { list = new ArrayList(); }
public boolean empty() { return list.size() == 0; }
public void push(Object elt) { list.add(elt); }
public Object pop() {
Object elt = list.remove(list.size()-1);
return elt;
}
public String toString() { return "stack"+list.toString(); }
}
l/Stacks.java:
class Stacks {
public static Stack reverse(Stack in) {
Stack out = new ArrayStack();
while (!in.empty()) {
Object elt = in.pop();
out.push(elt);
}
return out;
}
}
l/Client.java:
class Client {
public static void main(String[] args) {
Stack stack = new ArrayStack();
for (int i = 0; i<4; i++) stack.push(new Integer(i));
assert stack.toString().equals("stack[0, 1, 2, 3]");
int top = ((Integer)stack.pop()).intValue();
assert top == 3 && stack.toString().equals("stack[0, 1, 2]");
Stack reverse = Stacks.reverse(stack);
assert stack.empty();
assert reverse.toString().equals("stack[2, 1, 0]");
}
}
클라이언트는 스택을 할당하고 몇 개의 정수를 집어넣고 꺼낸 다음 남아있는 것들을 다른 새로이 생성된 스택으로 반전시켜 넣는다. 이는 자바 1.4로 작성되었기 때문에 정수값들은 push 메소드로 전달될 경우 명시적으로 박싱(boxing)되어야만 하며, 반대로 pop 메소드에 의해 리턴될 경우에는 명시적으로 언박싱(unboxing)되어야만 한다.
제네릭 라이브러리에 제네릭 클라이언트가 있는 경우
다음으로는 [예제 5-2]에 나와있는 것처럼 이 라이브러리와 클라이언트가 제네릭을 사용하도록 수정해볼 것이다. 이것은 자바 5와 자바 5의 컬렉션 프레임워크에 맞게 작성된 제네릭 코드이다. 이제 인터페이스는 타입 파라미터를 갖게 되어 Stack
(List와 같은)가 되며, 따라서 구현 클래스 역시 ArrayStack(ArrayList와 같은)가 된다. 하지만 유틸리티 클래스인 Stacks(Collections와 같은)에는 타입 파라미터가 추가되지 않는다. push와 pop 메소드의 시그너처와 메소드 본문에 있던 Object 타입은 타입 파라미터인 E로 대체되었다. 주의할 점은 ArrayStack에 있는 생성자는 타입 파라미터를 필요로 하지 않는다는 것이다. 유틸리티 클래스의 reverse 메소드는 인자와 리턴 타입이 Stack인 제네릭 메소드가 되었다. 적절한 타입 파라미터가 클라이언트에 추가되어 이제 박싱과 언박싱이 암시적(implicit)으로 바뀌었다.
[예제 5-2] 제네릭 라이브러리에 제네릭 클라이언트가 있는 경우
g/Stack.java:
interface Stack {
public boolean empty();
public void push(E elt);
public E pop();
}
g/ArrayStack.java:
import java.util.*;
class ArrayStack implements Stack {
private List list;
public ArrayStack() { list = new ArrayList(); }
public boolean empty() { return list.size() == 0; }
public void push(E elt) { list.add(elt); }
public E pop() {
E elt = list.remove(list.size()-1);
return elt;
}
public String toString() { return "stack"+list.toString(); }
}
g/Stacks.java:
class Stacks {
public static Stack reverse(Stack in) {
Stack out = new ArrayStack();
while (!in.empty()) {
T elt = in.pop();
out.push(elt);
}
return out;
}
}
g/Client.java:
class Client {
public static void main(String[] args) {
Stack stack = new ArrayStack();
for (int i = 0; i<4; i++) stack.push(i);
assert stack.toString().equals("stack[0, 1, 2, 3]");
int top = stack.pop();
assert top == 3 && stack.toString().equals("stack[0, 1, 2]");
Stack reverse = Stacks.reverse(stack);
assert stack.empty();
assert reverse.toString().equals("stack[2, 1, 0]");
}
}
요약하자면 변환 절차는 단순히 몇 개의 파라미터를 추가하고 Object가 나타나는 부분을 적절한 타입의 변수로 대체하는 것이므로 간단하다. 모든 레거시 버전과 제네릭 버전간의 차이점은 두 예제에서 강조되어 있는 부분을 비교해 봄으로써 발견할 수 있다. 제네릭의 구현체는 레거시 버전과 제네릭 버전의 두 버전이 본질적으로 동일한 클래스 파일을 생성하도록 설계되어 있다. 몇 가지 타입에 관한 보조정보만이 다를 수도 있겠지만 실행될 실제 바이트코드는 동일할 것이다. 그러므로 레거시 버전과 제네릭 버전을 실행하게 되면 둘 모두 동일한 결과를 나타낼 것이다. 레거시 소스와 제네릭 소스가 동일한 클래스 파일을 만들어낸다는 사실은 진화 과정을 용이하게 해주며 바로 다음에 이어지는 부분에서 언급하도록 하겠다.
제네릭 라이브러리에 레거시 클라이언트가 있는 경우
이제 라이브러리는 제네릭으로 갱신되었지만 클라이언트는 아직 레거시 버전으로 존재하고 있는 경우를 생각해 보자. 이런 경우는 한번에 모든 것들을 변환할 충분한 시간이 없기 때문에 발생하거나 아니면 라이브러리와 클라이언트가 다른 조직에 의해 관리되는 연유로 발생할 수 있다. 이는 자바 5의 제네릭 컬렉션 프레임워크가 여전히 자바 1.4의 컬렉션 프레임워크에 맞게 작성된 레거시 클라이언트와 작동해야만 하는 경우로서 하위 호환성에 있어 가장 중요한 경우에 해당된다고 할 수 있다.
또한 진화를 지원하기 위해 파라미터화된 타입이 정의될 때마다 자바는 그것에 대응되는 로타입(raw type)이라 불리는 파라미터화되지 않은 타입의 버전을 인식한다. 예를 들어, 파라미터화된 타입의 Stack는 로타입의 Stack에 대응되며 파라미터화된 타입인 ArrayStack는 로타입의 ArrayStack에 대응된다.
모든 파라미터화된 타입은 대응되는 로타입의 하위타입(subtype)이며, 따라서 파라미터화된 타입의 값은 로타입이 예상되는 곳으로 전달될 수 있다. 보통 하위타입의 값이 예상되는 곳으로 상위타입의 값을 전달하는 것은 에러를 발생시키지만 자바는 로타입의 값을 파라미터화된 타입이 예상되는 곳으로 전달하는 것을 허용하고 있다. 그러나 이러한 상황을 “비확인 변환(unchecked conversion)” 경고를 발생시킴으로써 알려준다. 예를 들어, 여러분은 Stack 타입의 값을 Stack 타입의 변수에 할당할 수 있는데, 전자가 후자의 하위타입이기 때문이다. 또한 여러분은 Stack 타입의 값을 Stack 타입의 변수에 할당할 수도 있지만 이는 비확인 변환 경고를 발생시킬 것이다.
구체적으로 말하자면 예제 5.2(g 디렉토리에 들어있는)에 있는 Stack, ArrayStack, Stacks의 제네릭 소스를 예제 5.1(l 디렉토리에 들어있는)에 있는 클라이언트의 레거시 소스와 함께 컴파일할 때는 잘 생각해 보아야 한다는 것이다. Sun사의 자바 5 컴파일러는 다음과 같은 메시지를 만들어 낸다.
% javac g/Stack.java g/ArrayStack.java g/Stacks.java l/Client.java
Note: Client.java uses unchecked or unsafe operations.
Note: Recompile with -Xlint:unchecked for details.
비확인 경고는 제네릭이 처음부터 끝까지 균등하게 사용되는 경우와 동일한 안전성 보장을 컴파일러가 제공할 수 없다는 것을 의미한다. 그러나 레거시 코드를 갱신함으로써 제네릭 코드가 생성될 경우 우리는 동등한 클래스 파일들이 둘 모두로부터 생성됨을 알고 있기 때문에(비확인 경고에도 불구하고), 따라서 레거시 클라이언트에 제네릭 라이브러리가 있는 경우의 실행결과가 레거시 클라이언트에 레거시 라이브러리가 있는 경우의 실행결과와 동일한 결과를 만들어낼 것이다. 여기에서는 라이브러리를 갱신하는 데 있어 유일한 변경사항은 제네릭을 도입하는 것 말고는 고의로 혹은 실수로라도 다른 것은 아무것도 추가되지 않은 것으로 가정한다.
만일 위에서 제시한 사항에 따라 적절한 옵션을 활성화시켜 컴파일러를 다시 실행시키면 좀 더 자세한 내용을 볼 수 있다:
% javac -Xlint:unchecked g/Stack.java g/ArrayStack.java
% g/Stacks.java l/Client.java
l/Client.java:4: warning: [unchecked] unchecked call
to push(E) as a member of the raw type
Stack
for (int i = 0; i<4; i++) stack.push(new Integer(i));
^
l/Client.java:8: warning: [unchecked] unchecked conversion
found : Stack
required: Stack
Stack reverse = Stacks.reverse(stack);
^
l/Client.java:8: warning: [unchecked] unchecked method invocation:
reverse(Stack) in Stacks is applied to (Stack)
Stack reverse = Stacks.reverse(stack);
^
3 warnings
매번 로타입을 사용할 때마다 경고가 발생하지는 않는다. 왜냐하면 모든 파라미터화된 타입은 그것에 대응되는 로타입의 하위타입이기 때문이다. 즉 파라미터화된 타입을 로타입이 예상되는 곳으로 전달하는 것은 안전하다(따라서 reverse 메소드로부터 결과를 얻는 것은 아무런 경고를 발생시키지 않는다). 그러나 그 반대, 즉 로타입을 파라미터화된 타입이 예상되는 곳으로 전달하는 것은 경고를 발생시키는데(따라서 reverse 메소드로 인자를 전달할 때는 경고가 발생한다), 이는 치환원칙(Substitution Principle)의 한 예라 할 수 있다. 로타입의 수신자(receiver)에서 메소드를 호출할 경우 메소드는 타입 파라미터가 와일드 카드인 것으로 간주하기 때문에 로타입으로부터 값을 얻는 것은 안전하지만(따라서, pop 메소드에 대한 호출은 경고를 발생시키지 않는다), 반대로 로타입으로 값을 집어넣는 것은 경고를 발생시키며(따라서 push 메소드에 대한 호출은 경고를 발생시킨다) 이는 Get과 Put 원칙(Get and Put Principle)의 한 예이다.
비록 여러분이 어떠한 제네릭 코드를 작성하지 않았다손 치더라도 여러분은 여전히 다른 누군가가 그들의 코드를 제네릭화시키기 때문에 진화 문제를 겪게 될지도 모른다. 이는 Sun사에서 제네릭화 해놓은 컬렉션 프레임워크를 사용하는 레거시 코드를 가진 모든 이들에게 영향을 미칠 것이다. 그러므로 제네릭 라이브러리에 레거시 클라이언트를 사용하는 데 있어 가장 중요한 경우는 자바 1.4 컬렉션 프레임워크에 맞게 작성된 레거시 코드에 자바 5 컬렉션 프레임워크를 사용하는 경우이다.
특히 예제 5.1의 레거시 코드에 자바 5 컴파일러를 적용시키는 것도 비확인 경고를 발생시키는데, 이는 레거시 클래스인 ArrayStack에서 제네릭화된 클래스인 ArrayList를 사용하기 때문이다. 아래는 전체 레거시 버전의 파일들을 자바 5 컴파일러와 라이브러리로 컴파일할 때 어떠한 일이 발생하는지에 대해 보여준다:
% javac -Xlint:unchecked l/Stack.java l/ArrayStack.java
% l/Stacks.java l/Client.java
l/ArrayStack.java:6: warning: [unchecked] unchecked call to add(E)
as a member of the raw type java.util.List
public void push(Object elt) list.add(elt);
^
1 warning
여기에 나타나 있는 레거시 메소드인 push내에서 제네릭 메소드인 add를 사용함으로써 발생하는 경고는 이전에 레거시 클라이언트에서 제네릭 메소드인 push를 사용하면서 나타났던 경고와 유사한 연유로 발생한다.
여러분이 무시하고자 하는 경고를 반복적으로 발생시키도록 설정하는 것은 좋지 못한 관례이다. 이는 주의를 분산시키거나 아니면 더 나쁘게 하며 “양치기 소년”의 우화에서 그랬던 것처럼 여러분의 주의를 필요로 하는 경고를 무시하도록 만들 수도 수 있다. 순수 레거시 코드의 경우 이러한 경고는 –source 1.4 스위치를 사용하여 나타나지 않게 할 수 있다.
% javac -source 1.4 l/Stack.java l/ArrayStack.java
% l/Stacks.java l/Client.java
이렇게 하면 레거시 코드를 컴파일하며 아무런 경고나 오류를 발생하지 않는다. 경고를 끄는 이 방법은 오직 순수 레거시 코드에만 적용될 수 있으며 제네릭이나 다른 어떤 자바 5에서 도입된 기능에 대해서는 적용되지 않는다. 또한 어떤 것은 다음 섹션에서 언급되어 있는 것과 같이 어노테이션(annotations)을 사용해서도 비확인 경고를 끌 수 있는데 이는 자바 5에서 도입된 기능에서도 작동한다.
Maurice Naftalin는 영국의 컨설팅 기업인 Morningside Light Ltd.의 소프트웨어 개발부서장이다.
Philip Wadler는 스코틀랜드에 위치한 University of Edinburgh의 이론 컴퓨터 과학 전공 교수이며 그곳에서 함수형 프로그래밍과 논리형 프로그래밍에 초점을 맞춰 연구하고 있다.