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

열거 타입과 애너테이션, 여섯 번째 아이템

Posted by SungBeom on April 16, 2020 · 11 mins read

명명 패턴의 단점

전통적으로 도구나 프레임워크가 특별히 다뤄야 할 프로그램 요소에는 딱 구분되는 명명 패턴을 적용해왔다. 효과적인 방법이지만 단점도 큰데, 첫 번째는 오타가 나면 안 된다. 실수로 이름을 잘못 지으면 메소드를 무시하고 지나치기 때문이다.

두 번째는 올바른 프로그램 요소에서만 사용되리라 보증할 방법이 없다. 메소드 이름을 검증하는 프레임워크에 클래스 이름을 맞춰서 던져준다고 해도 관심이 없을 것이다. 개발자가 의도한대로 전혀 수행되지 않는다.

세 번째 단점은 프로그램 요소를 매개변수로 전달할 마땅한 방법이 없다. 특정 예외를 던져야만 성공하는 테스트가 있다고 했을 때, 예외의 이름을 메소드 이름에 덧붙이는 방법도 있지만, 보기도 나쁘고 깨지기도 쉽다. 컴파일러는 메소드 이름에 덧붙인 문자열이 무엇을 의미하는지 알지 못하고, 실행하기 전에는 그런 이름의 클래스가 존재하는지 혹은 예외가 맞는지조차 알 수 없다.

마커 애너테이션

애너테이션은 명명 패턴의 모든 문제를 해결해주는 멋진 개념이다. Test라는 이름의 애너테이션을 정의한다고 해보자.

1
2
3
4
5
6
7
8
9
10
11
12
13
/*
 * 마커(marker) 애너테이션 타입 선언
 */
import java.lang.annotation.*;
 
/**
 * 테스트 메소드임을 선언하는 애너테이션이다.
 * 매개변수 없는 정적 메소드 전용이다.
 */
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface Test {
}
cs

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 마커 애너테이션을 사용한 프로그램 예
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() { }
}
cs

위와 같은 애너테이션을 "아무 매개변수 없이 단순히 대상에 마킹(marking)한다"는 뜻에서 마커(marker) 애너테이션이라 한다. 이 애너테이션을 사용하면 프로그래머가 Test 이름에 오타를 내거나 메소드 선언 외의 프로그램 요소에 달면 컴파일 오류를 내준다.

@Test 애너테이션 타입 선언 외에도 다른 애너테이션이 있는데, 이처럼 애너테이션 선언에 다는 애너테이션을 메타애너테이션(meta-annotation)이라 한다. @Retention(RetentionPolicy.RUNTIME) 메타애너테이션은 @Test가 런타임에도 유지되어야 한다는 표시다. @Target(ElementType.METHOD) 메타애너테이션은 @Test가 반드시 메소드 선언에서만 사용돼야 한다고 알려준다.

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

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

1
2
3
4
5
6
7
8
9
10
11
12
13
/*
 * 매개변수 하나를 받는 애너테이션 타입
 */
import java.lang.annotation;
 
/**
 * 명시한 예외를 던져야만 성공하는 테스트 메소드용 애너테이션
 */
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface ExceptionTest {
    Class<extends Throwable> value();
}
cs

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 매개변수 하나짜리 애너테이션을 사용한 프로그램
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() { }  // 실패해야 한다. (예외가 발생하지 않음)
}
cs

이 애너테이션의 매개변수 타입은 Class<? extends Throwable>이다. 여기서의 와일드카드 타입은 "Throwable을 확장한 클래스의 Class 객체"라는 뜻이며, 따라서 모든 예외와 오류 타입을 다 수용한다. 애너테이션을 실제 활용하는 예에서는 class 리터럴을 매개변수의 값으로 사용했다.

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

한 걸음 더 들어가, 예외를 여러 개 명시하고 그중 하나가 발생하면 성공하게 만들 수도 있다. @ExceptionTest 애너테이션의 매개변수 타입을 Class 객체의 배열로 수정해보자.

1
2
3
4
5
6
7
8
/*
 * 배열 매개변수를 받는 애너테이션 타입
 */
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface ExceptionTest {
    Class<extends Throwable>[] value();
}
cs

1
2
3
4
5
6
7
8
// 배열 매개변수를 받는 애너테이션을 사용하는 코드
@ExceptionTest({ IndexOutOfBoundsException.class,
        NullPointerException.class })
public static void doublyBad() {  // 성공해야 한다.
    List<String> list = new ArrayList<>();
 
    list.addAll(5null);
}
cs

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

반복 가능한 애너테이션

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

여기에는 주의할 점이 있다. 첫 번째, @Repeatable을 단 애너테이션을 반환하는 '컨테이너 애너테이션'을 하나 더 정의하고, @Repeatable에 이 컨테이너 애너테이션의 class 객체를 매개변수로 전달해야 한다. 두 번째, 컨테이너 애너테이션은 내부 애너테이션 타입의 배열을 반환하는 value 메소드를 정의해야 한다. 마지막으로 컨테이너 애너테이션 타입에는 적절한 보존 정책(@Retention)과 적용 대상(@Target)을 명시해야 한다. 그렇지 않으면 컴파일되지 않을 것이다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
/*
 * 반복 가능한 애너테이션 타입
 */
// 반복 가능한 애너테이션
@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();
}
cs

1
2
3
4
// 반복 가능 애너테이션을 두 번 단 코드
@ExceptionTest(IndexOutOfBoundsException.class)
@ExceptionTest(NullPointerException.class)
public static void doublyBad() { ... }
cs

반복 가능 애너테이션을 여러 개 달면 하나만 달았을 때와 구분하기 위해 해당 '컨테이너' 애너테이션 타입이 적용된다. 따라서 달려 애너테이션의 수와 상관없이 모두 검사하려면 따로따로 확인해야 한다.

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

도구 제작자를 제외하고는, 일반 프로그래머가 애너테이션 타입을 직접 정의할 일은 거의 없다. 하지만 자바 프로그래머라면 예외 없이 자바가 제공하는 애너테이션 타입들을 사용해야 한다. IDE나 정적 분석 도구가 제공하는 애너테이션을 사용하면 해당 도구가 제공하는 진단 정보의 품질을 높여줄 것이다.


핵심 정리

소스코드에 추가 정보를 제공할 수 있는 도구를 만든다면 명명 패턴이 아닌 애너테이션 타입을 정의하여 제공하자. 애너테이션은 명명 패턴이 가진 단점을 해결해줌과 동시에 다양한 활용이 가능하게 해준다. 마커 애너테이션, 매개변수 하나를 받는 애너테이션, 배열 매개변수를 받는 애너테이션, 반복 가능 애너테이션 등 여러 타입의 활용이 가능하다.