ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • 아이템39. 명명 패턴보다 애너테이션을 사용하라
    책/이펙티브자바 2021. 8. 31. 13:57

    명명패턴

    예전에 주로 사용하던 패턴인데 JUnit3 에서 사용되었는데 테스트 이름을 test... 로 시작하게끔 하는 방식이었다.

    효과적인 방법이지만 단점도 크다.

    • 오타가 나면 안된다.
      • 오타가 났을때 JUnit이 이 메서드를 무시하고 지나치기 때문에 이 테스트가 통과했다고 오해할 수 있다.
    • 올바른 프로그램 요소에서만 사용되리라 보증할 방법이 없다.
      • 예를들어 메소드가 아닌 클래스 이름을 TestSafetyMechanisms 로 지어 JUnit 에게 줬다면 작성한 개발자는 이 클래스에 정의된 테스트 메서드들을 수행해주길 기대하지만 JUnit은 클래스 이름에 관심이 없다. 경고 메시시조차 출력하지 않지만 의도한 테스트는 전혀 수행되지 않는다.
    • 프로그램 요소를 매개변수로 전달할 마땅한 방법이 없다.
      • 예를들어 특정예외를 던져야만 성공하는 테스트가 있다고 하면, 예외 타입을 테스트에 매개변수로 전달해야 하는 상황이다. 예외의 이름을 테스트 메서드 이름에 덧붙이는 방법도 있지만, 보기도 나쁘고 깨지기도 쉽다. 테스트 실행 전에는 그런 이름의 클래스가 존재하는지, 예외가 맞는지 알 수 없다.

    애너테이션

    위의 문제를 모두 해결해주는 멋진 개념이고, JUnit도 4 부터 도입하였다.

    애너테이션을 공부하기 위해 예시를 만들어보자.

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

    애너테이션을 만들었는데 위에 보면 @Retention, @Target 애너테이션이 또 있다.

    이 처럼 애너테이션 선언에 다는 애너테이션을 메타애너테이션(meta-annotation)이라 한다.

     

    @Retention(RetentionPolicy.RUNTIME) 은 @KingTest 가 런타임에 유지되어야 한다는 표시다.

    만약 이 에너테이션을 생략하면 테스트 도구는 @KingTest를 인식할 수 없다.

    조금 더 알아보자면, Retention 안에 RetentionPolicy 는 enum 으로 이루어져 있고 SOURCE, CLASS, RUNTIME이 존재한다.

    SOURCE: .class 파일에서도 보이지 않고 단지 개발자의 어떤 용도로만 사용되다가 컴파일할 때 버려지는 소스이다.

    CLASS: 애노테이션을 단지 바이트코드에서 확인할 수 있는 수준으로만 정의해놓을 뿐 Runtime 환경에서는 역시나 버려지는 메모리이다.

    RUNTIME: 런타임시에까지 사용할 수 있다. JVM이 자바 바이트코드가 담긴 class 파일에서 런타임환경을 구성하고 런타임을 종료할 때까지 메모리가 살아있다.

     

    @Target(ElementType.METHOD) 애너테이션은 @KingTest 가 반드시 메서드 선언에서만 사용돼야 한다고 알려준다. 따라서 클래스, 필드 등 다른 프로그램 요소에는 달 수 없다.

     

    주석에는 "매개변수 없는 정적 메서드 전용이다" 라고 쓰여있다.

    이 제약을 컴파일러가 강제할 수 있으면 좋겠지만, 그렇게 하려면 적절한 애너테이션 처리기를 직접 구현해야 한다. (javax.annotation.processing API 문서 참조)

    애너테이션 처리기 없이 인스턴스 메서드나 매개변수가 있는 메서드에 달면 ? 컴파일은 잘 되겠지만, 테스트 도구를 실행할 때 문제가 된다.

     

    마커 에너테이션: "아무 매개변수 없이 단순히 대상에 마킹(marking)한다"는 뜻

    이 애너테이션을 사용하면 프로그래머가 KingTest 이름에 오타를 내거나 메서드 선언 외의 요소에 달면 컴파일 오류를 내준다.

    public class Item39 {
      @KingTest
      public static void m1() {} // success
    
      public static void m2() {}
    
      @KingTest
      public static void m3() { // fail
        throw new RuntimeException("fail");
      }
    
      public static void m4() {}
    
      @KingTest
      public void m5() {} // 잘못사용: 정적 메서드가 아님
    
      public static void m6() {}
    
      @KingTest
      public static void m7() { // fail
        throw new RuntimeException("fail");
      }
    
      public static void m8() {}
    
      public static void main(String[] args) throws Exception {
        int tests = 0;
        int passed = 0;
        Class<?> testClass = Class.forName("me.kingsubin.studyrepo.book.effectiveJava.Item39");
        for (Method m : testClass.getDeclaredMethods()) {
          if (m.isAnnotationPresent(KingTest.class)) {
            tests++;
            try {
              m.invoke(null);
              passed++;
            } catch (InvocationTargetException invocationTargetException) {
              Throwable throwable = invocationTargetException.getCause();
              System.out.println(m + " 실패: " + throwable);
            } catch (Exception exception) {
              System.out.println("잘못 사용한 @KingTest: " + m);
            }
          }
        }
        System.out.printf("성공: %d, 실패: %d%n", passed, tests - passed);
      }
    }
    
    /* 출력 
    
    잘못 사용한 @KingTest: public void me.kingsubin.studyrepo.book.effectiveJava.Item39.m5()
    public static void me.kingsubin.studyrepo.book.effectiveJava.Item39.m7() 실패: java.lang.RuntimeException: fail
    public static void me.kingsubin.studyrepo.book.effectiveJava.Item39.m3() 실패: java.lang.RuntimeException: fail
    성공: 1, 실패: 3
    
    */

    이제 특정 예외를 던져야만 성공하는 테스트를 지원해주는 새로운 애너테이션을 만들어보자.

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

    이 애너테이션의 매개변수 타입은 Class<? extends Throwable> 이다.

    여기서의 와일드카드 타입은 Throwable을 확장한 클래스의 Class 객체 라는 뜻이다.

    따라서 모든 예외타입을 다 수용한다.

     

    배열 매개변수를 받는 애너테이션 만들어보자. 단순히 배열만 추가해주면된다 .

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

    사용할때는 이런식으로 사용한다.

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

     


    @Repeatable

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

    배열 매개변수 대신 애너테이션에 @Repeatable 메타에너테이션을 다는 방식이다.

    이 애너테이션을 달면 하나의 프로그램 요소에 여러 번 달 수 있다.

    단, 아래 3가지 조건을 만족해야 한다.

    1. @Repeatable 을 단 애너테이션을 반환하는 '컨테이너 애너테이션'을 하나 더 정의하고, @Repeatable 에 이 컨테이너 에너테이션의 class 객체를 매개변수로 전달해야 한다.
    2. 컨테이너 애너테이션은 내부 애너테이션의 타입의 배열을 반환하는 value 메서드를 정의해야 한다.
    3. 컨테이너 애너테이션 타입에는 적절한 보존정책과 적용대상을 명시해야한다. 그렇지 않으면 컴파일 되지 않을것이다.
    @Repeatable(ExceptionTestContainer.class)
    @Retention(RetentionPolicy.RUNTIME)
    @Target(ElementType.METHOD)
    public @interface ExceptionTest {
      Class<? extends Throwable>[] value();
    }
    
    @Retention(RetentionPolicy.RUNTIME)
    @Target(ElementType.METHOD)
    public @interface ExceptionTestContainer {
      ExceptionTest[] value();
    }

    이런식으로 ExceptionTest 에는 @Repeatable 메타 에너테이션을 추가하고, ExceptionTestContainer 에너테이션을 또 새로 만들어주었다. 매개변수로는 ExceptionTest 배열을 받는다.

    @ExceptionTest(IndexOutOfBoundsException.class)
    @ExceptionTest(ArithmeticException.class)
    public static void m1() { }

    이런식으로 위에 여러 번 붙여서 사용 가능하다.

     

    Repeatable 에너테이션을 사용해서 여러 개 달면 하나만 달았을 때와 구분하기 위해 해당 '컨테이너' 애너테이션 타입이 적용된다. 즉 ExceptionTestContainer 타입이 적용된다는 뜻이다.

     

    실제로 @Repeatable을 쓰는걸 못봐서 잘 와닿지 않는다. 특별한 장점을 아직 모르겠다.

    결론

    • 애너테이션이 명명 패턴보다 낫다.
    • 애너테이션으로 할 수 있는 일을 명명 패턴으로 처리할 이유는 없다.
    • 자바 개발자라면 애너테이션을 사용할 수 밖에 없으니 잘 알아두자.
킹수빈닷컴