[교재 EffectiveJava] 아이템 39. 명명 패턴보다 애너테이션을 사용하라

반응형
728x90
반응형

명명패턴의 단점

1. JUnit3까지 테스트 메서드 이름은 testxx로 시작해야했다. 만일 오타가 발생하여 tsetxx로 메서드명을 지정한다면, 이 메서드는 무시되어 테스트가 통과되었다고 오해할 수 있다.

 

2. 올바른 프로그램 요소에서만 사용되리라는 보증할 방법이 없다. JUnit3 이하에서 클래스 이름을 Testxx로로 던져줬다고 해보자. 개발자는 이 클래스에 정의된 테스트 메서드들을 수행해주길 기대하겠지만 JUnit은 클래스 이름에는 관심이 없다.

 

3. 프로그램 요소를 매개변수로 전달할 마땅한 방법이 없다. 특정 예외를 던져야만 성공하는 테스트가 있을 때, 기대하는 예외 타입을 테스트에 매개변수로 전달해야한다. 예외의 이름을 테스트 메서드 이름에 덧붙이는 방법도 있지만 이는 보기도 나쁘고 깨지기도 쉽다. 컴파일러는 메서드 이름에 덧붙인 문자열이 예외를 가리키는지 알 도리가 없다.

 

 

애노테이션 사용

JUnit4의 도입
import java.lang.annotation.*;

/**
* 테스트 메서드임을 선언하는 애노테이션
* 매개변수 없는 정적 메서드 전용 -> 적절한 어노테이션이 없으므로, 직접 구현하여 처리하면 좋다.
*/
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface Test{}

 

메타 애노테이션

애노테이션 선언에 다는 애터네이션

 

@Retention(RetentionPolicy.RUNTIME)

@Test가 런타임에도 유지되어야 한다는 표시다. 만약 이 매타에너테이션을 생략하면 테스트 도구는 @Test 를 인식할 수 없다.

 

@Target(ElementType.METHOD)

@Test가 반드시 메서드 선언에서만 사용돼야 한다고 알려준다. 

 

주석에도 있듯이, '매개변수 없는 정적 메서드 전용' 이라는 내용도 어노테이션 처리를 직접 구현해서 처리할 수 있다.

 

@Test 애노테이션을 실제 적용한 모습
public class Sample {
    @Test
    public static void m1() { 
        // 성공해야 한다.
    }

    public static void m2() {
        // @Test 가 붙어있지 않은 메서드는 무시    
    }

    @Test
    public static void m3() { 
        // 실패해야 한다.
        throw new RuntimeException("실패");
    }

    public static void m4() {
        // @Test 가 붙어있지 않은 메서드는 무시
    }

    @Test
    public void m5() { 
        // 잘못 사용한 예: 정적 메서드가 아니다.
    }

    public static void m6() {
        // @Test 가 붙어있지 않은 메서드는 무시
    }

    @Test
    public static void m7() { 
        // 실패해야 한다.
        throw new RuntimeException("실패");
    }

    public static void m8() {
        // @Test 가 붙어있지 않은 메서드는 무시
    }
}

Sample 클래스에는 정적 메서드가 7개고, 그 중 4개에 @Test를 달았다. m3, m7 메서드는 예외를 던지고 m1, m5는 그렇지 않다. m5는 정적 메서드가 아닌 메서드로, @Test를 클래스 의도와 잘못 사용한 경우다. 이는 총 4개의 테스트 메서드 중 1개는 성공, 2개는 실패, 1개는 잘못 사용했다. @Test 가 붙어있지 않은 메서드는 무시될 것이다.

 

@Test 어노테이션은 Sample 클래스의 의미에 직접적인 영향을 주지 않는다. 

 

