본문 바로가기
Java/Effective Java 3E

[이펙티브자바 3판] ITEM39. 명명 패턴보다 애너테이션을 사용하라

by 잭피 2020. 11. 18.

이번장의 핵심은...

애너테이션으로 할 수 있는 일을 명명 패턴으로 처리할 이유는 없다

도구 제작자 제외하곤 일반 프로그래머가 애너테이션 타입을 직접 정의할 일은 거의 없지만, 자바 프로그래머라면 예외 없이 자바가 제공하는 애너테이션 타입들은 사용해야 한다


명명 패턴의 단점

ex) junit3 : 테스트 메서드의 시작을 test로 시작하게 하였다.

 

1. 오타가 나면 안됨

테스트 메서드 이름에 오타가 난다면 JUnit 3은 이 메소드를 그냥 지나쳐버림

 

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

 

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

예외를 던져야 성공하는 테스트 : 방법이 없다

 

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

 

애너테이션

@Retention(RetentionPolicy.RUNTIME)
// @Test가 런타임에도 유지되어야 한다는 표시, 만약 생략하면 테스트 도구는 @Test를 인식할 수 없다
@Target(ElementType.METHOD)
// @Test가 반드시 메서드 선언에서만 사용돼야 한다는 뜻 
public @interface 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() { }
}

총 8개 메소드

성공 1 / 실패 2 / 1개는 잘못사용 (static (정적)이 아니다) / 나머지 4개는 @Test를 붙이지 않음

Test 애너테이션이 Sample 클래스의 의미에 직접적인 영향을 주지 않음

즉, 대상 코드의 의미는 그대로 둔 채 그 애너테이션에 관심 있는 도구에서 특별한 처리를 할 기회를 줌

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 애너테이션이 달린 메서드를 차례로 호출

테스트 메소드가 예외를 던지면 리플렉션 메커니즘이 InvocationTargetException으로 감싸서 다시 던짐

InvocationTargetException 외의 예외는 @Test 애너테이션을 잘못사용한 것이다

특정 예외를 던져야만 성공하는 테스트

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

@ExceptionTest 애너테이션 매개변수 타입은 Class<? extends Throwable>

→ Throwable을 확장한 클래스의 Class 객체

즉, 모든 예외 타입을 다 수용한다는 뜻이다

// 코드 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() { }  // 실패해야 한다. (예외가 발생하지 않음)
}

ArithmeticException - 0으로 나눌 때 발생하는 익셉션

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

위의 예제와 차이는 애너테이션 매개변수의 값을 추출하여 테스트 메서드가 올바른 예외를 던지는지 확인하는데 사용

배열 매개변수를 받는 애너테이션 타입

// 코드 39-6 배열 매개변수를 받는 애너테이션 타입 (242쪽)
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface ExceptionTest {
    Class<? extends Exception>[] value();
}

원소가 여럿인 배열을 지정할 땐 원소들을 중괄호로 감싸고 쉼표로 구분

// 코드 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);
}
// 배열 매개변수를 받는 애너테이션을 처리하는 코드 (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);
    }
}

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

배열 매개변수를 사용하는 대신

→ 애너테이션에 @Repeatable 메타애너테이션을 다는 방식

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

 

단, 주의할 점이 있음

 

1. @Repeatable을 단 애너테이션을 반환하는 컨테이너 애너테이션을 하나 더 정의하고

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

 

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

 

3. 컨테이너 애너테이션 타입에는 적절한 보존 정책(@Rentention)과 적용 대상(@Tartget)을 명시해야 한다

→ 지키지 않으면 컴파일되지 않을 것이다

 

// 코드 39-8 반복 가능한 애너테이션 타입 (243-244쪽)
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
@Repeatable(ExceptionTestContainer.class)
public @interface ExceptionTest {
    Class<? extends Throwable> value();
}


// 반복 가능한 애너테이션의 컨테이너 애너테이션 (244쪽)
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface ExceptionTestContainer {
    ExceptionTest[] value();
}

이제 앞에 있는 배열 방식 대신 반복 가능 애너테이션을 적용해보면.

// 코드 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);
}

반복 가능 애너테이션 처리할 때 주의 사항

여러 개 달면 하나만 달았을 때와 구분하기 위해 해당 '컨테이너' 애너테이션 타입이 적용

 

- isAnnotationPresent

반복 가능 애너테이션을 여러 번 단 다음 isAnnotationPresent로 반복 가능 애너테이션이 달렸는지 검사하면 False로 리턴 (컨테이너가 달렸기 때문)

 

- getAnnotationsByType : 반복 가능 애너테이션과 컨테이너 애너테이션을 모두 가져옴

 

- 따로따로 확인하여 코딩한다

// 코드 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);
    }
}

애너테이션을 여러번 달아 코드 가독성을 높임

애너테이션을 선언하고 처리하는 부분에서 코드양이 늘어남

처리 코드가 복잡해져 오류가 날 가능성이 커짐을 명심!

 

 

이 글은 “이펙티브 자바 3판” 책 내용을 정리한 글입니다.

만약 저작권 관련 문제가 있다면 “shk3029@kakao.com”로 메일을 보내주시면, 바로 삭제하도록 하겠습니다.

 

댓글