DEV ℧ Developer Diary

[EffectiveJava] item39 - 명명 패턴보다 애너테이션을 사용하라

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

명명 패턴

예를들어, 유명한 테스트 프레임워크중 하나인 JUnit은 버전3까지 테스트 메서드의 이름을 test로 시작하게 하였다. 효과적인 방법이지만 단점도 컸다.

첫번째로, 오타에 취약하다. test를 tset와 같은 오타가 날 경우, JUnit3는 이 메서드를 무시하고 지나치기 때문에, 통과했다고 착각할 수 있었다.
두번째로, 올바른 프로그램 요소에서만 사용되리라 보증할 방법이 없었다. 메서드가 아닌 클래스에 Test를 붙이고 JUnit에게 던졌을 때, JUnit은 동일하게 해당 클래스를 무시한다.
세번째로, 프로그램 요소를 매개변수로 전달할 마땅한 방법이 없다는 것이다. 특정 예외를 던져야 성공하는 테스트가 있을때, 예외의 이름을 테스트 메서드 이름에 덧붙이는 방법도 있지만, 보기도 나쁘고 깨지기도 쉽다.

애너테이션으로의 대체

이러한 단점들은 애너테이션을 도입해 문제들을 해결할 수 있었다.

JUnit도 버전4부터 test라는 명명 규칙대신 @Test를 도입하여 테스트 메서드를 찾아 실행했다.

여기서는 Test 애너테이션을 간단하게 구현하며 예시를 들어보고자 한다.

애너테이션 예시

import java.lang.annotation.*;

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

@Test 애너테이션 타입 선언을 보면 두가지의 다른 애너테이션이 달려있다. @Retention@Target이다.

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

@Retention(RetentionPolicy.RUNTIME) 메타애너테이션은 @Test가 런타임에도 유지되어야 한다는 표시다. 만약 이 메타애너테이션을 생략하면 테스트 도구는 @Test를 인식할 수 없다.
@Target(ElementType.METHOD) 메타애너테이션은 @Test 가 반드시 메서드 선언에서만 사용돼야 한다고 알려준다.

해당 애너테이션 타입의 주석을 보면 매개변수 없는 정적 메서드 전용이라 하지만 강제할 수는 없다.
강제하기 위해서는 애너테이션 처리기를 구현해야 한다.

위와 같은 애너테이션은 “아무 매개변수 없이 단순히 대상에 마킹(marking)한다”는 뜻에서 마커(marker) 애너테이션이라 한다.

한번 위의 애너테이션을 사용해보자.

public class TestSample {
    @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() { }
}

위의 소스에는 정적 메서드가 7개이고, 그중 4개에 @Test를 달았다. 요약하면 총 4개의 테스트 메서드 중 1개는 성공, 2개는 실패, 1개는 잘못 사용했다. 그리고 @Test를 붙이지 않은 나머지 4개의 메서드는 무시할 것이다.

적용한 @Test 애넡이션이 TestSample 클래스의 의미에 직접적인 영향을 주지 않는다. 다음과 같은 구현으로, 대상 코드의 의미는 그대로 둔 채 그애너테이션에 관심있는 도구에 특별한 처리를 할 수 있다.

public class RunTests {
    public static void main(String[] args) throws Exception {
        runTest(TestSample.class.getName());
    }

