Item39 - 명명 패턴보다 애너테이션을 사용하라

2023. 12. 17. 13:20Book/이펙티브 자바

옛날에는 도구나 프레임워크가 특별히 다뤄야 할 프로그램 요소에는 구분되는 명명 패턴을 적용했다고 한다.

예를 들어 JUnit은 버전 3까지 테스트 메서드 이름을 test로 시작하게끔 했다.

public TestClass extends TestCase {

	public void testHelloWorld() {
		System.out.println("Hello World");
	}
}

 

단점

1. 오타가 나면 안된다. : 프레임워크가 이 메서드를 무시하고 지나친다.

2. 올바른 프로그램 요소에서만 사용되리라 보증할 방법이 없다.

3. 프로그램 요소를 매개변수로 전달할 마땅한 방법이 없다.

 

애너테이션은 이 모든 문제를 해결해준다.

@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface Test {
}

 

@Retention(RetentionPolicy.RUNTIME) => 런타임에도 @Test가 유지

@Target(ElementType.METHOD) => 메서드에만 애너테이션을 붙일 수 있음

 

위 @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() { }
}

 

Sample 클래스에는 정적 메서드가 7개고, 그중 4개에 @Test를 달았다.

@Test 애너테이션이 Sample 클래스의 의미에 직접적인 영향을 주지는 않는다. 이 애터테이션에 관심 있는 프로그램에게 추가 정보를 제공할 뿐이다. 예를 한번 보자

 

 

 

public class RunTests {
    public static void main(String[] args) throws ClassNotFoundException {
        int tests = 0;
        int passed = 0;

        Class<?> testClass = Sample.class;
        for (Method m : testClass.getDeclaredMethods()) {
            //Test 어노테이션이 달려 있는지 확인
            if (m.isAnnotationPresent(Test.class)) {
                tests++;
                try {
                    //인스턴스를 넘기지 않았으므로 정적 메서드가 아니라면 에러가 발생한다.
                    //메서드 내에서 에러가 발생한다면 리플렉션 메커니즘이
                    //InvocationTargetException으로 감싸서 다시 던진다.
                    m.invoke(null);
                    passed++;
                } catch (InvocationTargetException wrappedExc) {
                    Throwable exc = wrappedExc.getCause();
                    System.out.println(m + " 실패 " + exc);
                } catch (Exception e) {
                    System.out.println("잘못 사용한 @Test: " + m);
                }
            }
        }
        System.out.printf("성공: %d, 실패: %d%n", passed, tests - passed);
    }
}
=>
public static void effective.study.chapter06.item39.test.Sample.m3() 실패 java.lang.RuntimeException: 실패
잘못 사용한 @Test: public void effective.study.chapter06.item39.test.Sample.m5()
public static void effective.study.chapter06.item39.test.Sample.m7() 실패 java.lang.RuntimeException: 실패
성공: 1, 실패: 3

 

 

 

다음은 특정 예외를 던져야만 성공하는 테스트를 지원해보자(매개변수 1개)

@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface ExceptionTest {
    Class<? extends  Throwable> value();
}

 

이 애너테이션은 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 RunTests2 {
    public static void main(String[] args) throws ClassNotFoundException {
        int tests = 0;
        int passed = 0;

        Class<?> testClass = Sample2.class;
        for (Method m : testClass.getDeclaredMethods()) {
            if (m.isAnnotationPresent(ExceptionTest.class)) {
                tests++;
                try {
                    m.invoke(null);
                    System.out.printf("테스트 %s 실패: 예외를 던지지 않음%n", m);
                } catch (InvocationTargetException wrappedExc) {
                    Throwable exc = wrappedExc.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 e) {
                    System.out.println("잘못 사용한 @ExceptionTest: " + m);
                }
            }
        }
        System.out.printf("성공: %d, 실패: %d%n", passed, tests - passed);
    }
}

=>
테스트 public static void effective.study.chapter06.item39.test.Sample2.m2() 실패 기대한 예외 java.lang.ArithmeticException 발생한 예외 java.lang.ArrayIndexOutOfBoundsException: Index 1 out of bounds for length 0
테스트 public static void effective.study.chapter06.item39.test.Sample2.m3() 실패: 예외를 던지지 않음
성공: 1, 실패: 2

 

이 코드는 애너테이션 매개변수의 값을(value()) 값을 추출하여 테스트 메서드가 올바른 예외를 던지는지 확인하는 데 사용한다.

 

 

 

이 예외 테스트에서 한 걸음 더 들어가, 예외를 여러 개 명시하고 그중 하나가 발생하면 성공하게 만들 수 있다.

