Post

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

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

Item 39 : 명명 패턴보다 애너테이션을 사용하라

들어가며

전통적으로 Java에서는 특정 요소에 추가적인 정보나 처리 방식을 부여하기 위해 명명 패턴(naming pattern) 을 사용해왔다. 예를 들어, JUnit 3 버전까지는 테스트 메서드의 이름을 test로 시작하도록 강제했다. 하지만 명명 패턴은 여러 가지 단점을 가지고 있으며, Java 5부터 도입된 애너테이션(annotation) 이 이를 완벽하게 대체할 수 있는 해법을 제공한다.

이 글에서는 명명 패턴의 문제점을 구체적으로 살펴보고, 애너테이션이 어떻게 이를 해결하는지, 그리고 실제로 애너테이션을 어떻게 설계하고 사용해야 하는지 깊이 있게 다룬다.


명명 패턴의 문제점

명명 패턴은 단순해 보이지만, 다음과 같은 심각한 문제를 야기한다.

1. 오타에 취약하다

명명 패턴은 컴파일러가 이름의 의도를 알 수 없기 때문에, 오타가 발생해도 경고조차 받지 못한다.

1
2
3
4
5
6
7
8
9
10
11
12
// 잘못된 예시

public class MyTest extends TestCase {
    // 오타: tsetSafetyOverride (test가 아닌 tset으로 시작)
    public void tsetSafetyOverride() {
        // 이 메서드는 테스트로 인식되지 않음
        // 하지만 개발자는 테스트가 통과했다고 착각할 수 있다
    }
    
    // 올바른 명명법
    public void testSafetyOverride() { ... }
}

위 코드에서 tsetSafetyOverride 메서드는 단순히 무시되며, 개발자는 테스트가 정상적으로 통과했다고 오해할 수 있다. 컴파일 타임에 아무런 경고도 받지 못하는 것 이 가장 큰 문제다.

2. 특정 프로그램 요소에만 사용되도록 강제할 수 없다

명명 패턴으로는 메서드에만 적용되어야 한다는 제약을 표현할 방법이 없다.

1
2
3
4
5
6
7
8
// 잘못된 예시: 클래스명에 test 접두사 사용됨

public class TestSafetyMechanisms {
    public void checkSafety() {
        // 개발자는 이 클래스의 모든 메서드가 테스트로 실행되길 기대하지만
        // JUnit 3은 클래스명 기반 처리를 지원하지 않음
    }
}

개발자가 클래스명에 Test 접두사를 붙여도, 프레임워크는 이를 인식하지 못하고 조용히 무시한다. 의도한 대로 동작하지 않지만 아무런 피드백이 없다.

3. 프로그램 요소와 매개변수를 연결할 방법이 없다

특정 예외를 기대하는 테스트처럼, 추가 정보를 전달해야 하는 경우 명명 패턴으로는 한계가 있다.

1
2
3
4
5
6
// 명명 패턴으로는 의도를 표현하는데 한계가있다.
// ex) "ArithmeticException이 발생해야 성공하는 테스트다"

public void testDivideByZero_expectArithmeticException() {
    int result = 10 / 0;
}

예외 타입을 메서드명에 인코딩하는 방법도 있지만, 이는 매우 어색하고 파싱하기 어려우며, 컴파일러의 검증을 전혀 받을 수 없다.


애너테이션의 등장

Java 5에서 도입된 애너테이션은 명명 패턴의 모든 문제를 해결한다. 실제로 JUnit 4는 전면적으로 애너테이션을 도입했으며, 이는 테스트 프레임워크의 표준이 되었다.

마커 애너테이션 타입 선언

가장 단순한 형태의 애너테이션인 마커 애너테이션(marker annotation) 부터 살펴보자.

1
2
3
4
5
6
7
8
9
10
11
import java.lang.annotation.*;

/**
 * 테스트 메서드임을 나타내는 애너테이션
 * 매개변수 없이 정적 메서드에만 사용해야 한다
 */
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface Test {
    // 아무 매개변수도 받지 않는 마커 애너테이션
}

이 선언에서 주목해야 할 메타애너테이션(meta-annotation) 들이 있다.

@Retention(RetentionPolicy.RUNTIME)

이 애너테이션이 런타임에도 유지되어야 함을 명시한다. @Retention은 애너테이션의 생명주기를 결정하는 메타애너테이션으로, 다음 세 가지 정책 중 하나를 선택할 수 있다.

  • SOURCE : 소스 레벨에만 유지, 컴파일러가 버린다.
  • CLASS : 클래스 파일까지 유지, VM은 버린다. (기본값)
  • RUNTIME : 런타임까지 유지, 리플렉션으로 읽을 수 있다.

