전통적으로 도구나 프레임워크가 특별히 다뤄야 할 프로그램 요소에는 딱 구분되는 명명 패턴을 적용해왔다.
테스트 프레임워크인 JUnit은 버전 3까지 테스트 메서드 이름을 test로 시작하게끔 했다.
효과적인 방법이지만 단점도 크다.
첫째, 오타가 나면 안된다. 실수로 이름을 test~~...으로 지으면 JUnit3는 이 메서드를 무시하고 지나치기 때문에 개발자는 이 테스트가 통과했다고 오해할 수 있다.
둘째, 올바른 프로그램 요소에서만 사용되리라고 보증할 수 없기 때문이다.
셋째, 프로그램 요소를 매개변수로 전달할 마땅한 방법이 없다는 것이다.
특정 예외를 던져야만 성공하는 테스트가 있다고 해보자.
기대하는 예외타입을 테스트에 매개변수로 전달해야 하는 상황이다.
예외의 이름을 테스트 메스드 이름에 덧붙이는 방법도 있지만, 보기도 나쁘고 깨지기도 쉽다.
애너테이션은 이 모든 문제를 해결해주는 개념으로, JUnit도 4버전 부터 도입하였다.
import java.lang.annotation.*;
/**
* 테스트 메서드임을 선언하는 애너테이션이다.
* 매개변수 없는 정적 메서드 전용이다.
*/
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface Test {
}
마커 애너테이션 타입 선언.
@Test 애너테이션 타입 선언 자체에도 두가지의 다른 애너테이션이 달려 있다.
이처럼 애너테이션 선언에 다는 애너테이션을 메타애너테이션이라 한다.
@Retention ( RetentionPolicy.RUNTIME ) 메타애너테이션은 @Test가 런타임에도 유지되어야 한다는 표시다.
만약 이 메타애너테이션을 생략하면 테스트 도구는 @Test를 인식할 수 없다.
@Target(ElementType.METHOD) 메타애너테이션은 @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() { }
}
1개는 성공, 2개는 실패, 1개는 잘못 사용했다.
@Test를 붙이지 않은 나머지 4개의 메서드를 테스트 도구가 무시할 것이다.
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);
}
}
이 클래스는 명령줄로부터 완전 정규화된 클래스 이름을 받아, 그 클래스에서 @Test 애너 테이션이 달린 메서드를 차례로 호출한다.
isAnnotationPresent가 실행할 메서드를 찾아주는 메서드다.
테스트 메서드가 예외를 던지면 리플렉션 매커니즘이 InvocationTargetException으로 감싸서 다시 던진다.
그 후 실패 정보를 출력한다.
InvocationTargetException 외의 예외가 발생한다면 @Test 애너테이션을 잘 못 사용했다는 뜻이다.
아마도 인스턴스 메서드, 매개변수가 있는 메서드, 호출할 수 없는 메서드 등에 달았을 것이다.
/**
* 명시한 예외를 던져야만 성공하는 테스트 메서드용 애너테이션
*/
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface ExceptionTest {
Class<? extends Throwable> value();
}
이 애너테이션의 매개변수 타입은 Class<? extends Throwable>이다.
여기서의 와일드카드 타입은 Throwable을 상속받은 Class 객체 라는 뜻이고, 모든 예외 타입을 다 수용한다.
이는 아이템 33의 한정적 타입 토큰의 또 하나의 활용 사례다.
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() { } // 실패해야 한다. (예외가 발생하지 않음)
}
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);
}
}
이 코드는 애너테이션 매개변수의 값을 추출하여 테스트 메서드가 올바른 예외를 던지는지 확인하는 데 사용한다.
형변환 코드가 없으니 ClassCastException 걱정이 없다.
따라서 테스트 프로그램이 문제없이 컴파일되면 애너테이션 매개변수가 가리키는 예외가 올바른 타입이라는 뜻이다.
여기서 한걸음 더 들어가, 예외 여러개를 명시하고 그중 하나가 발생하면 성공하게 만들 수도 있다.
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface ExceptionTest {
Class<? extends Throwable>[] value();
}
매개변수 타입을 Class 객체의 배열로 수정했다.
배열 매개변수를 받는 애너테이션용 문법은 아주 유연하다.
단일 원소 배열에 최적화했지만, 앞서 @ExceptionTest들도 모두 수정 없이 수용한다.
원소가 여럿인 배열을 지정할 때는 다음과 같이 원소들을 중괄호로 감싸고 쉼표로 구분해주기만 하면 된다.
@ExceptionTest({ IndexOutOfBoundsException.class, NullPointerException.class })
public static void doublyBad() {
List<String> list = new ArrayList<>();
// 자바 API 명세에 따르면 다음 메서드는 위의 2개를 던질 수 있다.
list.addAll(5, null);
}
자바 8에서는 여러 개의 값을 받는 애너테이션을 다른 방식으로도 만들 수 있다.
배열 매개변수를 사용하신 대신 애너테이션에 @Repeatable을 단 메타애너테이션을 다는 방식이다.
@Repeatable을 단 애너테이션은 하나의 프로그램 요소에 여러 번 달 수 있다.
// 반복 가능한 애너테이션
@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();
}
단, 주의할 점이 있다.
@Repeatable을 단 애너테이션을 반환하는 '컨테이너 애너테이션'을 하나 더 정의하고,
@Repeatable에 이 컨테이너 애너테이션의 class 객체를 매개변수로 전달해야 한다.
두 번째, 컨테이너 애너테이션은 내부 애너테이션 타입의 배열을 반환하는 value 메서드를 정의해야 한다.
마지막으로, 컨테이너 애너테이션 타입에는 적절한 보존 정책과 적용 대상을 명시해야 한다.
그렇지 않으면 컴파일 되지 않을 것이다.
@ExceptionTest(IndexOutOfBoundsException.class)
@ExceptionTest(NullPointerException.class)
public static void doublyBad() { ... }
이것을 적용한 코드이다.
반복 가능 애너테이션은 처리할 때도 주의를 요한다.
반복 가능 애너테이션을 여러 개 달면 하나만 달았을 때와 구분하기 위해 해당 '컨테이너' 애너테이션 타입이 적용된다.
getAnnotationsByType 메서드는 이 둘을 구분하지 않아서 반복 가능 애너테이션과 그 컨테이너 애너테이션을 모두 가져오지만,
isAnnotationPresent 메서드는 둘을 명확히 구분한다.
따라서 반복가능 애너테이션을 여러 번 단 다음 isAnnotationPresent로 반복 가능 애너테이션이 달렸는지 검사하면 그렇지 않다 라고 알려준다.
도구 제작자를 제외하고, 일반 프로그래머가 애너테이션 타입을 직접 정의할 일은 거의 없다.
하지만 자바 프로그래머라면 예외 없이 자바가 제공하는 애너테이션 타입들은 사용해야 한다.
@Retention :
- RetentionPolicy.SOURCE : 소스 코드(.java)까지 남아있는다.
- 롬복의 @Getter, @Setter의 내부는 해당 정책으로 되어있다.
- java 소스 파일까지 남아있고 ,컴파일되어 클래스 파일이 되면 사라진다.
- 하지만 @Getter가 컴파일될 때 사라지며, 실제 getter 코드가 바이트코드로 생성된다. (디컴파일 시 @Getter 애노테이션은 사라지지만 소스에 작성하지 않았던 getter 메서드가 생성됨
- RetentionPolicy.CLASS : 클래스 파일(.class)까지 남아있는다.(=바이트 코드)
- 롬복의 @NotNull 은 해당 적책으로 되어있다.
- .class 파일까지는 애노테이션이 살아있고, 런타임에서 클래스로더가 해당 클래스를 읽어오면 사라진다.
- 레포지토리에서 다운받는 라이브러리와 같이, jar 파일에는 소스가 포함되어있지않고 class파일만 포함되어 있다. 즉, class파일만 존재하는 라이브러리 같은 경우에도 타입체커, IDE 부가기능 등을 사용할 수 있으려면 CLASS 정책이 필요하다. SOURCE 정책으로 사용한다면 컴파일 된 라이브러리의 jar파일에는 어노테이션 정보가 남아있지 않기 때문이다.
- RetentionPolicy.RUNTIME : 런타임까지 남아있는다.(=사실상 안 사라진다.)
질문1:
질문2:
'JAVA > 이펙티브 자바' 카테고리의 다른 글
41. 정의하려는 것이 타입이라면 마커 인터페이스를 사용하라 ( 마커 인터페이스 ) (0) | 2023.07.24 |
---|---|
40. @Override 애너테이션을 일관되게 사용하라 ( @Override ) (0) | 2023.07.24 |
38. 확장할 수 있는 열거 타입이 필요하면 인터페이스를 사용하라 (0) | 2023.07.20 |
37. ordinal 인덱싱 대신 EnumMap을 사용하라 (0) | 2023.07.18 |
35. ordinal 메서드 대신 인스턴스 필드를 사용하라 (0) | 2023.07.18 |