티스토리 뷰

어노테이션의  두 가지 성질 스포

  • 어노테이션은 그저 마킹하는 용도이다. 어노테이션 혼자서는 어떠한 동작도 하지 않는다.
  • 어노테이션을 동작하게 하려면 또 다른 코드가 필요하다.

위의 배경지식을 알고 가면 이해하기 쉬울 것 같다.

 

 

명명 패턴이란?

주어진 규칙에 맞게 메소드, 클래스 이름 등을 정의하는 것이다. 규칙에 맞게 작성했다면 라이브러리나 프레임워크가 이를 읽어 어떤 로직을 처리해 준다. 예시로 JPA가 있다.

 

그러나 이런 패턴 작성을 개발자에게 맡기면..

  • 오타를 낼 수 있다.
  • 올바른 요소에서 사용되는지 보장할 수 없다.

테스트 프레임워크인 JUnit이 예시로 등장한다. 과거 JUnit은 테스트를 수행하기 위해 method의 이름을 test로 시작하는 패턴에 대해서만 테스트가 수행되게 했다. 이런 오타를 냈다! 동작하지 않는다. 또한 클래스 이름을 test로 시작하고 클래스 내부의 method들을 테스트해 주길 바라지만 동작하지 않는다. 그리고 마지막으로 테스트를 시행할 때 어떤 파라미터를 넘겨줄 방법이 없다.

 

어노테이션은 모든 문제를 해결해 준다.

 

예시를 살펴보자.

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

@Retention과 @Target은 JAVA에서 기본으로 제공해 주는 어노테이션이다.

@Retention은 해당 어노테이션이 언제까지 존재하는가, @Target은 해당 어노테이션의 적용 범위를 나타낸다.

좌측은 @Retention의 파라미터 enum이다.

SOURCE컴파일 타임에서만 어노테이션이 존재하게 된다. 예시로 Lombok의 getter, setter와 같은 어노테이션은 컴파일 타임에 method를 생성하고 사라진다. (어노테이션이 이를 처리하는 게 아니다. 미리 정의된 어노테이션 프로세서에 의해 처리된다.)

CLASS는 컴파일 타임에서 존재하고 어노테이션이 컴파일 될 때 바이트 코드에 남는다. 그러나 JVM이 해당 어노테이션이 존재하는지를 메모리에 들고 있지 않는다.

RUNTIME은 CLASS의 성질에 더해 JVM이 해당 어노테이션을 메모리상에 물고 있는다. reflection을 이용해 어노테이션이 존재하는지 확인하려면 RUNTIME으로 선언되어야 한다.

 

우측은 읽어보면 알 수 있다. 어느 곳에 선언 할 수 있는지를 나타낸다. @Target은 파라미터로 배열을 받아 여러 개를 동시에 받을 수 있다.

 

 

아래는 테스트 어노테이션을 사용하는 프로그램이다.

// 코드 39-2 마커 애너테이션을 사용한 프로그램 예 (239쪽)
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가 붙은 모든 method들이 성공적으로 컴파일된다. 그러나 m5의 경우 실제 테스트 프로그램을 돌리면 에러가 발생한다. 아래의 코드가 테스트 프로그램이다.

// 코드 39-3 마커 애너테이션을 처리하는 프로그램 (239-240쪽)
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)) {
                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);
    }
}