RetentionPolicy.RUNTIME 이 없으면 테스트 도구가 리플렉션을 통해 @Test 를 인식할 수 없다. 즉, 런타임에 애너테이션 정보를 읽어야 하는 경우 반드시 RUNTIME 정책을 사용 해야 한다.


@Target(ElementType.METHOD)

이 애너테이션이 메서드 선언에만 사용될 수 있음을 명시한다. @Target은 애너테이션을 적용할 수 있는 프로그램 요소의 타입을 제한한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public enum ElementType {
    TYPE,               // 클래스, 인터페이스, 열거 타입, 레코드
    FIELD,              // 필드 (열거 상수 포함)
    METHOD,             // 메서드
    PARAMETER,          // 메서드 매개변수
    CONSTRUCTOR,        // 생성자
    LOCAL_VARIABLE,     // 지역 변수
    ANNOTATION_TYPE,    // 애너테이션 타입
    PACKAGE,            // 패키지
    TYPE_PARAMETER,     // 타입 매개변수 (Java 8)
    TYPE_USE,           // 타입 사용 (Java 8)
    MODULE,             // 모듈 (Java 9)
    RECORD_COMPONENT    // 레코드 컴포넌트 (Java 16)
}

ElementType.METHOD를 지정함으로써, 클래스나 필드에 @Test를 붙이면 컴파일 오류 가 발생한다. 이것이 명명 패턴과의 결정적인 차이다.

실제 @Test 구현

JUnit 5의 실제 @Test 를 보면, 더 많은 메타애너테이션이 사용된다.

1
2
3
4
5
6
7
8
9
10
11
12
//
// Source code recreated from a .class file by IntelliJ IDEA
// (powered by FernFlower decompiler)
//

@Target({ElementType.ANNOTATION_TYPE, ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
@Documented
@API(status = Status.STABLE, since = "5.0")
@Testable
public @interface Test {
}

JUnit 5만의 특징

JUnit 5는 @TargetElementType.ANNOTATION_TYPE을 포함한다. 이는 @Test를 사용해 커스텀 애너테이션을 만들 수 있다 는 의미다.

1
2
3
4
5
6
7
8
9
10
11
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Test  // @Test를 메타 애너테이션으로 사용
public @interface FastTest {
}

// 사용
@FastTest  // @Test와 동일하게 동작
public void quickTest() {
    // JUnit 5가 이 메서드를 테스트로 인식
}

이를 통해 @IntegrationTest, @SlowTest 같은 의미 있는 테스트 애너테이션 을 만들어 테스트를 분류하고 선택적으로 실행할 수 있다. 명명 패턴으로는 불가능한 기능이다.

여기서 추가로 사용된 메타애너테이션들도 짧게 알아보면,

@Documented

이 애너테이션이 JavaDoc에 포함되어야 함을 나타낸다. @Documented가 붙은 애너테이션을 사용한 코드의 JavaDoc을 생성하면, 해당 애너테이션 정보가 문서에 표시된다.

1
2
3
4
5
@Documented
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.ANNOTATION_TYPE)
public @interface Documented {
}

@Testable

JUnit 플랫폼에서 테스트 가능한 요소를 표시하는 마커 애너테이션이다.

1
2
3
4
5
6
@Retention(RetentionPolicy.RUNTIME)
@Inherited
@Documented
@API(status = Status.STABLE, since = "1.0")
public @interface Testable {
}

이 애너테이션에는 @Inherited 가 붙어 있는데, 중요한 특성을 제공한다.

@Inherited의 동작 방식

@Inherited는 애너테이션이 하위 클래스에 상속되도록 한다. 하지만 클래스에만 작동하며, 메서드나 필드에는 영향을 주지 않는다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
@Inherited
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.TYPE)
public @interface TestCategory {
    String value();
}

@TestCategory("Integration")
public class BaseTest {
    // 기본 테스트 클래스
}

// @TestCategory가 자동으로 상속된다
public class UserServiceTest extends BaseTest {
    @Test
    public void testUserCreation() {
        // 이 클래스도 "Integration" 카테고리에 속한다
    }
}

@Inherited가 없다면 UserServiceTest@TestCategory 애너테이션을 상속받지 못한다. 즉, 상위 클래스의 애너테이션을 자동으로 하위 클래스에 적용하고 싶을 때만 @Inherited를 사용한다.

마커 애너테이션 사용 예시

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
31
32
33
34
35
36
37
38
39
40
public class Sample {
    @Test
    public static void m1() {
        // 성공해야 하는 테스트
    }
    
    public static void m2() {
        // @Test가 없으므로 테스트 도구가 무시
    }
    
