명명 패턴보다 Annotation을 사용하라

명명 패턴

• 원래는 도구나 프레임워크가 특별히 다뤄야 할 프로그램 요소에는 명명 패턴을 적용해왔다.

    ◦  예) JUnit은 ver.3 까지 테스트 메소드 이름은 test로 시작해야 했다.

 

명명 패턴의 단점

• 오타가 나면 메소드가 무시된다.

    ◦  예) 테스트 메소드 앞에 test 를 붙여야 하는데 tast 를 붙이면 해당 테스트 메소드는 무시된다.

    ◦  개발자는 테스트 메소드가 무시된 것을 테스트 메소드가 통과한 것으로 오해할 수 있다.

• (JUnit의 경우) 개발자가 뭐 앞에 test 만 붙이면 다 되는줄 안다.

    ◦  메소드가 아닌 클래스 앞에 Test 를 붙여놓고, 해당 클래스에 정의된 메소드들이 테스트 메소드로 실행되길 바란다면?

    ◦  JUnit은 클래스 이름에는 관심이 없으며, 경고 메세지조차 출력하지 않는다.

    ◦  개발자는 역시나 메소드들이 모두 무시된 것을, 테스트 메소드가 모두 통과한 것으로 오해할 수 있다.

• 매개변수를 사용할 수 없다.

    ◦  특정 예외를 던져야만 성공하는 테스트가 있는 경우

    ◦  기대하는 예외 타입을 테스트에 알려줘야 하지만 방법이 없다.

 

단점을 어떻게 해결해야 하나?

• Annotation을 사용하면 명명 패턴이 가지는 위의 문제들을 해결할 수 있다.


Annotation

마커 어노테이션 (Marker Annotation)

• 아무 매개변수 없이 단순히 달아주기만 하는 어노테이션

• 어노테이션에 오타를 내거나, 지정된 위치(메소드 위 등) 외 잘못된 위치에 어노테이션을 다는 경우 컴파일 오류가 발생하기 때문에

   기존 명명 패턴에서 발생하던 문제를 해결할 수 있다.

• 테스트 메소드용 어노테이션을 직접 정의해보자.

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

• @Retention(RetentionPolicy.RUNTIME)

    ◦  @Test 가 런타임에도 유지되어야 한다는 표시. 생략하면 테스트 도구는 @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() { } // 무시

}

• @Test 가 Sample 클래스에 직접적인 영향을 주는 것은 아니다.

• 그저 이 Annotation 에 관심있는 프로그램에게 추가 정보를 제공할 뿐

• 즉, @Test 의 역할 = Sample 클래스의 의미는 그대로 둔 채, 외부에서 이 Annotation 을 이용하여 추가적인 처리를 할 수 있도록 해줌

import java.lang.reflect.*;

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)) { // @Test 가 달려있는지 확인
                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);
    }

}

• command line 에서 클래스 이름을 받아 해당 클래스에 존재하는 메소드들을 전부 돌면서 @Test Annotation이 달려있는지를

  확인한다.

• @Test Annotation이 달려있는 메소드인 경우 해당 메소드를 실행시키며, 실행된 메소드에서 예외가 발생되면 해당 예외를

   InvocationTargetException 으로 받아 반환하고, InvocationTargetException 외의 예외가 발생하는 경우 이는 @Test

   잘못 사용한 것이므로 이를 출력하여 알려준다.

 

매개변수를 가지는 어노테이션

• 특정 예외를 던져야만 성공하는 테스트 메소드용 어노테이션을 정의해보자.

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

• 위 어노테이션의 매개변수 타입은 Class<? extends Throwable> 이다.

• 와일드카드 → Throwable을 확장한 클래스 객체 의미

    ◦  즉 모든 Exception 을 매개변수로 받을 수 있다.

public class Sample2 {

    @ExceptionTest(ArithmeticException.class) // 분모가 0일때 발생하는 에러
    public static void m1() {  // 성공 (ArithmeticException 예외발생)
        int i = 0;
        i = i / i;
    }

    @ExceptionTest(ArithmeticException.class)
    public static void m2() {  // 실패 (ArithmeticException 말고 다른 예외발생)
        int[] a = new int[0];
        int i = a[1]; // NullPointerException
    }

    @ExceptionTest(ArithmeticException.class)
    public static void m3() { }  // 실패 (예외가 발생하지 않았음)

}

• 새로 만든 어노테이션을 메소드에 달아주었다.

• 모든 메소드는 ArithmeticException 예외를 반환해야 한다.

