Effective Java

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

성범이라고합니다 2022. 3. 15. 20:26

전통적으로 도구나 프레임워크가 특별히 다뤄야 할 프로그램 요소에는 딱 구분되는 명명 패턴을 적용해왔다 (ex. Junit의 test 메소드명)

효과적이긴 하나 단점도 크다.

- 오타가 나는 경우 테스트를 통과했다고 오해할 수 있다 (ex. tsetSafetyOverride)

- 올바른 프로그램 요소에서만 사용되리라 보증할 방법이 없다 (ex. 메소드명이 아닌 클래스명을 TestSafetyMechnism인 경우 Junit은 pass)

- 프로그램 요소를 매개변수로 전달한 마땅한 방법이 없다 (ex. 특정한 예외를 검출해야 하는 테스트인 경우, 클래스명에 예외를 덧붙인 예외를 넘겨도 컴파일러가 알 방법이 없다)

 

이를 보완하기 위해 등장한 것이 (Junit 4 이후의) 애너테이션(annotation)이다. 

다음 코드들은 설명을 위해 제작한 코드들이다.

//마커(marker) 애너테이션 타입 선언

import java.lang.annotation.*;

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

애너테이션 선언에 다는 에너테이션을 메타애너테이션(meta-annotation)이라고 하며 위의 코드에서는 @Retention 과 @Target이 이에 해당된다.

  • @Retention(RetentionPolicy.RUNTIME) : @Test가 런타임에도 유지되어야 한다는 표시. 해당 애너테이션을 생략하면 테스트 도구는 @Test를 인식할 수 없다.
  • @Target(ElementType.METHOD) : @Test가 반드시 메소드 선언에서만 사용돼야 한다는 것을 의미. 클래스 선언, 필드 선언등에는 달 수 없다.

적절한 애너테이션 처리기 없이 인스턴스 메소드나 매개변수가 있는 메소드에 달게되면 컴파일은 잘 되겠지만 테스트 도구를 실행할 때 문제가 발생한다.

 

다음 코드는 @Test 애너테이션을 실제로 적용한 코드이다. "아무 매개변수 없이 단순히 대상에 마킹(marking)한다"는 뜻에서 마커 애너테이션이라고 한다. 이 애너테이션을 사용하면 프로그래머가 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(){}//잘못 사용: 정적 메소드x
    
    public static void m6(){}
    
    @Test
    public static void m7(){//실패
    	throw new RuntimeException("실패");
    }
    
    public static void m8(){}
}

정적 메소드가 7개이고 그 중 4개에 @Test annotation. m3와 m7 메소드는 예외를 던지고 m1과 m5는 그렇지 않다.

그리고 m5는 인스턴스 메소드이므로 @Test를 잘못 사용한 경우이다.

요약하자면, 총 4개의 테스트 메소드 중 1개는 성공, 2개는 실패, 1개는 잘못 사용한 경우이다. 그리고 @Test가 붙지 않은 나머지 4개의 메소드(m2, m4, m6, m8)는 테스트 도구가 무시할 것이다.

 

애너테이션의 의미 : 그저 이 애너테이션에 관심 있는 프로그램에게 추가 정보를 제공할 뿐이다. 더 넓게는, 대상 코드의 의미는 그대로 둔 채 그 애너테이션에 관심 있는 도구에서 특별한 처리를 할 기회를 준다.

 

마커 애너테이션을 처리하는 프로그램 코드의 예시는 다음과 같다.

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

이 테스터 러너는 명령줄로부터 완전 정규화된 클래스 이름을 받아, 그 클래스에서 @Test 애너테이션이 달린 메소드를 차례로 호출한다.

isAnnotationPresent가 실행할 메소드를 찾아주는 메소드이다. 테스트 메소드가 예외를 던지면 리플렉션 메커니즘이 InvocationTargetException으로 감싸서 다시 던진다. 그래서 이 프로그램은 InvocationTargetException을 잡아 원래 예외에 담긴 실패 정보를 추출해 출력한다(getCause()).

 

다음은, 특정 예외를 던져야만 성공하는 테스트 케이스를 지원하도록 코드이며 다음과 같다.

import java.lang.annotation.*;

/**
* 명시한 예외를 던져야만 성공하는 테스트 메소드용 애너테이션
*/

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

이 애너테이션의 매개변수 타입은 Class<? extends Throwable>이며 여기서의 와일드카드 타입은 많은 의미를 담고 있다.

"Throwable을 확장한 클래스의 Class 객체"라는 뜻이며 따라서 모든 예외 타입을 다 수용한다. 이는 한정적 타입 토큰의 또 하나의 활용 사례이다.

 

다음은 이 애너테이션을 실제 활용한 코드이다. class 리터럴은 애너테이션 매개변수의 값으로 사용됐다.

public class Sample2{
	@ExceptionTest(ArithmeicException.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[i];
    }
    