    @Test
    public static void m3() {
        // 실패해야 하는 테스트
        throw new RuntimeException("실패");
    }
    
    public static void m4() {
        // @Test가 없으므로 테스트 도구가 무시
    }
    
    @Test
    public void m5() {
        // 잘못 사용한 예: 정적 메서드가 아니다.
        // 하지만 컴파일은 된다 (런타임에 검증)
    }
    
    public static void m6() {
        // @Test가 없으므로 테스트 도구가 무시
    }
    
    @Test
    public static void m7() {
        // 실패해야 하는 테스트
        throw new RuntimeException("실패");
    }
    
    public static void m8() {
        // @Test가 없으므로 테스트 도구가 무시
    }
}

위 코드에서 m1, m3, m5, m7만이 테스트 도구에 의해 인식된다. m5는 잘못된 사용이지만 컴파일은 성공 한다. 이는 애너테이션이 소스 코드에 정보를 추가할 뿐, 직접적으로 무언가를 강제하지는 않기 때문이다.


애너테이션 처리기 구현

애너테이션이 실제로 동작하려면, 이를 처리하는 도구가 필요하다. 다음은 @Test 애너테이션을 처리하는 간단한 테스트 러너다.

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
31
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()) {
            // @Test 애너테이션이 붙은 메서드만 처리
            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) {
                    // @Test를 잘못 사용한 경우 (인스턴스 메서드 등)
                    System.out.println("잘못 사용한 @Test: " + m);
                }
            }
        }
        
        System.out.printf("성공: %d, 실패: %d%n", passed, tests - passed);
    }
}

이 테스트 러너는 리플렉션을 사용하여 다음과 같은 순서로 동작한다.

  1. 클래스의 모든 메서드를 순회한다.
  2. @Test 애너테이션이 붙은 메서드를 찾는다.
  3. 해당 메서드를 호출하고 결과를 기록한다.
  4. 예외 발생 시 실패로 처리한다.

InvocationTargetException의 이해

위 코드에서 첫번째로 catch하는 InvocationTargetException에 대해 알아보자.

리플렉션으로 메서드를 호출할 때, 메서드 내부에서 발생한 예외는 InvocationTargetException 으로 감싸진다. 이는 메서드 호출 자체의 문제메서드 실행 중 발생한 문제 를 구분하기 위함이다.

1
2
3
4
5
6
7
8
9
// 예외 래핑 과정
public void testMethod() {
    throw new RuntimeException("테스트 실패"); // 원본 예외
}

// 리플렉션으로 호출하면
method.invoke(null);
// → InvocationTargetException이 발생
//    └─ getCause() → RuntimeException("테스트 실패")

따라서 getCause()를 통해 실제 예외를 추출해야 한다. 그렇지 않으면 모든 테스트 실패가 InvocationTargetException 으로만 보여 디버깅이 어렵다.


매개변수를 받는 애너테이션

마커 애너테이션보다 강력한 것은 매개변수를 받는 애너테이션 이다. 예를 들어, 특정 예외가 발생해야 성공하는 테스트를 표현할 수 있다.

1
2
3
4
5
6
7
8
9
10
import java.lang.annotation.*;

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

이 애너테이션의 매개변수 타입은 Class<? extends Throwable>이다. 이는 매우 중요한 설계 결정 인데, 다음을 의미한다.

1. Throwable을 확장한 클래스의 Class 객체만 받는다

  • Exception, RuntimeException, Error 등 모든 예외/오류 타입 허용
  • 하지만 String.classInteger.class 같은 것은 컴파일 오류

2. 컴파일 타임 타입 안전성을 보장한다

1
2
3
4
5
@ExceptionTest(String.class)  // 컴파일 오류!
// error: incompatible types: Class<String> cannot be converted to 
//        Class<? extends Throwable>

@ExceptionTest(ArithmeticException.class)  // OK

3. 런타임에 Class 객체로 타입을 검사할 수 있다

1
2
3
Class<? extends Throwable> excType = 
m.getAnnotation(ExceptionTest.class).value();
excType.isInstance(exception); // 타입 검사

매개변수를 받는 애너테이션 사용 예시

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public class Sample2 {
    @ExceptionTest(ArithmeticException.class)
    public static void m1() { // 성공해야 한다.
        int i = 0;
        i = i / i; // ArithmeticException 발생 → 성공
    }
    
    @ExceptionTest(ArithmeticException.class)
    public static void m2() { // 실패해야 한다. (다른 예외 발생)
        int[] a = new int[0];
        int i = a[1]; // ArrayIndexOutOfBoundsException 발생 → 실패
    }
    
    @ExceptionTest(ArithmeticException.class)
    public static void m3() { // 실패해야 한다. (예외가 발생하지 않음)
        // 예외를 던지지 않음 → 실패
    }
}