마커 애노테이션 처리하는 프로그램
ublic class RunTests {
    public static void main(String[] args) throws ClassNotFoundException, InvocationTargetException, IllegalAccessException {
        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 exception) {
                    System.out.println("잘못 사용한 @Test: " + m);
                }
            }
        }
        
        System.out.printf("성공: %d, 실패: %d%n", passed, tests - passed);
    }
}

명령줄로부터 완전 정규화된 클래스 이름을 받아, 그 클래스에서 @Test 애노테이션이 달린 메서드를 차례로 호출한다. 실행할 메서드를 찾는 메서드가 isAnnotationPresent() 이다. 예외 발생 케이스에 따라 결과를 출력해준다. 

 

InvocationTargetException 예외가 발생한다면 @Test 어노테이션을 잘못 사용했다는 뜻이다. 아마도 인스턴스 메서드, 매개변수가 있는 메서드, 호출할 수 없는 메서드 등에 달았을 것이다. 

 

 

특정 예외 처리 애노테이션 

이제 특정 예외를 던져야만 성공하는 테스트를 지원해보자.

 

매개변수 하나를 받는 애노테이션 타입
import java.lang.annotation.*;

/**
 * 명시한 예외를 던저야만 성공하는 테스트 메서드 애노테이션
 */
 
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface ExceptionTest {
    Class<? extends Throwable> value();
}

이 어노테이션의 매개변수 타입은 Class<? extends Throwable>이다. 여기서의 와일드카드 타입은 "Throwable을 확장한 클래스의 Class 객체"라는 뜻이며, 모든 예외와 오류 타입을 다 수용한다. 이는 한정적 타입 토큰의 활용 사례다. 

 

매개변수 하나짜리 어노테이션을 사용한 프로그램 
class Sample2 {
    @MadExceptionTest(ArithmeticException.class)
    public static void m1() {
        int i = 0;
        i = i / i;
        
        // 성공해야한다.
    }

    @MadExceptionTest(ArithmeticException.class)
    public static void m2() {
        int[] a = new int[0];
        a[1] = 2;
        
        // 실패해야한다. (다른 예외가 발생)
    }

    @MadExceptionTest(ArithmeticException.class)
    public static void m3() {
        // 실패해야한다. (예외가 발생하지 않으므로)
    }
}

이제 이 어노테이션을 다룰 수 있도록 테스트 도구를 수정해보자.

