Effective Java - 열거타입과 에너테이션(5)

SeungHyuk Shin·2021년 11월 8일
0

Effective Java

목록 보기
24/26
post-thumbnail

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


전통적으로 도구나 프레임워크가 특별히 다뤄야 할 프로그램 요소에는 딱 구분되는 명명 패턴을 적용해왔다.

public class HelloTest extends TestCase {
	public void testGetUsers() {
		return getUsers();
	}
}

그러나 이러한 명명패턴은 몇가지 단점을 가지고 있다.

  • 첫 번째, 오타가 나면 안 된다.

  • 두 번째, 올바른 프로그램 요소에서만 사용 되리라 보증할 방법이 없다. 예컨대 클래스 이름을 TestSafetyMechanism로 지어 JUnit에 던져 줬다고 하면 JUnit은 경고 메시지조차 출력하지 않으면서 의도한 테스트가 전혀 수행되지 않을 것이다.

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

에너테이션은 이 모든 문제를 해결해주는 개념으로 Junit도 버전 4부터 전면 도입하였다.

/**
 * 테스트 메서드임을 선언하는 애너테이션이다.
 * 매개변수 없는 정적 메서드 전용이다.
 */
@Retention(RetentionPolicy.RUNTIME) // @Test가 런타임에도 유지되어야 한다는 표시이다. 
@Target(ElementType.METHOD) // @Test가 반드시 메서드 선언에서만 사용돼야 한다. 
public @interface Test { // @Test
}

마커 애너테이션을 동작하는 프로그램을 작성해보자

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

다음은 특정 예외를 던져야만 성공하는 테스트를 지원하도록 해보자.

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

@ExceptionTest 애노테이션에 대한 내부 구현은 아래와 같이 되어 있다.

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

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

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

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

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

@ExceptionTest({IndexOutOfBoundsException.class, NullPointerException.class})
public static void doublyBad() {   // 성공해야 한다.
    List<String> list = new ArrayList<>();
    // 자바 API 명세에 따르면 다음 메서드는 IndexOutOfBoundsException이나
    // NullPointerException을 던질 수 있다.
    list.addAll(5, null);
}

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

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

@Repeatable은 주의해야 할점이 있다.

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

반복 가능 애너테이션을 사용해 하나의 프로그램 요소에 같은 애너테이션을 여러 번 달 때의 코드 가독성을 높여보였다.

다른 프로그래머가 소스코드에 추가 정보를 제공할 수 있는 도구를 만드는 일을 한다면 적당한 애너테이션 타입도 함께 정의해 제공하자. 애너테이션으로 할 수 있는 일을 명명 패턴으로 처리할 이유는 없다.

자바 프로그래머라면 예외 없이 자바가 제공하는 애너테이션 타입들은 사용해야 한다.

0개의 댓글