@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface ExceptionListTest {
    Class<? extends  Throwable>[] value();
}

 

배열 매개변수를 맏는 애너테이션용 문법은 아주 유연하다. 단일 원소 배열에 최적화했지만, 앞서의 @ExceptionTest들도 모두 수정 없이 수용한다.

 

public class Sample3 {
    @ExceptionListTest({ IndexOutOfBoundsException.class,
                         NullPointerException.class })
    public static void m1() {
        List<String> list = new ArrayList<>();
        list.addAll(5, null);
    }
}

 

테스트 러너

public class RunTests3 {
    public static void main(String[] args) throws ClassNotFoundException {
        int tests = 0;
        int passed = 0;

        Class<?> testClass = Sample3.class;
        for (Method m : testClass.getDeclaredMethods()) {
            if (m.isAnnotationPresent(ExceptionListTest.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(ExceptionListTest.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);
    }
}
=>
성공: 1, 실패: 0

 

예외를 순회하면서 메서드 실행시 발생한 예외와 비교한다.

 

자바 8에서는 여러 개의 값을 받는 애너테이션을 다른 방식으로도 만들 수 있다.

@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface ExceptionContainer {
    ExceptionRepeatTest[] value();
}

컨테이너 애너테이션은 내부 애너테이션 타입의 배열을 반환하는 value() 메서드를 정의한다.

 

@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
@Repeatable(ExceptionContainer.class)
public @interface ExceptionRepeatTest {
    Class<? extends  Throwable> value();
}

 

@Repeatable에 이 컨테이너 애너테이션의 class 객체를 매개변수로 전달한다.

 

public class Sample4 {
    @ExceptionRepeatTest(IndexOutOfBoundsException.class)
    @ExceptionRepeatTest (NullPointerException.class)
    public static void m1() {
        List<String> list = new ArrayList<>();
        list.addAll(5, null);
    }
}

 

 

테스터 러너

public class RunRepeatTests {
    public static void main(String[] args) throws ClassNotFoundException {
        int tests = 0;
        int passed = 0;

        Class<?> testClass = Sample4.class;
        for (Method m : testClass.getDeclaredMethods()) {
            if (m.isAnnotationPresent(ExceptionContainer.class) ||
                m.isAnnotationPresent(ExceptionRepeatTest.class)) {
                tests++;
                try {
                    m.invoke(null);
                    System.out.printf("테스트 %s 실패: 예외를 던지지 않음%n", m);
                } catch (Throwable wrappedExc) {
                    Throwable exc = wrappedExc.getCause();
                    int oldPassed = passed;
                    ExceptionRepeatTest[] excTests =
                            m.getAnnotationsByType(ExceptionRepeatTest.class);
                    for (ExceptionRepeatTest excType : excTests) {
                        if(excType.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);
    }
}

=>
성공: 1, 실패: 0

 

반복 가능 애너테이션은 처리할 때 주의를 요한다.

반복 가능 애너테이션을 여러 개 달면 하나만 달았을 때와 구분하기 위해 해당 컨테이너 애너테이션 타입이 적용된다.

 

getAnnotationsByType 메서드는 이 둘을 구분하지 않아서 반복 가능 애너테이션과 그 컨테이너 애너테이션을 모두 가져온다.

 

isAnnotationPresent 메서드는 둘을 명확히 구분한다.

=> 반복 가능한 애너테이션을 여러번 단 다음 isAnnotationPresent로 반복 가능 애너테이션이 달렸는지 검사한다면 false가 나온다. (컨테이너가 달렸기 때문)

public class Sample4 {
    @ExceptionRepeatTest(IndexOutOfBoundsException.class)
    @ExceptionRepeatTest (NullPointerException.class)
    public static void m1() {
        List<String> list = new ArrayList<>();
        list.addAll(5, null);
    }
}
if (m.isAnnotationPresent(ExceptionContainer.class))  => true;
if (m.isAnnotationPresent(ExceptionRepeatTest.class)) => false;

 

=> 반복 가능한 애너테이션을 한번만 단 다음 isAnnotationPresent로 반복 가능 애너테이션이 달렸는지 검사한다면 true가 나온다.

public class Sample4 {
    @ExceptionRepeatTest (NullPointerException.class)
    public static void m1() {
        List<String> list = new ArrayList<>();
        list.addAll(5, null);
    }
}
if (m.isAnnotationPresent(ExceptionContainer.class))  => false;
if (m.isAnnotationPresent(ExceptionRepeatTest.class)) => true;