명령줄에서 클래스 이름을 reflection을 통해 클래스를 찾고 해당 클래스에 선언 된 method들을 순회하는 소스코드이다.

  • if(m.isAnnotationPresent(Test.class))에 의해 @Test가 달려있는지 확인한다. (reflection을 통해 불러오기 때문에 @Retention(RUNTIME)이 선언되어야 한다.)
    // Method class에 isAnnotationPresen()가 없어서 어리둥절했는데 AccessibleObject의 method를 상속받아 그대로 사용한다. Method -> Executable -> AccessibleObject으로 구현한다.

  • invoke()를 통해 method를 호출한다. invoke method에 파라미터로 인스턴스를 넘기면 해당 인스턴스의 method를 호출할 수 있게 된다. null을 넘기면 인스턴스가 필요없는 static method만 호출할 수 있다. 따라서 static method가 아닌 m5는 Exception을 발생시킨다.

  • method 내부에서 Exception이 발생하면 reflection은 해당 Exception을 InvocationTargetException으로 wrapping해 Exception을 발생시킨다. 만약 InvocationTargetException이 아닌 Exception이 발생한다면 method 내부가 아닌 다른 곳에서 Exception이 발생한 경우다. @Test를 잘못 사용한 m5()의 경우가 그렇다. -> 이를 컴파일 타임에 방지하고 싶으면 annotation processor를 구현하는 방법이 있다.

 

특정 Exception을 던져야 성공하는 테스트를 작성하기 위해선 새로운 어노테이션을 정의해야한다.

// 코드 39-4 매개변수 하나를 받는 애너테이션 타입 (240-241쪽)
import java.lang.annotation.*;

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

어노테이션이 파라미터를 받는 법이 특이하다. method의 이름파라미터 이름으로 사용하고 return 타입파라미터의 타입으로 사용한다. ExceptionTest(value = new Exception()) 이렇게 사용할 수 있다. 이때 파라미터가 하나라면 (정의된 method가 하나라면) 파라미터 이름을 생략할 수 있다.

 

 

이를 사용하는 프로그램이다.

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

 

위의 클래스를 테스트하는 코드이다.

// 마커 애너테이션과 매개변수 하나짜리 애너태이션을 처리하는 프로그램 (241-242쪽)
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);
    }
}

ExceptionTest 어노테이션을 검사하는 코드가 추가됐다. InvocationTargetException으로 wrapping된 Exception의 타입이 ExceptionTest 어노테이션의 파라미터의 하위 타입이면 테스트가 성공하고 그렇지 않다면 실패한다.

 

 

 

어노테이션의 파라미터로 배열을 받는 법은 다음과 같다.

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

어노테이션의 파라미터 배열을 value()를 통해 가져와 이를 순회하며 일치하는지 검사한다.

 

 

 

이렇게 여러 개의 파라미터를 받는 것을 배열을 통해 구현할 수도 있지만 @Repeatable을 통해 구현할 수도 있다.

코드를 먼저 보는 게 이해가 빠르다.

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

value 하나를 지니는 @Repeatable이 달린 ExceptionTest와 ExceptionTest 배열을 들고 있는 ExceptionTestContainer이다. 

 

 

 

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

 

ExceptionTest 어노테이션을 여러 개 달면 ExceptionTestContainer의 value에 ExceptionTest들이 들어가게 된다.

 

 

 

이를 테스트하는 코드이다.

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

이 코드가 참 멋진 것 같다. 과거에 작성한 모든 테스트 어노테이션과 호환된다.

getAnnotation(ExceptionTest.class).value()로 배열을 가져오는 코드에서 getAnnotationsByType()을 통해 배열을 가져오게 바뀌었다. getAnnotationsByType()은 @ExceptionTestContainer와 @Repeatable이 붙은 @ExceptionTest를 모두 가져온다. 따라서 과거의 @ExceptionTest 하나만 들고 있는 테스트도 호환된다. @Repeatable을 통해 코드의 가독성을 높일 수 있지만 작성해야 하는 코드가 조금 많아져 귀찮다.

 

 

 

어노테이션의 사용법을 제대로 익힌 것 같다.

댓글
공지사항
최근에 올라온 글
최근에 달린 댓글
Total
Today
Yesterday
링크
TAG
more
«   2024/09   »
1 2 3 4 5 6 7
8 9 10 11 12 13 14
15 16 17 18 19 20 21
22 23 24 25 26 27 28
29 30
글 보관함