if (m.isAnnotationPresent(ExceptionTest.class)) {
    try {
        m.invoke(null);
        System.out.printf("테스트 %s 실패: 예외를 던지지 않음%n", m);
    }
    
    catch (InvocationTargetException wrappedEx) {
        Throwable exc = wrappedEx.getCause(); // exc = 실제 발생한 예외
        Class<? extends Throwable> excType = m.getAnnotation(ExceptionTest.class).value(); // excType = 어노테이션에 명시된 예외
        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);
    }
}

• 어노테이션 매개변수 값을 추출하여 테스트 메소드가 반환한 예외와 어노테이션에서 매개변수로 명시된 예외가 동일한지를 비교해준다.

• 명시된 예외가 아닌 다른 예외가 반환되는 경우 : 실패로 처리

• 아무런 예외가 반환되지 않는 경우 : 실패로 처리

• InvocationTargetException 이외의 예외가 발생하는 경우 : 어노테이션을 잘못 사용함

 

배열 매개변수를 가지는 어노테이션

• 예외를 여러개 명시한 다음 그 중 하나만 발생해도 OK 인 로직을 구현하려면 어떻게 해야할까?

@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface ExceptionTest {
    Class<? extends Exception>[] value();
}
@ExceptionTest({IndexOutOfBoundsException.class, NullPointerException.class})
public static void doublyBad() { 
    List<String> list = new ArrayList<>();
    list.addAll(5, null); // list의 5번 인덱스부터 null 에 들어있는 값을 add 해라
}

• 매개변수를 여러개 넘길 때 { } 로 감싸주기만 하면 된다.

if (m.isAnnotationPresent(ExceptionTest.class)) {
    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문을 돌면서 검사
        for (Class<? extends Throwable> excType : excTypes) {
            if (excType.isInstance(exc)) { passed++; break; }
        }
        
        if (passed == oldPassed) System.out.printf("테스트 %s 실패: %s %n", m, exc);
    }
}

 

배열 매개변수 대신 @Repeatable 을 사용하는 방법

• 어노테이션 선언시 @Repeatable을 메타 어노테이션으로 달아주면 동일한 어노테이션을 여러번 사용할 수 있다.

• 단, 어노테이션 선언 방법이 조금 달라진다.

    ◦  예를 들어 아래와 같이 어노테이션 A 에 @Repeatable 을 달아서 정의하려 한다고 하자.

@Repeatable
public @interface A { ... }

    ◦  먼저 어노테이션 A 를 반환하는 컨테이너 어노테이션을 정의해야 한다.

    ◦  그 다음 컨테이너 어노테이션 객체를 A 의 매개변수로 넣어줘야 한다.

          -  이 때 컨테이너 어노테이션 은 A 타입 배열을 반환하는 value 메소드 를 가지고 있어야 한다.

          -  또한 컨테이너 어노테이션 에는 적절한 @Retension 과 @Target 이 명시되어야 한다.

          -  이걸 지키지 않으면 컴파일 되지 않는다.

// 1. @Repeatable을 달고 있는 Annotation
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
@Repeatable(ExceptionTestContainer.class)
public @interface ExceptionTest {
    Class<? extends Throwable> value();
}

// 2. 위 어노테이션 객체 배열을 반환하는 '컨테이너 어노테이션'
@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<>();
    list.addAll(5, null);
}

• 가독성이 조금 더 좋아진다!

• 어노테이션을 한번만 달면 단일 어노테이션 타입이 적용되지만, 이렇게 여러번 달면 컨테이너 어노테이션 타입이 적용된다.

• 즉 위와 같은 경우 아래와 같은 T/F 값이 반환된다.

doublyBad.isAnnotationPresent(ExceptionTest.class) // false
doublyBad.isAnnotationPresent(ExceptionTestContainer.class) // true

• 즉 어노테이션이 몇 번 달려 있는지 상관없이, @ExceptionTest 를 달고 있는 메소드를 다 골라보려면

    두가지 상황을 모두(|| 사용) 고려해 줘야 한다.

if (m.isAnnotationPresent(ExceptionTest.class) ||
    m.isAnnotationPresent(ExceptionTestContainer.class)) {
    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);
    }
}

• 즉 @Repeatable을 사용하면 가독성은 좋아지지만, 어노테이션을 처리하는 부분에서 코드 처리가 복잡해질 수 있다.


정리

• 어노테이션으로 처리할 수 있는 상황이라면 명명 패턴을 사용하지 말자.

• 사실 도구 제작자를 제외하고는 일반 프로그래머가 어노테이션을 직접 정의할 일은 잘 없다!

• 하지만 어노테이션을 사용하는 사람이라면 그 작동 원리를 알아둘 필요가 있다!