    @ExceptionTest(ArithmeticException.class)
    public static void m3(){}//실패해야 한다.(예외가 발생하지 않음)
}

이 애너테이션을 다루기 위해 위 RunTest 코드를 수정하여 다시 작성하면

if(m.isAnnotationPresent(ExceptionTest.class){
	tests++;
    try{
    	m.invoke(null);
        System.out.printf("테스트 %s 실패: 예외를 던지지 않음%n", m);
    } catch(InvocationTargetException wwrappedEx){
    	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);
    }
}

@Test 애너테이션용 코드와 비슷해 보이지만 차이라면, 이 코드는 애너테이션 매개변수의 값을 추출하여 테스트 메소드가 올바를 예외를 던지는지 확인하는데 사용한다. 

테스트 프로그램이 문제없이 컴파일되면 애너테이션 매개변수가 가리키는 예외가 올바른 타입이라는 뜻이다. 단, 해당 예외의 클래스 파일이 컴파일 타임에는 존재했으나 런타임에는 존재하지 않을 수는 있다. 이런 경우라면 테스트 러너가 TypeNotPresentException을 던질 것이다.

 

위 코드를 확장해서 예외를 여러 개 명시하고 그 중 하나가 발생하면 성공하게 만들 수도 있다.

다음 코드는 여러 예외를 다루는 애너테이션 타입과 그를 사용하는 코드이다.

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

///////////////////////////////////////////

@ExceptionTest({IndexOutOfBoundsException.class, NullPointerException.class})
public static void doubleyBad(){
	List<String> list = new ArrayList<>();
    
    list.addAll(5,null);//IndexOut or NullPointer exception 던질 수 있다.
}


/////////////////////////////////////////

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

직관적인 것을 확인할 수 있다.

 

자바 8에서는 여러 개의 값을 받는 애너테이션을 다른 방식으로도 만들 수 있다.

배열 매개변수(Class<? extends Throwable>[])를 사용하는 대신 애너테이션에 @Repeatable 메타애너테이션을 다는 방식이다.

@Repeatable을 단 애너테이션은 하나의 프로그램 요소에 여러 번 달 수 있다.

주의할 점은 다음과 같다.

  • @Repeatable을 단 애너테이션을 반환하는 '컨테이너 애너테이션'을 하나 더 정의하고 @Repeatable에 이 컨테이너 애너테이션의 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(){...}

반복 가능 애너테이션을 처리할 때 주의를 요한다.

반복 가능 애너테이션을 여러 개 달면 하나만 달았을 때와 구분하기 위해 해당 '컨테이너' 애너테이션 타입이 적용된다

getAnnotationByType 메소드는 이 둘을 구분하지 않아서 반복 가능 애너테이션고 그 컨테이너 애너테이션을 모두 가져오지만 isAnnotationPresent 메소드는 둘을 명확히 구분한다. 따라서 반복 가능 애너테이션을 여러 번 단 다음 inAnnotationPresent로 반복 가능 애너테이션이 달렸는지 검사한다면 "그렇지 않다"라고 알려준다(컨테이너가 달렸기 때문)

그 결과 애너테이션을 여러 번 단 메소드들을 모두 무시하고 지나친다. 같은 이유로 isAnnotationPresent로 컨테이너 애너테이션이 달렸는지 검사한다면 반복 가능 애너테이션을 한 번만 단 메소드를 무시하고 지나친다. 그래서 달려 있는 수와 상관없이 모두 검사하려면 둘을 따로따로 확인해야 한다.

 

반복 가능 애너테이션 다루기 위한 코드는 다음과 같다.

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.getAnnotationByType(ExceptionTest.class);
        for(ExceptionTest excTest : excTests){
        	if(excTest.value().isInstance(exc)){
            	passed++;
                break;
            }
        }
        if(passed == oldPassed)
        	System.out.printf("테스트 %s 실패: %s %n", m , exc);
    }
}

반복 가능 애너테이션을 사용해 하나의 프로그램 요소에 같은 애너테이션을 여러 번 달 때의 코드 가독성을 높였다. 이 방식으로 코드의 가독성을 개선할 수 있다면 이런 방식을 택하자.

하지만 애너테이션을 선언하고 이를 처리하는 부분에서는 코드 양이 늘어나며 특히 처리 코드가 복잡해져 오류가 날 가능성이 높아지기도 한다.


핵심 정리

테스트는 애너테이션으로 할 수 있는 일 중 극히 일부일 뿐이다.

소스코드에 추가 정보를 제공할 수 있는 도구를 만드는 일을 한다면 적당한 애너테이션 타입도 함께 정의해 제공하자.

애너테이션으로 할 수 있는 일을 명명 패턴으로 처리할 이유는 없다.

프로그래머가 애너테이션 타입을 직접 정의할 일은 극히 드물지만, 자바가 제공하는 애너테이션 타입들은 사용할 줄 알아야 하며 사용해야 한다.