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

wisdom·2022년 8월 26일
0

Effetctive Java

목록 보기
39/80
post-thumbnail

1. 명명 패턴

전통적으로 도구나 프레임워크를 특별히 다뤄야 할 프로그램 요소에는 딱 구분되는 명명 패턴을 적용해왔다.
예를 들어, JUnit은 버전 3까지 테스트 메서드 이름을 test로 시작하도록 했다.

명명 패턴 단점

  1. 오타가 나면 안 된다.
    예를 들어, 실수로 이름을 tsetSafetyOverride로 지으면 JUnit 3은 이 메서드를 무시하고 지나간다.

  2. 올바른 프로그램 요소에서만 사용되리라 보증할 방법이 없다.
    예를 들어, 클래스 이름을 TestSafetyMechanisms로 지으면, JUnit은 경고 메시지조차 출력하지 않고 테스트를 전혀 수행하지 않을 것이다.

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


2. 애너테이션

애너테이션은 명명 패턴의 문제를 해결해주는 개념으로, JUnit도 버전 4부터 전면 도입하였다.

지금부터 자동으로 수행되는 간단한 테스트용 애너테이션 @Test를 정의하는 예제를 살펴보자.

1) 마커(marker) 애너테이션

💡 마커 애너테이션

아무 매개변수 없이 단순히 대상에 마킹하는 애너테이션.

마커 애너테이션 타입 선언

@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface Test {
}

애너테이션 선언에 다는 애너테이션을 메타애너테이션이라고 한다.
여기에는 두 가지 메타애너테이션이 사용되었다.

  • @Retention(RetentionPolicy.RUNTIME) : @Test가 런타임에도 유지되어야 한다는 표시.
  • @Target(ElementType.METHOD) : @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() { }
}

마커 애너테이션 처리

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

isAnnotationPresent 메서드

  • @Test 애너테이션이 달린 메서드를 찾아준다.

InvocationTargetException

  • 테스트 메서드가 예외를 던지면, 리플렉션 메커니즘이 InvocationTargetException으로 감싸서 다시 던진다.
  • 원래 예외에 담긴 정보를 추출하려면, InvocationTargetException을 잡아 getCause 메서드를 사용해야 한다.

출력 결과

public static void Sample.m3() 실패: java.lang.RuntimeException: 실패
잘못 사용한 @Test: public void Sample.m5()
public static void Sample.m7() 실패: java.lang.RuntimeException: 실패
성공: 1, 실패: 3

2) 매개변수를 하나 받는 애너테이션

특정 예외를 던져야만 성공하는 테스트를 지원하기 위해, 매개변수를 하나 추가해보자.

애너테이션 타입 선언

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

매개변수 타입은 Class<? extends Throwable>이다.
이것은 한정적 타입 토큰으로써, 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() { }  // 실패해야 한다. (예외가 발생하지 않음)
}

애너테이션 처리

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

애너테이션 매개변수의 값을 추출하여, 테스트 메서드가 올바른 예외를 던지는지 확인하는 부분이 추가되었다.

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

출력

테스트 public static void Sample2.m2() 실패: 기대한 예외 java.lang.ArithmeticException, 발생한 예외 java.lang.ArrayIndexOutOfBoundsException: Index 1 out of bounds for length 0
테스트 public static void Sample2.m3() 실패: 예외를 던지지 않음
성공: 1, 실패: 2

3) 배열 매개변수를 받는 애너테이션

예외를 여러 개 명시하고 그중 하나가 발생하면 성공하도록 만들어보자.

애너테이션 타입 선언

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

매개변수 타입을 Class 객체의 배열로 수정하였다.

애너테이션 사용 예

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

애너테이션의 매개변수 배열을 받아서, 배열의 원소 중 동일한 예외를 던졌다면 성공하고, 아니면 실패하도록 처리한다.


4) 반복 가능 애너테이션

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

💡 @Repeatable 메타애너테이션

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

단 @Repeatable을 사용하려면 다음을 지켜야 한다.

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

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

  3. 컨테이너 애너테이션 타입에는 적절한 보존 정책(@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();
}

애너테이션 사용 예

@ExceptionTest(IndexOutOfBoundsException.class)
@ExceptionTest(NullPointerException.class)
public static void doublyBad() {
    List<String> list = new ArrayList<>();

    // 자바 API 명세에 따르면 다음 메서드는 IndexOutOfBoundsException이나
    // NullPointerException을 던질 수 있다.
    list.addAll(5, null);
}

이번엔 애너테이션을 2번 달았다.

애너테이션 처리
반복 가능 애너테이션은 처리할 때 주의를 요한다.
반복 가능 애너테이션을 여러 개 달면 하나만 달았을 때와 구분하기 위해 해당 '컨테이너' 애너테이션 타입이 적용된다.

getAnnotationsByType 메서드는 이 둘을 구분하지 않아, 반복 가능 애너테이션과 그 컨테이너 애너테이션을 모두 가져온다.

isAnnotationPresent 메서드는 둘을 명확히 구분한다.
예를 들어, 반복 검사 애너테이션을 여러 번 단 다음 (컨테이너 애너테이션이 아닌) 반복 가능 애너테이션이 달렸는지 검사한다면 "그렇지 않다"라고 알려준다.
따라서 반복 가능 애너테이션과 컨테이너 애너테이션을 각각 따로 확인해야 한다.

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

반복 가능 애너테이션을 사용해 여러 번 애너테이션을 달면, 코드의 가독성을 높일 수 있다.
그러나 애너테이션을 선언하고 이를 처리하는 부분에서는 코드 양이 늘어나며, 특히 처리 코드가 복잡해져 오류가 날 가능성이 커짐을 명심하자.

profile
백엔드 개발자

0개의 댓글