    private static void runTest(String args) throws ClassNotFoundException {
        int tests = 0;
        int passed = 0;
        Class<?> testClass = Class.forName((args));
        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 애너테이션을 잘못 사용했다는 뜻이다.

이 테스트를 실행하면 다음과 같은 결과가 나온다.

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

매개변수를 하나 받는 애너테이션 타입

이제 특정 예외를 던져야만 성공하는 테스트로 구현을 확대해보자. 그러려면 새로운 애너테이션 타입이 필요하다.

import java.lang.annotation.*;

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

@ExceptionTest 애너테이션의 매개변수 타입은 Class<? extends Throwable>이다. 즉, “Throwable을 확장한 클래스의 Class 객체” 라는 뜻이며, 모든 예외(와 오류) 타입을 다 수용한다.

이 애너테이션을 사용하면 아래와 같다.

public class TestSample2 {
    @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() { } /** 실패. (예외가 발생하지 X) */
}

이제 이 애너테이션을 다룰 수 있는 테스트 도구를 수정해보자. 아까 구현한 RunTests에 분기를 추가해 줄 것이다.

private static void runTest(String args) throws ClassNotFoundException {
    int tests = 0;
    int passed = 0;
    Class<?> testClass = Class.forName((args));
    for (Method m : testClass.getDeclaredMethods()) {
        if (m.isAnnotationPresent(Test.class)) {
            ...
        } else if (m.isAnnotationPresent(ExceptionTest.class)) {
            tests++;
            try {
                m.invoke(null);
                System.out.printf("테스트 %s 실패: 예외를 던지지 않음%n", m);
            } catch (InvocationTargetException wrappedExc) {
                Throwable exc = wrappedExc.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);
}

@Test 애너테이션용 코드와 비슷해 보인다. 형변환 코드가 없으니 ClassCastException 걱정은 없다. 따라서 테스트 프로그램이 문제없이 컴파일되면 애너테이션 매개변수가 가리키는 예외가 올바른 타입이라는 뜻이다.

단 해당 예외의 클래스 파일이 컴파일 타임에는 존재했으나 런타임에는 존재하지 않을수 있다. 이런경우라면 테스트 러너가 TypeNotPresentException을 던질 것이다.

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

여기서 더 심화하여, 예외를 여러개 명시하고 그중 하나가 발생하면 성공하게 만들 수 있다.
애너테이션 매커니즘에는 이런 유용한 기능이 기본으로 들어이따. @ExceptionTest 애너테이션의 매개변수 타입을 Class 객체의 배열로 수정하면 된다.

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

배열 매개변수를 받는 애너테이션 문법은 아주 유연하다. 단일 원소 배열에 최적화했지만, 앞서의 ExceptionTest들도 모두 수정 없이 수용 가능하다.
사용법은, 원소들을 중괄호로 감싸고 쉼표로 구분해 주면 된다.

@ExceptionTest({IndexOutOfBoundsException.class,
                NullPointerException.class})
public static void doublyBad() { /** 성공 */
    List<String> list = new ArrayList<>();
    /** IndexOutOfBoundsException나 NullPointerException 발생 */
    list.addAll(5, null);
}

다음 이 새로운 @ExceptionTest를 지원하도록 RunTests를 수정하면 된다.

if (m.isAnnotationPresent(ExceptionTest.class)) {
    tests++;
    try {
        m.invoke(null);
        System.out.printf("테스트 %s 실패: 예외를 던지지 않음%n", m);
    } catch (InvocationTargetException | IllegalAccessException 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을 단 애너테이션을 반환하는 ‘컨테이너 애너테이션’ 하나 더 정의하고 @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();
}

이렇게 정의한 애너테이션은 아래와 같이 사용할 수 있다.

@ExceptionTest(IndexOutOfBoundsException.class)
@ExceptionTest(NullPointerException.class)
public static void doublyBad2() { /** 성공 */
    List<String> list = new ArrayList<>();
    /** IndexOutOfBoundsException나 NullPointerException 발생 */
    list.addAll(5, null);
}

반복 가능 애너테이션은 처리할 때도 주의를 요한다.

반복 가능 애너테이션을 여러 개 달면 하나만 달았을 때와 구분하기 위해 해당 ‘컨테이너’ 애너테이션 타입이 적용된다.

getAnnotationsByType 메서드는 이 둘을 구분하지 않아서 반복 가능 애너테이션과 그 컨테이너 애너테이션을 모두 가져오지만, isAnnotationPresent 메서드는 둘을 명확히 구분한다.

그래서, 애너테이션을 여러번 단 메서드를 검사할때 isAnnotationPresent로 검사하면 ExceptionTest.class가 아닌 ExceptionTestContainer.class가 달린 것이기 때문에, 달려 있는 수와 상관없이 모두 검사하려면 둘을 따로따로 확인해야 한다.

그래서 앞서 if (m.isAnnotationPresent(ExceptionTest.class))의 조건 처리를 if (m.isAnnotationPresent(ExceptionTest.class) || m.isAnnotationPresent(ExceptionTestContainer.class)) 이렇게 해줘야 한다는 뜻이다.

한번 구현체를 수정해보자.

if (m.isAnnotationPresent(ExceptionTest.class)
    || m.isAnnotationPresent(ExceptionTestContainer.class)) {
    tests++;
    try {
        m.invoke(null);
        System.out.printf("테스트 %s 실패: 예외를 던지지 않음%n", m);
    } catch (InvocationTargetException | IllegalAccessException wrappedExc) {
        Throwable exc = wrappedExc.getCause();
        int oldPassed = passed;
        ExceptionTest[] excTests = m.getAnnotationsByType(ExceptionTest.class);
        for (ExceptionTest excTest : excTests) {
            if (excTest.annotationType().isInstance(exc)) {
                passed++;
                break;
            }
        }
        if (passed == oldPassed)
            System.out.printf("테스트 %s 실패: 기대한 예외 %s %n", m, exc);
    }
}

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

이 방식으로 여러분 코드의 가독성을 개선할 수 있다면 이 방식을 사용하도록 하자. 하지만 애너테이션을 선언하고 이를 처리하는 부분에서 코드양이 늘어나며, 처리 코드가 복잡해져 오류가 날 가능성이 커짐을 명심하자.

이번 아이템의 테스트 프레임 워크는 아주 간단하지만 애너테이션이 명명 패턴보다 낫다는점을 보여준다. 또한, 해당 작업은 애너테이션으로 할 수 있는 극히 일부이다.

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