[Effective Java 3/E] ITEM 39. 명명 패턴보다 애너테이션을 사용하라

    Effective Java 표지. Source : https://blog.insightbook.co.kr/

    *알림 : *
    Effective Java 3판은 Java 9까지 도입된 언어적 기능을 중심으로 서술되어 있습니다. 10버젼 이후의 Java 개발을 하시는 분들은 우회적인 접근법 대신 Java 언어 내 새로 도입된 기능이 더 간결하고 좋을 수 있습니다.

    해당 포스팅은 SSAFY 내 책읽기 스터디의 활동을 통해 작성된 포스팅입니다.
    https://github.com/kjsu0209/JavaBook
    https://medium.com/javabook

     

    JavaBook – Medium

    Documentation space of our book study.

    medium.com

     

    kjsu0209/JavaBook

    책읽기 스터디. Contribute to kjsu0209/JavaBook development by creating an account on GitHub.

    github.com

     

    유틸이나 프레임워크에서 특별히 다뤄야 할 프로그램 요소에 특정한 이름짓는 규칙을 적용하는 명명 패턴이 있다.

    테스트 프레임워크인 JUnit은 test로 시작하는 메서드를 ㅌ베스트 메서드로 인식하게끔 구현되어있다. 그러나 문제점이 있다.

    1. 오타가 나면 안된다
    2. tsetBlahBlah와 같이 오타가 난다면 JUnit 3은 이 메서드를 테스트로 취급하지 않는다. 테스트를 무시하지만 별다른 오류를 띄우지 않기 때문에 개발자는 테스트가 통과했다는 착각을 가질 수 있다.
    3. 개발자가 올바른 프로그램 요소에 사용하리라는 보장이 없다.
    4. 클래스 이름을 TestSafetyMechanisms로 지어서 개발자는 안에 있는 메서드를 테스트 취급하여 수행해주길 바라지만 JUnit 3은 여기에 관심이 없다.
    5. 프로그램 요소를 매개변수로 전달할 방법이 없다.
    6. 특정 예외를 던져야 성공하는 테스트를 수행하고자 하지만, 프로그래머가 함수를 호출하는게 아닌 JUnit이 테스트 메서드를 호출하기 때문에 매개변수를 전달할 방도가 없다.

     

    annotation은 이 문제를 해결하기 위해 도입되었다.

     

    예제 1 : Test annotation

    import java.lang.annotation.*;
    
    /**
     * 테스트 메서드임을 선언하는 애너테이션이다.
     * 매개변수 없는 정적 메서드 전용이다.
     */
    @Retention(RetentionPolicy.RUNTIME)
    @Target(ElementType.METHOD)
    public @interface Test {
    }

    annotation 선언에 annotation이 달려있는 것을 meta-annotation이라 한다.

     

    @Retension

    이 annotation이 선언된 annotation은 @Test가 런타임에도 유지되어야 한다는 표시다.

     

    @Target (ElementType.METHOD)

    이 annotation이 선언된 annotation은 메서드 선언에 사용되어야 함을 표기한다.

    이 예제에서는 개발자가 주석으로 매개변수 없는 정적 메서드에만 부착하도록 구현했다는 의도를 달아놓았지만, 인스턴스 메서드나 매개변수가 있는 메서드에 달 수도 있다. 컴파일러는 이러한 제약을 알 길이 없기 때문이다. 만약 컴파일러가 검사하는 이러한 제약을 구현하고자 한다면 javax.annotation.processing API 문서를 참고하면 된다. 그리고 해당 Test annotation은 아무런 역할을 하지 않아 marker annotation이라고 부른다.

     

    예제 2 : Marker Annotation 적용 예시

    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("Crash");
        }
        public static void m8() { }
    }
    

    위에서 언급한 것 처럼 프로그래머가 정적 메서드에만 부착하도록 annotation을 구현하였더라도 별도의 annotation 처리기를 구현하지 않았기에 m5()와 같은 경우가 발생할 수 있다. 컴파일 타임에 에러가 발생하지 않지만, 테스트 과정에 오류가 발생할 수 있다. https://stackoverflow.com/questions/2467544/invoking-a-static-method-using-reflection/2467562

     

    @Test annotation은 Sample 클래스에 아무런 영향을 끼치지 않고, 그냥 테스트를 수행하도록 표시해놓는 역할만을 수행한다. 테스트를 수행하는 프로그램 요소가 이 마커를 보고 테스트를 수행할 것이다.

     

    예제 3 : Marker Annotation을 처리하는 프로그램

    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);
        }
    }

    m.isAnnotationPresent(Test.class)를 통해 해당 메서드에 @Test annotation이 달려있는 메서드를 m.invoke()로 호출한다.

    만약 테스트가 예외를 던지면 Java Reflection이 InvocationTargetException을 catch하여 실패 정보를 getCause()로 추출하여 출력한다. 만약 InvocationTargetException 이외의 예외가 발생한다면 @Test annotation을 잘못 사용하였다는 의미다.

    참고 : 자바의 리플렉션(강관우님) https://brunch.co.kr/@kd4/8

    위 예제는 허용되는 아무 예외나 던져도 받아들인다. 만약 특정 예외를 던지면 성공하는 테스트는 어떻게 작성할까?

     

    예제 4 : 매개변수를 받는 annotation 타입

    import java.lang.annotation.*;
    
    /**
     * 명시한 예외를 던져야만 성공하는 테스트 메서드용 애너테이션
     */
    @Retention(RetentionPolicy.RUNTIME)
    @Target(ElementType.METHOD)
    public @interface ExceptionTest {
        Class<? extends Throwable> value();//annotation의 매개변수
    }

    annotation의 매개변수는 Throable을 확장한 클래스의 객체를 받는다.

     

    예제 5 : 매개변수 하나짜리 annotation을 사용한 프로그램

    import java.util.*;
    
    // 코드 39-5 매개변수 하나짜리 애너테이션을 사용한 프로그램 (241쪽)
    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(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);
        }
    }

    m.getAnnotation(ExceptionTest.class).value();를 통해 annotation의 매개변수 값을 추출하고, 발생한 예외와 비교하여 올바른 예외를 던졌는지 확인한다.

    하나의 예외가 아니라 여러개의 예외 중 하나가 발생했는지 알고 싶다면?

     

    예제 6 : 배열 매개변수를 받는 annotation

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

     

    예제 7 : 배열 매개변수를 받는 annotation을 사용하는 코드

    public class Sample3 {
        // 이 변형은 원소 하나짜리 매개변수를 받는 애너테이션도 처리할 수 있다. (241쪽 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() { }  // 실패해야 한다. (예외가 발생하지 않음)
    
        // 코드 39-7 배열 매개변수를 받는 애너테이션을 사용하는 코드 (242-243쪽)
        **@ExceptionTest({ IndexOutOfBoundsException.class,
                         NullPointerException.class })**
        public static void doublyBad() {   // 성공해야 한다.
            List<String> list = new ArrayList<>();
    
            // 자바 API 명세에 따르면 다음 메서드는 IndexOutOfBoundsException이나
            // NullPointerException을 던질 수 있다.
            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()) {
                // 배열 매개변수를 받는 애너테이션을 처리하는 코드 (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);
        }
    }

    @ExceptionTest({ IndexOutOfBoundsException.class,NullPointerException.class })와 같이 중괄호로 감싸고 쉼표로 구분해주기만 하면 된다.

    여러 값을 받는 annotation을 다른 방식으로도 만들 수 있다.

    @Repeatable meta-annotation을 다는게 그 방법이다.

     

    주의할 점도 있다

    1. @Repeatable을 단 annotation을 반환하는 '컨테이너 annotation'을 하나 더 정의하고, @Repeatable에 컨테이너 annotation의 class 객체를 매개변수로 전달해야 한다.
    2. 컨테이너 annotation은 내부 annotation의 타입의 배-열을 반환하는 .value() 메서드를 정의해야 한다.
    3. 컨테이너 annotation 타입에는 @Retention과 @Target을 명시해야 한다. 그렇지 않다면 컴파일에 문제가 생긴다.

     

    예제 8 : @Repeatable을 사용한 annotation 타입

    import java.lang.annotation.*;
    
    // 반복 가능한 애너테이션의 컨테이너 애너테이션 (244쪽)
    @Retention(RetentionPolicy.RUNTIME)
    @Target(ElementType.METHOD)
    public @interface ExceptionTestContainer {
        ExceptionTest[] value();
    }
    
    @Retention(RetentionPolicy.RUNTIME)
    @Target(ElementType.METHOD)
    @Repeatable(ExceptionTestContainer.class)
    public @interface ExceptionTest {
        Class<? extends Throwable> value();
    }

     

    예제 9 : 반복 가능한 annotation을 두 번 단 코드

    public class Sample4 {
        @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() { }  // 실패해야 한다. (예외가 발생하지 않음)
    
        // 코드 39-9 반복 가능 애너테이션을 두 번 단 코드 (244쪽)
        **@ExceptionTest(IndexOutOfBoundsException.class)
        @ExceptionTest(NullPointerException.class)**
        public static void doublyBad() {
            List<String> list = new ArrayList<>();
    
            // 자바 API 명세에 따르면 다음 메서드는 IndexOutOfBoundsException이나
            // NullPointerException을 던질 수 있다.
            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()) {
                 // 코드 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);
        }
    }

     

    배열로 선언하는 대신 두 번 달아서 같은 효과를 낼 수도 있지만 주의가 필요하다. 여러 개 달 때와 하나만 달았을 때를 구분하기 위해 '컨테이너' 애너테이션 타입이 적용된다.

    getAnnotationsByType() 메서드는 @Repeatable을 사용한 Annotation과 컨테이너 Annotation를 모두 가져오지만, isAnnotationPresent() 메서드는 둘을 명확히 구분하기 때문에 @Repeatable을 사용한 Annotation이 달렸는지 isAnnotationPresent()로 검사할 때는 컨테이너 Annotation과 @Repeatable을 사용한 Annotation 모두를 검사해주어야 한다.

     

                if (m.isAnnotationPresent(ExceptionTest.class)
                        || m.isAnnotationPresent(ExceptionTestContainer.class)) 

     

    정리하기

    Annotation으로 할 수 있는 일을 명명 패턴으로 처리할 이유는 없다. 처리코드가 방대해져 오류가 날 가능성이 높지만, 가독성이 높아진다.

    댓글