public class RunTests {
    public static void main(String[] args) throws ClassNotFoundException, InvocationTargetException, IllegalAccessException {
        int tests = 0;
        int passed = 0;
        Class<?> testClass = Class.forName(args[0]);
        
        for (Method m : testClass.getDeclaredMethods()) {
            if (m.isAnnotationPresent(ExceptionTest.class)) {
                tests++;
                try {
                    m.invoke(null);
                    System.out.printf("테스트 %s 실패: 예외를 던지지 않음%n", method);
                } 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 e) {
                    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({IndexOutOfBoundsException.class, NullPointerException.class})
public static void doublyBad() {
    // 성공해야한다.
    List<String> list = new ArrayList<>();
    
    // 자바 API 명세에 따르면, 
    // IndexOutOfBoundsException, NullPointerException을 던질 수 있다.
    list.addAll(1, null);
}

 

테스트 러너
public class RunTests {
    public static void main(String[] args) throws ClassNotFoundException, InvocationTargetException, IllegalAccessException {
        int tests = 0;
        int passed = 0;
        Class<?> testClass = Class.forName(args[0]);
        
        for (Method m : testClass.getDeclaredMethods()) {
            if (m.isAnnotationPresent(ExceptionTest.class)) {
                tests++;
                try {
                    m.invoke(null);
                    System.out.printf("테스트 %s 실패: 예외를 던지지 않음%n", method);
                } catch (InvocationTargetException wrappedEx) {
                    Throwable exc = wrappedEx.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", method, ex);
                    }
                } catch (Exception e) {
                    System.out.println("잘못 사용한 @ExceptionTest: " + m);
                }
        }
        
        System.out.printf("성공: %d, 실패: %d%n", passed, tests - passed);
    }
}

 

 

@Repeatable 메타 애노테이션

여러 개의 값을 받는 어노테이션을 다른 방식으로도 만들 수 있다. 배열 매개변수를 사용하는 대신 애노테이션에 @Repeatable 메타 애노테이션을 다는 방식이다.

 

@Repeatable을 단 어노테이션은 하나의 프로그램 요소에 여러번 달 수 있다.

* 주의점
1) @Repeatable을 단 애노테이션을 반환하는 '컨테이너 애노테이션'을 하나 더 정의하고, @Repeatable에 이 컨테이너 애노테이션의 class 객체를 매개변수로 전달해야한다.

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

3) 컨테이너 애노테이션 타입에는 적절한 보존 정책(@Retention)과 적용 대상(@Target)을 명시해야한다. 그렇지 않으면 컴파일이 되지 않는다.

 

반복 가능한 애노테이션 타입
// 반복 가능한 애노테이션
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
@Repeatable(ExceptionContainer.class)
public @interface ExceptionTest {
    Class<? extends Throwable> value();
}

// 컨테이너 애노테이션
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface ExceptionTestContainer {
    ExceptionTest[] value();
}

 

위 애노테이션 적용
@ExceptionTest(NullPointerException.class)
@ExceptionTest(IndexOutOfBoundsException.class)
public static void doublyBad() { ... }

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

 

getAnnotationsByType()

반복 가능 애노테이션, 컨테이너 어노테이션 구분 없이 모두 가져온다.

 

isAnnotationPresent()

반복 가능 어노테이션, 컨테이너 어노테이션을 명확히 구분하여, 반복 가능 어노테이션을 여러번 단 다음 해당 메서드로 반복 가능 어노테이션이 달렸는지의 여부를 확인한다면 "그렇지 않다"라고 알려준다. (컨테이너가 달렸기 때문이다.) 그 결과 애노테이션을 여러번 단 메서드들을 모두 무시하고 지나친다. 같은 이유로, isAnnotationPresend()로 컨테이너 어노테이션이 달렸는지 검사한다면 반복 가능 애노테이션을 한번만 단 메서드를 무시하고 지나친다. 그래서 달려있는 수와 상관없이 모두 검사하려면 따로따로 확인해야한다.

 

반복 가능 어노테이션 다루기
public class RunTests {
    public static void main(String[] args) throws ClassNotFoundException, InvocationTargetException, IllegalAccessException {
        int tests = 0;
        int passed = 0;
        Class<?> testClass = Class.forName(args[0]);
        
        for (Method m : testClass.getDeclaredMethods()) {
            if (m.isAnnotationPresent(ExceptionTest.class)) {
                tests++;
                try {
                    m.invoke(null);
                    System.out.printf("테스트 %s 실패: 예외를 던지지 않음%n", method);
                } catch (InvocationTargetException wrappedEx) {
                    Throwable exc = wrappedEx.getCause();
                    int oldPassed = passed;
                    ExceptionTest[] excTests = m.getAnnotationByType(ExceptionTest.class);
                    
                    for (ExceptionTest excTest : excTests) {
                        if (excTest.value().isInstance(exc)) {
                            passed++;
                            break;
                        }
                    }
                    if (passed == oldPassed) {
                        System.out.printf("테스트 %s 실패: %s %n", method, ex);
                    }
                } catch (Exception e) {
                    System.out.println("잘못 사용한 @ExceptionTest: " + m);
                }
        }
        
        System.out.printf("성공: %d, 실패: %d%n", passed, tests - passed);
    }
}

 

 

정리

이번 아이템은 명명 패턴보다 애노테이션의 사용이 더 낫다는 점을 보여준다. 애노테이션으로 할수 있는 일을 명명 패턴으로 처리할 이유가 없다.

 

 

반응형

Designed by JB FACTORY