전통적으로 도구나 프레임워크가 특별히 다뤄야 할 프로그램 요소에는 딱 구분되는 명명 패텅능ㄹ 적용해왔다. 예를 들어 테스트 프레임워크인 JUnit
은 버전 3까지 테스트 메서드 이름을 test
로 시작하게 했다. 효과적인 방법이지만 단점도 컸다.
tsetSafetyOverride
로 지으면 JUnit 3
은 이 메서드를 무시하고 지나쳤고 개발자는 이 테스트가 통과했대고 오해할 수 있다.TestSafetyMechanisms
로 지어 JUnit
에 던져줬다고 해본다. 개발자는 이 클래스에 정의된 테스트 메서드들을 수행해주길 기대하겠지만 JUnit
은 클래스 이름에는 관심이 없다.위와 같은 문제를 해결해주는 개념으로 애너테이션이 있다. 애너테이션의 동작 방식을 아래의 코드로 설명할 것이다. 자동으로 수행되는 간단한 테스트용 애너테이션으로, 예외가 발생하면 해당 테스트를 실패로 처리한다.
import java.lang.annotation.*;
/**
* 테스트 메서드임을 선언하는 애너테이션이다.
* 매개변수 없는 정적 메서드 전용이다.
*/
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface Test{
}
@Test
애너테이션 타입 선언 자체에도 @Retention
애너테이션과 @Target
애너테이션이 달려 있다. 이처럼 애너테이션 선언에 다는 애너테이션을 메타애너테이션이라 한다. @Retention
은 @Test
의 유지 기간에 대한 것이다. @Target
은 @Test
애너테이션이 적용될 대상에 대한 것이다. 위에서는 메서드를 대상으로 하고 있어서 클래스 선언, 필드 선언 등 다른 프로그램 요소에는 달 수 없다.
앞 코드의 메서드 주석에는 "매개변수 없는 정적 메서드 전용이다"라고 쓰여있다. 이 제약을 컴파일러가 강제할 수 있으면 좋겠지만, 그렇게 하려면 적절한 애너테이션 처리기를 직접 구현해야 한다. 적절한 애너테이션 처리기 없이 인스턴스 메서드나 매개변수가 있는 메서드에 달면 컴파일은 잘 되지만, 테스트 도구를 실행할 때 문제가 된다.
@Test
같은 애너테이션을 "아무 매개변수 없이 단순히 대상에 마킹한다"는 뜻에서 마커 애너테이션이라 한다. 이 애너테이션을 사용하면 프로그래머가 Test
이름에 오타를 내거나 메서드 선언 외의 프로그램 요소에 달면 컴파일 오류를 낸다.
public class Sample {
@Test
public static void m1() { } // 성공해야 한다.
public static void m2() { }
@Test public static void m3() { // 실패해야 한다.
throw new RuntimeException("실패");
}
public static void m4() { } // 테스트가 아니다.
@Test public void m5() { } // 잘못 사용한 예: 정적 메서드가 아니다.
public static void m6() { }
@Test public static void m7() { // 실패해야 한다.
throw new RuntimeException("실패");
}
public static void m8() { }
}
@Test
애너테이션이 Sample
클래스에 직접적인 영향을 주지 않는다. 대상 코드의 의미는 그대로 둔 채 그 애너테이션에 관심 있는 도구에서 특별한 처리를 할 기회를 준다. 다음의 RunTests
가 그런 도구의 예다.
import java.lang.reflect.*;
public class RunTests {
public static void main(String[] args) throws Exception {
int tests = 0;
int passed = 0;
Class<?> testClass = Class.forName(args[0]);
for (Method m : testClass.getDeclaredMethods()) {
if (m.isAnnotationPresent(Test.class)) {
tests++;
try {
m.invoke(null);
passed++;
} catch (InvocationTargetException wrappedExc) {
Throwable exc = wrappedExc.getCause();
System.out.println(m + " 실패: " + exc);
} catch (Exception exc) {
System.out.println("잘못 사용한 @Test: " + m);
}
}
}
System.out.printf("성공: %d, 실패: %d%n",
passed, tests - passed);
}
}
이제 특정 예외를 던져야만 성공하는 테스트를 지원하기 위해 새로운 애너테이션 타입을 정의해본다.
import java.lang.annotation.*;
/**
* 명시한 예외를 던져야만 성공하는 테스트 메서드용 애너테이션
*/
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface ExceptionTest{
Class<? extends Throwable> value();
}
이 애너테이션의 매개변수 타입은 Class<? extends Throwable>
이다. 여기서 와일드카드 타입의 의미는 "Throwable
을 확장한 클래스의 Class
객체"라는 뜻이며, 따라서 모든 예외와 오류 타입을 다 수용한다. 이 애너테이션을 실제 적용한 코드와 테스트 도구를 수정한 것이다.
public class Sample2 {
@ExceptionTest(ArithmeticException.class)
public static void m1() { // 성공해야 한다.
int i = 0;
i = i / i;
}
@ExceptionTest(ArithmeticException.class)
public static void m2() { // 실패해야 한다. (다른 예외 발생)
int[] a = new int[0];
int i = a[1];
}
@ExceptionTest(ArithmeticException.class)
public static void m3() { } // 실패해야 한다. (예외가 발생하지 않음)
}
public class RunTests {
public static void main(String[] args) throws Exception {
int tests = 0;
int passed = 0;
Class<?> testClass = Class.forName(args[0]);
for (Method m : testClass.getDeclaredMethods()) {
if (m.isAnnotationPresent(Test.class)) {
tests++;
try {
m.invoke(null);
passed++;
} catch (InvocationTargetException wrappedExc) {
Throwable exc = wrappedExc.getCause();
System.out.println(m + " 실패: " + exc);
} catch (Exception exc) {
System.out.println("잘못 사용한 @Test: " + m);
}
}
if (m.isAnnotationPresent(ExceptionTest.class)) {
tests++;
try {
m.invoke(null);
System.out.printf("테스트 %s 실패: 예외를 던지지 않음%n", m);
} catch (InvocationTargetException wrappedEx) {
Throwable exc = wrappedEx.getCause();
Class<? extends Throwable> excType =
m.getAnnotation(ExceptionTest.class).value();
if (excType.isInstance(exc)) {
passed++;
} else {
System.out.printf(
"테스트 %s 실패: 기대한 예외 %s, 발생한 예외 %s%n",
m, excType.getName(), exc);
}
} catch (Exception exc) {
System.out.println("잘못 사용한 @ExceptionTest: " + m);
}
}
}
System.out.printf("성공: %d, 실패: %d%n",
passed, tests - passed);
}
}
예외를 여러 개 명시하고 그중 하나가 발생하면 성공하게 만들 수도 있다. 애너테이션 메커니즘에는 이런 쓰임에 아주 유용한 기능이 기본으로 들어있다. @ExceptionTest
애너테이션의 매개변수 타입을 Class
객체의 배열로 수정한다.
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface ExceptionTest{
Class<? extends Throwable>[] value();
}
배열 매개변수를 받는 애너테이션용 문법은 유연하다. 단일 원소 배열에 최적화했지만, 앞서의 @ExceptionTest
들도 모두 수정 없이 수용한다. 원소가 여럿인 배열을 지정할 때는 다음과 같이 원소들을 중괄호로 감싸고 쉼표로 구분해주기만 하면 된다.
@ExceptionTest({IndexOutOfBoundsException.class, NullPointerException.class})
public static void doublyBad(){
List<String> list = new ArrayList<>();
list.addAll(5, null);
}
테스트 도구를 수정한 코드다.
public class RunTests {
public static void main(String[] args) throws Exception {
int tests = 0;
int passed = 0;
Class<?> testClass = Class.forName(args[0]);
for (Method m : testClass.getDeclaredMethods()) {
if (m.isAnnotationPresent(Test.class)) {
tests++;
try {
m.invoke(null);
passed++;
} catch (InvocationTargetException wrappedExc) {
Throwable exc = wrappedExc.getCause();
System.out.println(m + " 실패: " + exc);
} catch (Exception exc) {
System.out.println("잘못 사용한 @Test: " + m);
}
}
// 배열 매개변수를 받는 애너테이션을 처리하는 코드 (243쪽)
if (m.isAnnotationPresent(ExceptionTest.class)) {
tests++;
try {
m.invoke(null);
System.out.printf("테스트 %s 실패: 예외를 던지지 않음%n", m);
} catch (Throwable wrappedExc) {
Throwable exc = wrappedExc.getCause();
int oldPassed = passed;
Class<? extends Throwable>[] excTypes =
m.getAnnotation(ExceptionTest.class).value();
for (Class<? extends Throwable> excType : excTypes) {
if (excType.isInstance(exc)) {
passed++;
break;
}
}
if (passed == oldPassed)
System.out.printf("테스트 %s 실패: %s %n", m, exc);
}
}
}
System.out.printf("성공: %d, 실패: %d%n",
passed, tests - passed);
}
}
자바 8에서는 여러 개의 값을 받는 애너테이션을 다른 방식으로도 만들 수 있다. 배열 매개변수를 사용하는 대신 애너테이션에 @Repaetable
매타애너테이션을 다는 방식이다. @Repeatable
을 단 애너테이션은 하나의 프로그램 요소에 여러 번 달 수 있다. 주의할 점이 있는데 아래와 같다.
@Repaetable
을 단 애너테이션을 반환하는 '컨테이너 애너테이션'을 하나 더 정의하고, @Repaetable
에 이 컨테이너 애너테이션의 class
객체를 매개변수로 전달해야 한다.value
메서드를 정의해야 한다.@Retention
)과 적용 대상(@Target
)을 명시해야 한다. 그렇지 않으면 컴파일되지 않을 것이다.@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
@Repeatable(ExceptionTestContainer.class)
public @interface ExceptionTest{
Class<? extends Throwable> value();
}
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface ExceptionTestContainer{
ExceptionTest[] value();
}
적용한 코드는 아래와 같다.
@ExceptionTest(IndexOutOfBoundsException.class)
@ExceptionTest(NullPointerException.class)
public static void doublyBad() {
List<String> list = new ArrayList<>();
// 자바 API 명세에 따르면 다음 메서드는 IndexOutOfBoundsException이나
// NullPointerException을 던질 수 있다.
list.addAll(5, null);
}
@Repeatable
애너테이션을 처리할 때 주의사항이 있다. @Repeatable
애너테이션을 여러 개 달면 하나만 달았을 때와 구분하기 위해 해당 '컨테이너' 애너테이션 타입이 적용된다. getAnnotationsByType
메서드는 여러 개 달은 경우와 하나를 달은 경우를 구분하지 않아서 @Repeatable
애너테이션과 그 컨테이너 애너테이션을 모두 가져오지만, isAnnotationPresent
메서드는 두 경우를 명확히 구분한다. 따라서 @Repaetable
애너테이션을 여러 번 단 다음 isAnnotationPresent
로 @Repeatable
애너테이션이 달렸는지 검사한다면 "그렇지 않다"고 알려준다. 그 결과 애너테이션을 여러 번 단 메서드들을 모두 무시하고 지나친다. 같은 이유로 isAnnotationPresent
로 컨테이너 애너테이션이 달렸는지 검사한다면 @Repeatable
애너테이션을 한 번만 단 메서드를 모두 무시하고 지나친다. 그래서 달려 있는 수와 상관없이 모두 검사하려면 둘을 따로따로 확인해야 한다. 다음은 테스트도구를 수정한 코드다.
public class RunTests {
public static void main(String[] args) throws Exception {
int tests = 0;
int passed = 0;
Class testClass = Class.forName(args[0]);
for (Method m : testClass.getDeclaredMethods()) {
if (m.isAnnotationPresent(Test.class)) {
tests++;
try {
m.invoke(null);
passed++;
} catch (InvocationTargetException wrappedExc) {
Throwable exc = wrappedExc.getCause();
System.out.println(m + " 실패: " + exc);
} catch (Exception exc) {
System.out.println("잘못 사용한 @Test: " + m);
}
}
// 코드 39-10 반복 가능 애너테이션 다루기 (244-245쪽)
if (m.isAnnotationPresent(ExceptionTest.class)
|| m.isAnnotationPresent(ExceptionTestContainer.class)) {
tests++;
try {
m.invoke(null);
System.out.printf("테스트 %s 실패: 예외를 던지지 않음%n", m);
} catch (Throwable wrappedExc) {
Throwable exc = wrappedExc.getCause();
int oldPassed = passed;
ExceptionTest[] excTests =
m.getAnnotationsByType(ExceptionTest.class);
for (ExceptionTest excTest : excTests) {
if (excTest.value().isInstance(exc)) {
passed++;
break;
}
}
if (passed == oldPassed)
System.out.printf("테스트 %s 실패: %s %n", m, exc);
}
}
}
System.out.printf("성공: %d, 실패: %d%n",
passed, tests - passed);
}
}
이번 아이템의 테스트 프레임워크에서 애너테이션이 명명패턴보다 낫다는 점은 확실히 보여준다. 애너테이션으로 할 수 있는 일을 명명 패턴으로 처리할 이유는 없다.
도구 제작자를 제외하고는, 일반 프로그래머가 애너테이션 타입을 직접 정의할 일을 거의 없다. 하지만 자바 프로그래머라면 예외 없이 자바가 제공하는 애너테이션 타입들을 사용해야 한다.