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

문법식·2022년 8월 30일
0

Effective Java 3/E

목록 보기
39/52

전통적으로 도구나 프레임워크가 특별히 다뤄야 할 프로그램 요소에는 딱 구분되는 명명 패텅능ㄹ 적용해왔다. 예를 들어 테스트 프레임워크인 JUnit은 버전 3까지 테스트 메서드 이름을 test로 시작하게 했다. 효과적인 방법이지만 단점도 컸다.

  • 오타가 나면 안 된다. 실수로 이름을 tsetSafetyOverride로 지으면 JUnit 3은 이 메서드를 무시하고 지나쳤고 개발자는 이 테스트가 통과했대고 오해할 수 있다.
  • 올바른 프로그램 요소에서만 사용된다고 보증할 방법이 없다. 예를 들어 메서드가 아닌 클래스 이름을 TestSafetyMechanisms로 지어 JUnit에 던져줬다고 해본다. 개발자는 이 클래스에 정의된 테스트 메서드들을 수행해주길 기대하겠지만 JUnit은 클래스 이름에는 관심이 없다.
  • 프로그램 요소를 매개변수로 전달할 마땅한 방법이 없다. 특정 예외를 던져야만 성공하는 테스트가 있다고 해본다. 기대하는 예외 타입을 테스트에 매개변수로 전달해야 하는 상황이다. 예외의 이름을 테스트 메서드 이름에 덧붙이는 방법도 있지만, 보기도 나쁘고 깨지기도 쉽다. 컴파일러는 메서드 이름에 덧붙인 문자열이 예외를 가르키는지 알 도리가 없다.

위와 같은 문제를 해결해주는 개념으로 애너테이션이 있다. 애너테이션의 동작 방식을 아래의 코드로 설명할 것이다. 자동으로 수행되는 간단한 테스트용 애너테이션으로, 예외가 발생하면 해당 테스트를 실패로 처리한다.

import java.lang.annotation.*;

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

@Test 애너테이션 타입 선언 자체에도 @Retention 애너테이션과 @Target 애너테이션이 달려 있다. 이처럼 애너테이션 선언에 다는 애너테이션을 메타애너테이션이라 한다. @Retention@Test의 유지 기간에 대한 것이다. @Target@Test 애너테이션이 적용될 대상에 대한 것이다. 위에서는 메서드를 대상으로 하고 있어서 클래스 선언, 필드 선언 등 다른 프로그램 요소에는 달 수 없다.
앞 코드의 메서드 주석에는 "매개변수 없는 정적 메서드 전용이다"라고 쓰여있다. 이 제약을 컴파일러가 강제할 수 있으면 좋겠지만, 그렇게 하려면 적절한 애너테이션 처리기를 직접 구현해야 한다. 적절한 애너테이션 처리기 없이 인스턴스 메서드나 매개변수가 있는 메서드에 달면 컴파일은 잘 되지만, 테스트 도구를 실행할 때 문제가 된다.
@Test 같은 애너테이션을 "아무 매개변수 없이 단순히 대상에 마킹한다"는 뜻에서 마커 애너테이션이라 한다. 이 애너테이션을 사용하면 프로그래머가 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 클래스에 직접적인 영향을 주지 않는다. 대상 코드의 의미는 그대로 둔 채 그 애너테이션에 관심 있는 도구에서 특별한 처리를 할 기회를 준다. 다음의 RunTests가 그런 도구의 예다.

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

이제 특정 예외를 던져야만 성공하는 테스트를 지원하기 위해 새로운 애너테이션 타입을 정의해본다.

import java.lang.annotation.*;

/**
* 명시한 예외를 던져야만 성공하는 테스트 메서드용 애너테이션
*/
@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(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 애너테이션의 매개변수 타입을 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<>();
    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(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);
                }
            }

            // 배열 매개변수를 받는 애너테이션을 처리하는 코드 (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);
                }
            }
        }
        System.out.printf("성공: %d, 실패: %d%n",
                passed, tests - passed);
    }
}

자바 8에서는 여러 개의 값을 받는 애너테이션을 다른 방식으로도 만들 수 있다. 배열 매개변수를 사용하는 대신 애너테이션에 @Repaetable 매타애너테이션을 다는 방식이다. @Repeatable을 단 애너테이션은 하나의 프로그램 요소에 여러 번 달 수 있다. 주의할 점이 있는데 아래와 같다.

  • @Repaetable을 단 애너테이션을 반환하는 '컨테이너 애너테이션'을 하나 더 정의하고, @Repaetable에 이 컨테이너 애너테이션의 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 doublyBad() {
	List<String> list = new ArrayList<>();

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

@Repeatable 애너테이션을 처리할 때 주의사항이 있다. @Repeatable 애너테이션을 여러 개 달면 하나만 달았을 때와 구분하기 위해 해당 '컨테이너' 애너테이션 타입이 적용된다. getAnnotationsByType 메서드는 여러 개 달은 경우와 하나를 달은 경우를 구분하지 않아서 @Repeatable 애너테이션과 그 컨테이너 애너테이션을 모두 가져오지만, isAnnotationPresent 메서드는 두 경우를 명확히 구분한다. 따라서 @Repaetable 애너테이션을 여러 번 단 다음 isAnnotationPresent@Repeatable 애너테이션이 달렸는지 검사한다면 "그렇지 않다"고 알려준다. 그 결과 애너테이션을 여러 번 단 메서드들을 모두 무시하고 지나친다. 같은 이유로 isAnnotationPresent로 컨테이너 애너테이션이 달렸는지 검사한다면 @Repeatable 애너테이션을 한 번만 단 메서드를 모두 무시하고 지나친다. 그래서 달려 있는 수와 상관없이 모두 검사하려면 둘을 따로따로 확인해야 한다. 다음은 테스트도구를 수정한 코드다.

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

            // 코드 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);
                }
            }
        }
        System.out.printf("성공: %d, 실패: %d%n",
                          passed, tests - passed);
    }
}

이번 아이템의 테스트 프레임워크에서 애너테이션이 명명패턴보다 낫다는 점은 확실히 보여준다. 애너테이션으로 할 수 있는 일을 명명 패턴으로 처리할 이유는 없다.
도구 제작자를 제외하고는, 일반 프로그래머가 애너테이션 타입을 직접 정의할 일을 거의 없다. 하지만 자바 프로그래머라면 예외 없이 자바가 제공하는 애너테이션 타입들을 사용해야 한다.

profile
백엔드

0개의 댓글