이제 이 애너테이션을 다룰 수 있도록 아까 위에 있던 RunTests 테스트 도구를 수정하면 다음과 같다.

매개변수를 받는 애너테이션 처리

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
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);
    }
}

getAnnotation() 메서드로 애너테이션 인스턴스를 얻고, value() 메서드로 매개변수 값(저장된 예외 클래스)을 추출한다. 그런 다음 isInstance()로 실제 발생한 예외가 기대한 타입인지 검사한다.

애너테이션 인터페이스의 특별함

애너테이션은 @interface로 선언하면,

  • 컴파일러가 특별 취급
  • JVM이 런타임에 자동으로 프록시 구현체 생성
  • 우리가 구현 클래스를 만들 필요 없음


isInstance() vs instanceof의 차이

1
2
3
4
5
6
// instanceof: 컴파일 타임에 타입이 결정됨
if (exc instanceof ArithmeticException) { }

// isInstance(): 런타임에 동적으로 타입 검사
Class<?> type = ArithmeticException.class;
if (type.isInstance(exc)) { }  // 런타임에 타입 결정 가능

애너테이션 처리에서는 예외 타입이 런타임에 결정되므로 isInstance()를 사용해야 한다.


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

하나의 테스트가 여러 예외 중 하나라도 던지면 성공하는 경우를 표현하려면 어떻게 해야 할까? 배열 매개변수 를 사용하면 된다.

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

단일 원소에서 배열로 타입만 바꾸면 된다. 놀랍게도 기존의 단일 원소 애너테이션도 배열 매개변수를 받는 애너테이션으로 수정해도 호환 된다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 배열 표기법 사용
@ExceptionTest({IndexOutOfBoundsException.class, NullPointerException.class})
public static void doublyBad() {
    List list = new ArrayList<>();
    
    // 이 코드는 IndexOutOfBoundsException 또는
    // NullPointerException을 던질 수 있다
    list.addAll(5, null);
}

// 단일 값도 여전히 작동 (배열로 자동 변환)
@ExceptionTest(ArithmeticException.class)
public static void singleException() {
    int i = 0;
    i = i / i;
}

배열 매개변수 처리

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

배열의 각 원소를 순회하며, 발생한 예외가 기대한 예외 중 하나와 일치하는지 확인한다. 하나라도 일치하면 테스트는 성공이다.


반복 가능 애너테이션 (@Repeatable)

Java 8부터는 @Repeatable 메타애너테이션 을 사용하여 하나의 프로그램 요소에 같은 애너테이션을 여러 번 달 수 있다. 배열 매개변수 대신 더 직관적인 문법을 제공한다.

@Repeatable의 실제 구현

1
2
3
4
5
6
7
8
9
@Documented
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.ANNOTATION_TYPE)
public @interface Repeatable {
    /**
     * 반복 가능 애너테이션을 담을 컨테이너 애너테이션 타입
     */
    Class value();
}

@Repeatable은 컨테이너 애너테이션의 Class 객체를 매개변수로 받는다. 이 설계는 매우 영리한데, 컴파일러가 내부적으로 여러 애너테이션을 컨테이너에 담아 처리할 수 있게 한다.

예를들면

1
2
3
4
5
6
7
8
9
10
11
@ExceptionTest(ArithmeticException.class)
@ExceptionTest(NullPointerException.class)
public void test() { }

// 이걸 컴파일러가 내부적으로 변환한다.

@ExceptionTestContainer({
    @ExceptionTest(ArithmeticException.class),
    @ExceptionTest(NullPointerException.class)
})
public void test() { }

@Repeatable 적용 방법

@Repeatable을 적용하려면 두 가지 애너테이션 이 필요하다:

  1. 반복 가능 애너테이션 : 실제로 여러 번 달 애너테이션
  2. 컨테이너 애너테이션 : 반복 가능 애너테이션들을 담을 컨테이너
1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 1. 컨테이너 애너테이션 (반드시 먼저 정의) ... 1️⃣
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface ExceptionTestContainer {
    ExceptionTest[] value();  // 반복 가능 애너테이션의 배열 ... 2️⃣
}

// 2. 반복 가능 애너테이션
@Retention(RetentionPolicy.RUNTIME) ...3️⃣
@Target(ElementType.METHOD)         ...3️⃣
@Repeatable(ExceptionTestContainer.class)  // 컨테이너 지정
public @interface ExceptionTest {
    Class value();
}

주의점

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

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

