제공: 한빛 네트워크
저자 : Philip Wadler, Maurice Naftalin
역자 : 이대엽
원문 : http://www.onjava.com/pub/a/excerpt/javagenerics_chap05/index1.html
[이전 기사 보기]
☞
자바 제네릭과 컬렉션: 변혁이 아닌 진화 - Part 1
[편집자주]
지난 주의 Java Generics and Collections의 5장 발췌문에서는 저자인 Maurice Naftalin과 Philip Wadler가 제네릭을 사용하지 않는 코드기반을 갖게 되는 상황과 여러분이 한 릴리즈 버전에서 완전히 모두 새로 만들지 않고도 제네릭으로 마이그레이션 할 수 있는 방법에 대해 다루어 보았다. 지난 주 기사에서는 간단한 “라이브러리”와 “클라이언트” 코드 예제로 그러한 상황을 묘사한 다음 클라이언트는 제네릭화 시키지 않은 채로 두고 라이브러리만 제네릭을 사용하도록 마이그레이션하는 것에 대해 알아 보았다. 이번 두 번째 발췌문에서는 좀 더 기교가 필요한 상황, 즉 라이브러리는 그대로 둔 채로 클라이언트만 제네릭화시키는 경우로 주제를 옮겨 보도록 하겠다.
레거시 라이브러리에 제네릭 클라이언트가 있을 경우
일반적으로 클라이언트를 업데이트 하기 전에 라이브러리를 업데이트 하는 것이 이치에 맞지만 여러분이 그 반대로 하고자 할 경우가 있을지도 모른다. 예를 들어, 여러분이 라이브러리가 아닌 클라이언트에 대한 유지보수 책임을 맡게 될 수도 있거나, 혹은 라이브러리가 방대해서 여러분이 그것을 한번에 모두 업데이트하는 것이 아니라 점진적으로 업데이트 하고자 할 수도 있고, 아니면 여러분이 라이브러리의 소스가 아닌 클래스 파일만 갖게 될 수도 있다.
이러한 경우에는 메소드 본문을 변경하는 것이 아니라 메소드 시그너처에서 라이브러리가 파라미터화된 타입을 사용하도록 업데이트하는 것이 적절하다. 이를 위한 방법에는 3가지가 있는데, 소스에 대한 최소한의 변경(making minimal changes to the source), 스텁 파일 생성(creating stub files), 래퍼 사용(use of wrappers)이 그것이다. 우리는 여러분이 소스에 접근할 수 있는 경우에는 최소한의 변경을, 오직 클래스 파일에만 접근할 수 있는 경우에는 스텁파일 사용을, 그리고 래퍼를 사용하는 것도 권장하였다.
최소한의 변경으로 라이브러리 진화시키기
최소 변경 기법(minimal changes technique)은 예제 5.3에 나타나 있다. 여기에는 메소드 본문이 아닌 메소드 서명에만 변경을 가한 편집된 라이브러리의 소스가 나와 있다. 정확히 변경이 필요한 부분은 굵게 강조되어 있는 부분이다. 이는 여러분이 소스에 접근했을 경우 라이브러리를 제네릭으로 진화하도록 하는데 권장되는 기법이다.
[예제 5-3] 최소한의 변경으로 라이브러리 진화시키기
m/Stack.java:
interface
Stack {
public boolean empty();
public void push(E elt);
public E pop();
}
m/ArrayStack.java:
@SuppressWarnings("unchecked")
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); } // unchecked call
public E pop() {
Object elt = list.remove(list.size()-1);
return (E)elt; // unchecked cast
}
public String toString() { return "stack"+list.toString(); }
}
m/Stacks.java:
@SuppressWarnings("unchecked")
class Stacks {
public static Stack reverse(Stack in) {
Stack out = new ArrayStack();
while (!in.empty()) {
Object elt = in.pop();
out.push(elt); // unchecked call
}
return out; // unchecked conversion
}
}
정확히 말해 필요한 변경사항들은 다음과 같다:
- 타입 파라미터를 인터페이스나 클래스 선언에 적절히 추가한다(Stack 인터페이스나 ArrayStack 클래스)
- 타입 파라미터를 새로이 파라미터화된 인터페이스나 클래스의 extends나 implements 절에 추가한다(ArrayStack의 implements 절의 Stack)
- 타입 파라미터를 각 메소드 서명에 적절히 추가한다(Stack와 ArrayStack의 push와 pop 메소드, Stacks의 reverse 메소드)
- 리턴 타입이 타입 파라미터를 포함하는 곳(리턴 타입이 E인 ArrayStack내의 pop 메소드)에서는 각 리턴 문에 비확인 형변환(unchecked cast)을 추가한다. 이러한 형변환을 하지 않으면 비확인 경고(unchecked warning)가 아닌 에러가 발생하게 된다.
- 선택적으로 어노테이션을 추가하여 비확인 경고를 억제한다(ArrayStack와 Stacks)
우리가 몇 가지 변경할 필요가 없는 것들에 대해 알아두는 것도 값진 일이다. 메소드 본문의 Object는 현 상태 그대로(ArrayStack 클래스의 pop 메소드 첫째 줄을 보라) 둘 수 있으며, 로 타입(raw types)이 나타나는 모든 곳에 타입 파라미터를 추가할 필요는 없다(Stacks 클래스의 reverse 메소드의 첫째 줄을 보라). 또한 리턴 타입이 타입 파라미터일 경우에만(pop 메소드에서처럼) 리턴 문에 형변환을 추가해줄 필요가 있으며, 리턴 타입이 파라미터화된 타입(reverse 메소드에서처럼)일 경우에는 리턴문에 형변환을 추가해줄 필요가 없다.
이러한 변경사항들을 적용하고 나면 라이브러리가 비록 몇몇 비확인 경고를 발생시키긴 하겠지만 성공적으로 컴파일 될 것이다. 베스트 프랙티스를 좇아 코드에 어떠한 라인이 이러한 경고를 발생시키는지를 가리키기 위해 주석을 달아 놓았다.
% javac -Xlint:unchecked m/Stack.java m/ArrayStack.java m/Stacks.java
m/ArrayStack.java:7: warning: [unchecked] unchecked call to add(E)
as a member of the raw type java.util.List
public void push(E elt) list.add(elt); // unchecked call
^
m/ArrayStack.java:10: warning: [unchecked] unchecked cast
found : java.lang.Object
required: E
return (E)elt; // unchecked cast
^
m/Stacks.java:7: warning: [unchecked] unchecked call to push(T)
as a member of the raw type Stack
out.push(elt); // unchecked call
^
m/Stacks.java:9: warning: [unchecked] unchecked conversion
found : Stack
required: Stack
return out; // unchecked conversion
^
4 warnings
라이브러리 클래스 파일들을 컴파일 할 때 우리가 비확인 경고가 나타나리라는 것을 예상하고 있음을 나타내기 위해 소스에 어노테이션을 추가하여 그러한 경고를 억제(suppress)하였다.
@SuppressWarnings("unchecked");
(경고 억제 어노테이션은 초기 버전의 썬 자바 5 컴파일러에서는 작동하지 않는다.) 이는 컴파일러가 거짓 경고를 발생시키는 것을 방지할 수 있는데, 즉 우리가 예상하고 있는 비확인 경고가 발생하지 않을 것이라 말했기 때문에 따라서 우리가 예상하고 있는 것이 아닌 것들을 발견하는 것이 쉬워진다. 특히 한번 라이브러리를 업데이트하고 나면 우리는 클라이언트에서 어떠한 비확인 경고도 보지 않게 된다. 우리는 라이브러리 클래스의 경고는 억제하였지만 클라이언트에 대해서는 억제하지 않았음을 유념하라.
라이브러리를 컴파일하여 생성된 비확인 경고를 없애는(억제와는 반대로) 유일한 방법은 전체 라이브러리 소스가 제네릭을 사용하도록 갱신하는 것 뿐이다. 이는 완전히 논리적인데, 전체 소스가 업데이트되지 않는 이상 선언된 제네릭 타입이 정확한지 컴파일러가 확인할 길이 없기 때문이다. 사실 비확인 경고는 에러보다는 대부분 경고인데, 왜냐하면 이러한 기법을 사용하는 것을 지원하기 때문이다. 여러분은 제네릭 서명이 실제로 정확하다고 확신할 경우에만 이러한 기법을 사용하라. 최선의 방법은 코드가 전체적으로 제네릭을 사용하도록 진화하는 단계상의 중간단계로서만 이 기법을 사용하는 것이다.
스텁을 이용하여 라이브러리 진화시키기
스텁을 활용한 기법은 예제 5.4에 나타나 있다. 여기서 우리는 제네릭 서명을 가지긴 하지만 메소드 본문에는 제네릭이 없는 스텁을 작성할 것이다. 우리는 제네릭 서명에 대해서는 제네릭 클라이언트를 컴파일 하겠지만 코드는 레거시 클래스 파일에 대해서만 실행할 것이다. 이 기법은 소스가 릴리즈 되지 않았거나 다른 사람이 소스를 유지보수할 책임을 맡게 될 경우에 적절한 기법이다.
[예제 5-4] 스텁을 이용하여 라이브러리 진화시키기
s/Stack.java:
interface Stack {
public boolean empty();
public void push(E elt);
public E pop();
}
s/StubException.java:
class StubException extends UnsupportedOperationException {}
s/ArrayStack.java:
class ArrayStack implements Stack {
public boolean empty() { throw new StubException(); }
public void push(E elt) { throw new StubException(); }
public E pop() { throw new StubException(); }
public String toString() { throw new StubException(); }
}
s/Stacks.java:
class Stacks {
public static Stack reverse(Stack in) {
throw new StubException();
}
}
정확히 말해 우리는 동일한 수정사항을 최소 변경 기법에서 했던 것과 같이 인터페이스와 클래스 선언부 및 메소드 서명에 적용하였는데, 모든 실행 코드를 완전히 삭제하는 것은 제외시켰으며 각각의 메소드 본문은 StubsException(UnsupportedOperationException을 확장하는 새로운 예외)을 던지는 코드로 대체하였다.
우리가 제네릭 클라이언트를 컴파일 할 경우, 적절한 제네릭 서명(말하자면 s 디렉터리에 들어있는)을 포함하고 있는 스텁 코드로부터 생성된 클래스 파일들에 대하여 그러한 방법을 적용할 수 있다. 클라이언트를 실행할 경우에는 원본 레거시 클래스 파일(말하자면 l디렉터리에 있는)들에 대해 그러한 방법을 적용할 수 있다.
% javac -classpath s g/Client.java
% java -ea -classpath l g/Client
다시 한번 말하지만 이는 레거시 파일과 제네릭 파일로부터 생성된 클래스 파일들이 본질적으로 동일하기 때문에 작동하며 이 때 타입에 관한 보조 정보를 저장한다. 특히 클라이언트에 대하여 컴파일된 제네릭 서명과 레거시 서명(타입 파라미터에 관한 보조 정보는 별개의 문제로 하고)이 일치하므로, 따라서 코드는 성공적으로 실행되며 이전과 동일한 결과를 보여준다.
래퍼(wrappers)를 이용하여 라이브러리 진화시키기
래퍼 기법은 예제 5.5에 나타나 있다. 여기서 우리는 레거시 소스와 클래스 파일을 변경하지 않은 채로 두고, 위임을 통해 레거시 클래스에 접근하는 제네릭 래퍼 클래스를 제공하고 있다. 이 기법을 여러분에게 보여주는 주 목적은 여러분이 이 기법을 사용하지 않도록 경고하는데 있으며 일반적으로 최소 변경 기법이나 스텁 기법을 사용하는 것이 더 낫다.
[예제 5-5] 래퍼를 이용하여 라이브러리 진화시키기
// 이렇게 하지 마라 – 래퍼를 사용하는 것은 권장되지 않는 방법이다.
l/Stack.java, l/Stacks.java, l/ArrayStack.java:
// As in Example 5.1
w/GenericStack.java:
interface GenericStack {
public Stack unwrap();
public boolean empty();
public void push(E elt);
public E pop();
}
w/GenericStackWrapper.java:
@SuppressWarnings("unchecked")
class GenericStackWrapper implements GenericStack {
private Stack stack;
public GenericStackWrapper(Stack stack) { this.stack = stack; }
public Stack unwrap() { return stack; }
public boolean empty() { return stack.empty(); }
public void push(E elt) { stack.push(elt); }
public E pop() { return (E)stack.pop(); } // unchecked cast
public String toString() { return stack.toString(); }
}
w/GenericStacks.java:
class GenericStacks {
public static GenericStack reverse(GenericStack in) {
Stack rawIn = in.unwrap();
Stack rawOut = Stacks.reverse(rawIn);
return new GenericStackWrapper(rawOut);
}
}
w/Client.java:
class Client {
public static void main(String[] args) {
GenericStack stack
= new GenericStackWrapper(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]");
GenericStack reverse = GenericStacks.reverse(stack);
assert stack.empty();
assert reverse.toString().equals("stack[2, 1, 0]");
}
}
이 기법은 제네릭 인터페이스와 래퍼 클래스의 병렬계층을 만든다. 정확히 말하자면, 레거시 인터페이스인 Stack에 대응되는 GenericStack라는 새로운 인터페이스를, 레거시 구현체인 ArrayStack에 접근하기 위한 GenericWrapperClass라는 새로운 클래스를, 그리고 지원 클래스인 Stacks에 대응되는 GenericStacks라는 새로운 클래스를 각각 작성한다.
제네릭 인터페이스인 GenericStack은 레거시 인터페이스인 Stack으로부터 이전 섹션에서 사용된 동일한 메소드를 제네릭을 사용토록 메소드 서명을 갱신하여 파생되었다. 추가적으로 래퍼로부터 레거시 구현을 추출하는 unwrap이라는 새로운 메소드가 추가되었다.
래퍼 클래스인 GenericStackWrapper
는 GenericStack을 Stack에 대한 위임을 통해 구현한다. 생성자는 레거시 인터페이스인 Stack을 구현하는 인스턴스를 받아들이는데, 이 인스턴스는 전용 필드(private field)에 저장되어 있으며 unwrap 메소드가 이 인스턴스를 리턴한다. 여기에서는 위임을 사용하고 있으므로 기반 레거시 스택에 대해 이루어진 모든 갱신내역은 래퍼로부터 제공되는 제네릭 스택의 관점을 통해 보여지게 될 것이다.
래퍼는 인터페이스의 각 메소드(empty, push, pop)를 대응되는 레거시 메소드에 대한 호출을 통해 구현하며 같은 방식으로 레거시 클래스(toString)내에 오버라이드된 Object 클래스의 각 메소드들도 구현한다. 최소 변경과 같이 리턴 타입이 타입 파라미터를 포함할 경우(pop에서처럼) 비확인 형변환을 리턴문에 추가하였다. 이러한 형변환을 하지 않으면 비확인 경고 대신 에러가 발생할 것이다.
하나의 래퍼는 동일한 인터페이스에 대한 여러 구현체를 충족시킬 것이다. 예를 들어, Stack의 구현체인 ArrayStack과 LinkedStack를 모두 가질 경우 둘 모두에 대하여 GenericStackWrapper을 사용할 수 있다.
새로운 편의 클래스(convenience class)인 GenericStacks는 레거시 클래스인 Stacks에 대한 위임을 통하여 구현된다. 제네릭 reverse 메소드는 인자를 언래핑하며 레거시 reverse 메소드를 호출하고, 그 결과를 래핑한다.
[예제 5-5]의 클라이언트에 필요한 변경사항은 굵은 글씨체로 나타나 있다.
래퍼는 최소 변경이나 스텁에 비해 상대적으로 몇 가지 단점을 가지고 있다. 래퍼는 레거시 인터페이스와 클래스로 이루어진 것과 제네릭 인터페이스와 클래스로 이루어진 두 개의 병렬 계층을 유지해야 할 필요가 있다. 이러한 두 계층간의 래핑과 언래핑을 통한 변환은 지겨운 작업이 될 수 있다. 레거시 클래스들이 적절히 제네릭화되면 중복된 래퍼들을 제거하는 추가 작업이 필요하게 될 것이다.
래퍼는 또한 더 난해하고 미묘한 문제를 제시한다. 코드에서 객체 동일성(object identity)을 이용할 경우 레거시 객체와 래핑된 객체가 구별되기 때문에 문제가 발생할지도 모른다. 나아가 복잡한 구조가 여러 개의 래퍼 계층을 필요로 하게 될 것이다. 이러한 기법을 스택의 스택에 적용시킨다고 상상해 보라! 여러분은 아마도 최상단의 스택으로 push되거나 최상단의 스택으로부터 pop되는 각각의 제2단계의 스택을 래핑하거나 언래핑하는 두 단계의 래퍼를 정의할 필요가 있을 것이다. 래핑된 객체와 레거시 객체가 뚜렷이 구별되므로 이는 래핑된 객체가 레거시 객체에 가해진 모든 변경사항을 확인하는 것을 항상 보장하는 것이 어려워지거나 심지어 불가능해질 수 있다.
자바의 제네릭 설계는 레거시 객체와 제네릭 객체가 동일함을 보장하여 래퍼와 관련된 이러한 모든 문제를 방지한다. C#의 제네릭 설계는 이와는 매우 다른데, 레거시 클래스와 제네릭 클래스가 완전히 구분되며, 그리고 레거시 컬렉션과 제네릭 컬렉션을 결합하려는 어떠한 시도도 여기에서 논의했던 래퍼와 관련된 문제를 겪게 할 것이다.
결론
복습해 보자면 우리는 라이브러리와 클라이언트의 제네릭과 레거시 버전을 모두 살펴보았다. 이것들은 동등한 클래스 파일을 생성하는데, 이는 진화를 매우 용이하게 해준다. 여러분은 제네릭 라이브러리에 레거시 클라이언트를 사용할 수 있거나 레거시 라이브러리에 제네릭 클라이언트를 사용할 수 있다. 후자의 경우 여러분은 최소한의 변경이나 스텁 파일을 사용하는 두 가지 방법 모두를 통해 레거시 라이브러리에 제네릭 메소드 서명을 갖도록 갱신할 수 있다.
이러한 모든 것들을 지원하는 초석은 erasure에 의한 제네릭 구현에 대한 결정이며, 따라서 제네릭 코드는 레거시 코드가 생성하는 것과 본질적으로 동일한 클래스 파일을 생성하며 이러한 특징을 바이너리 호환성이라 언급하였다. 보통 제네릭을 자연스러운 방법으로 추가하는 것은 레거시 버전과 제네릭 버전이 바이너리 호환성을 갖추도록 해준다. 그러나 경고가 필요한 꼼짝달싹 못하는 경우도 몇몇 있는데, 이러한 것들은 섹션 8.4에서 논의될 것이다.
자바와 C#의 제네릭 설계를 비교해보는 것도 흥미롭다. 자바에서는 배열이 런타임시 배열의 원소 타입에 관한 정보를 포함하는 반면 제네릭 타입은 런타임시 타입 파라미터에 관한 정보를 전달하지 않는다. C#에서는 제네릭 타입과 배열 모두 런타임시 파라미터와 원소 타입에 관한 정보를 포함한다. 각 접근법은 각기 장단점을 가진다. 다음 장에서는 C#에서는 발생하지 않지만, 자바에서 타입 파라미터에 관한 정보를 구체화하지 않음으로써 발생하는 형변환과 배열에 관련된 문제에 대해 알아볼 것이다. 한편 C#에서의 진화는 훨씬 더 쉽지 않다. 레거시 컬렉션과 제네릭 컬렉션은 완전히 구분되며 레거시 컬렉션과 제네릭 컬렉션을 결합하려는 어떠한 시도도 이전에 논의했던 래퍼와 관련된 문제를 겪게 할 것이다. 이와는 다르게 자바에서의 진화는 이미 살펴본 것과 같이 간단하다.
Maurice Naftalin는 영국의 컨설팅 기업인 Morningside Light Ltd.의 소프트웨어 개발부서장이다.
Philip Wadler는 스코틀랜드에 위치한 University of Edinburgh의 이론 컴퓨터 과학 전공 교수이며 그곳에서 함수형 프로그래밍과 논리형 프로그래밍에 초점을 맞춰 연구하고 있다.