3️⃣ 컨테이너 애너테이션 타입에는 적절한 보존 정책 (@Retention) 과 적용 대상(@Target) 을 명시해야한다.

중요한 제약 조건

  1. 컨테이너의 value() 메서드는 반복 가능 애너테이션의 배열을 반환해야 한다

  2. 컨테이너의 @Retention은 반복 가능 애너테이션과 같거나 더 길어야 한다

1
2
3
4
5
6
// 반복 가능 애너테이션: RUNTIME
// 컨테이너: RUNTIME 또는 그 이상 (사실상 RUNTIME만 가능)

// 잘못된 예
@Retention(RetentionPolicy.SOURCE)  // 반복 가능 애너테이션이 RUNTIME인데
public @interface ExceptionTestContainer { }  // 컨테이너가 SOURCE면 오류!
  1. 컨테이너의 @Target은 반복 가능 애너테이션과 같거나 더 넓어야 한다
1
2
// 반복 가능 애너테이션: METHOD
// 컨테이너: METHOD, {METHOD, TYPE} 등 (METHOD를 포함해야 함)

반복 가능 애너테이션 사용 예시

1
2
3
4
5
6
7
8
9
10
11
12
// 배열 매개변수 방식 (Java 7 이하)
@ExceptionTest({IndexOutOfBoundsException.class,
                NullPointerException.class})
public static void doublyBad() { }

// 반복 가능 애너테이션 방식 (Java 8+) - 훨씬 직관적
@ExceptionTest(IndexOutOfBoundsException.class)
@ExceptionTest(NullPointerException.class)
public static void doublyBad() {
    List list = new ArrayList<>();
    list.addAll(5, null);
}

배열 매개변수를 사용하는 것보다 훨씬 직관적이다. 각 예외를 독립적으로 명시할 수 있어 가독성이 좋다.

반복 가능 애너테이션 처리의 함정

반복 가능 애너테이션을 처리할 때 매우 중요한 함정 이 있다:

  • 애너테이션을 여러 개 달면: 컨테이너 애너테이션 타입이 적용된다
  • 애너테이션을 하나만 달면: 반복 가능 애너테이션 타입이 적용된다
1
2
3
4
5
6
7
8
9
10
11
12
13
14
// Case 1: 애너테이션 여러 개
@ExceptionTest(ArithmeticException.class)
@ExceptionTest(NullPointerException.class)
public static void multipleAnnotations() { }
// → 컴파일러가 내부적으로 ExceptionTestContainer로 변환
// → isAnnotationPresent(ExceptionTestContainer.class) == true
// → isAnnotationPresent(ExceptionTest.class) == false

// Case 2: 애너테이션 하나만
@ExceptionTest(ArithmeticException.class)
public static void singleAnnotation() { }
// → 그대로 ExceptionTest로 유지
// → isAnnotationPresent(ExceptionTest.class) == true
// → isAnnotationPresent(ExceptionTestContainer.class) == false

이 비일관성 때문에 두 가지 경우를 모두 확인해야 한다.

getAnnotationsByType()을 사용한 일관된 처리

다행히 Java 8은 이 문제를 해결하는 getAnnotationsByType() 메서드를 제공한다

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
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;
        
        // getAnnotationsByType()이 자동으로 처리
        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);
        }
    }
}

getAnnotationsByType()의 동작

  1. 반복 가능 애너테이션이 여러 개
    • 컨테이너에서 꺼내서 배열로 반환
    • @ExceptionTest 2개 → ExceptionTest[2]
  2. 반복 가능 애너테이션이 하나
    • 그것만 담은 배열로 반환
    • @ExceptionTest 1개 → ExceptionTest[1]
  3. 애너테이션이 없으면
    • 빈 배열 반환
    • 없음 → ExceptionTest[0]

이렇게 애너테이션 개수와 무관하게 일관된 방식으로 처리할 수 있다.


마치며

명명 패턴은 오타에 취약하고, 컴파일러의 도움을 받을 수 없으며, 매개변수를 전달할 방법이 없다. 반면 애너테이션은 이 모든 문제를 해결 한다.

애너테이션으로 할 수 있는 일을 명명 패턴으로 처리할 이유는 없다. 도구 제작자를 제외하고는, 일반 개발자가 애너테이션 타입을 직접 정의할 일은 거의 없다. 하지만 Java가 제공하는 애너테이션 타입들은 사용해야 한다 (Item 40). IDE나 정적 분석 도구가 제공하는 애너테이션을 사용하면 진단 정보의 품질을 높여준다.


References

  • 이펙티브 자바 3/E
This post is licensed under CC BY 4.0 by the author.