Processing math: 100%

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

2025. 1. 26. 14:04

명명 패턴이란, 변수나 함수의 이름을 일관된 방식으로 작성하는 패턴.

예를 들어 JUnit은 버전 3까지 테스트 메서드 이름을 test로 시작하게 하였다. testMethod()

하지만 이 방식은 단점이 크다.

 

첫 번째 단점, 오타가 나면 안된다. tetsMethod로 잘못 작성하면 테스트 무시하고 지나간다.

두 번째 단점, 올바른 프로그램 요소에서만 사용되리라 보증할 방법이 없다. (즉, 메서드에서만 test로 시작하는 명명을 하리라 보증할 방법이 없다는 것) 메서드가 아닌 클래스 이름을 test로 시작하게 지어서 넘겨주면 개발자는 이 클래스에 정의된 테스트 메서드들이 수행되길 바랬지만 JUnit은 클래스 이름에 관심이 없기에 경고도 띄우지 않은 채 테스트들은 수행되지 않는다.

세 번째 단점, 프로그램 요소를 매개변수로 전달할 마땅한 방법이 없다는 것. 특정 예외를 던져야만 성공하는 테스트가 있다고 했을 때, 기대하는 예외를 테스트에 매개변수로 전달해야 하는 상황이다. 예외의 이름을 테스트 메서드 이름에 덧붙일 수도 있겠지만 오타가 날 수도 있고 보기도 힘들다. 컴파일 시에 체크도 안된다. 즉, 명명 패턴은 매개변수를 전달하기 까다롭다.

 

위의 문제들을 해결하기 위해 나온 개념이 애너테이션이다. JUnit도 버전 4부터 전면 도입하였다.

 

마커 애너테이션 타입 선언

마커 애너테이션 : 아무 매개변수 없이 단순히 대상에 마킹하는 애너테이션. 이름에 오타를 내거나 Target에 지정된 위치 외의 프로그램 요소에 달면 컴파일 오류를 내준다.

import java.lang.annotation.*;

/**
 * 매개변수 없는 정적 메서드 전용이다.
 */
@Retention(RetentionPolicy.RUNTIME) //메타 애너테이션. 이 어노테이션은 런타임에도 유지되어야 한다.
@Target(ElementType.METHOD) //메타 애너테이션. 메서드 선언에서만 사용돼야 한다.
public @interface Test {
}

메타 애너테이션: 애너테이션 선언에 다는 애너테이션

현재 위 코드에서는 정적 메서드 전용을 강제하는 역할은 없음. 컴파일러에서 강제하려면 애너테이션 처리기를 직접 구현해야 한다.

애너테이션 처리기 : 컴파일 시점에 애너테이션을 분석하고 처리하는 도구. 

1. 코드 생성 2. 코드 검증 3. 메타데이터 처리

 

----------------------------------------------

마커 애너테이션을 사용한 프로그램 예시

package annotation;

public class Sample {
    @Test
    private static void m1() {
        System.out.println("Sample.m1");
    }

    public static void m2() {
    }

    @Test
    public static void m3() {
        System.out.println("Sample.m3");
        throw new RuntimeException("실패");
    }

    public static void m4() {
    }

    @Test
    public void m5() {
        System.out.println("Sample.m5");
    }

    public static void m6() {
    }

    @Test
    public static void m7() {
        System.out.println("Sample.m7");
        throw new RuntimeException("실패");
    }

    public static void m8() {
    }

}
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 e) {
                    System.out.println("exception: " + e);
                    Throwable exc = e.getCause();
                    System.out.println(m + " 실패: " + exc);
                } catch (Exception e) {
                    System.out.println("잘못 사용한 @Test: " + m);
                    System.out.println("잘못 사용한 @Test e: " + e);
                }
            }
        }
        System.out.println("tests: " + tests + " passed: " + passed);
        System.out.printf("성공: %d, 실패: %d%n", passed, tests - passed);
    }
}

InvocationTargetException: Java의 reflection API를 사용해 메서드를 동적으로 호출할 때 발생할 수 있는 예외. 호출된 메서드 내부에서 발생한 예외를 감싸서 전달하는 역할을 한다. = 예외가 터지면 InvocationTargetException로 감싸서 예외를 던진다.

Sample.m1

Sample.m3
exception: java.lang.reflect.InvocationTargetException
public static void annotation.Sample.m3() 실패: java.lang.RuntimeException: 실패

잘못 사용한 @Test: public void annotation.Sample.m5()
잘못 사용한 @Test e: java.lang.NullPointerException: Cannot invoke "Object.getClass()" because "obj" is null


Sample.m7
exception: java.lang.reflect.InvocationTargetException
public static void annotation.Sample.m7() 실패: java.lang.RuntimeException: 실패



tests: 4 passed: 1
성공: 1, 실패: 3

요약하면, 

invoke(null)을 호출하였기 때문에, 정적 메서드가 아닌 메서드들은 잘못 사용한 @Test로 나오고 실패한다. 만약 객체를 생성해서 매개변수로 넘겨준다면 통과함. 즉, 위 코드는 애너테이션 처리기로 정적 메서드만 들어오도록 제한하지 않았기에 모든 메서드에 @Test를 선언할 수 있지만, 실제 런타임 시에는 invoke(null)로 인해 정적 메서드만 예외 없이 성공함.

그래서 메서드 내에서 예외를 던지는 m3와 m7는 실패하고 m1은 성공, m5은 잘못 사용한 예로 실패함으로 총 성공 1, 실패 3 이다.

 

여기서 핵심은 @Test 애너테이션은 Sample 클래스의 의미에 직접적인 영향을 주지 않는 다는 것이다. Sample의 메서드 내부 로직에 영향을 끼치는게 아니라 메서드 선언에 어노테이션을 붙임으로써 그저 이 어노테이션에 관심있는 다른 프로그램에게 추가 정보를 제공할 뿐이다. 즉, 대상 코드의 의미는 그대로 둔 채 그 애너테이션에 관심 있는 도구(여기서는 RunTests)에서 특별한 처리를 할 기회를 주는 것이다.

 

 

 

* m.invoke()에 null이 객체를 생성해서 넣으면 인스턴스 메서드도 통과한다. 자세한 원리는 리플렉션 학습 필요.

---------------------------------------------------------------------------

@Test 메서드를 private으로 변경하면,

잘못 사용한 @Test: private static void annotation.Sample.m1()
잘못 사용한 @Test e: java.lang.IllegalAccessException: 
class annotation.RunTests cannot access a member of class annotation.
Sample with modifiers "private static"

-----------------------------------------------------------------------

@Test 메서드가 인스턴스 메서드라면,
잘못 사용한 @Test: public void annotation.Sample.m5()
잘못 사용한 @Test e: java.lang.NullPointerException:
Cannot invoke "Object.getClass()" because "obj" is null

 

 

==========================================

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

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

Class<? extens Throwable> = Throwable을 확장한 클래스의 Class 객체. ex) RuntimeException.class

Throwable은 모든 예외와 오류의 상위 타입이므로 모든 예외와 오류 타입을 다 수용한다. 

@ExceptionTest(ArithmeticException.class) //사용 예시

이 애너테이션이 붙은 메서드 내부에서 ArithmeticException이 터지면, 앞서 설명한 InvocationTargetException로 감싸서 예외가 던져지고 이 어노테이션에 관심 있는 도구에서는 getCause() 메서드를 이용해 실제 발생한 예외를 확인하고 처리할 수 있음.

 

==========================================

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

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

사용법은 위와 동일하다

 

==========================================

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

배열 매개변수를 사용하는 대신 애너테이션에 @Repeatable 메타애너테이션을 다는 방식이다.

애너테이션은 같은 애너테이션을 여러 번 달 수 없다.

왜냐하면 애너테이션은 보통 reflection API(tAotation(), tAotations())으로 정보를 가져오는데, 같은 애너테이션이 여러 개 붙어있으면 뭘 가져와야할지 모르기 때문.

@ExceptionTest(ArithmeticException.class)
@ExceptionTest(NullPointerException.class)
//컴파일 에러
Duplicate annotation. The declaration of 'annotation.
arg. ExceptionTest' does not have a valid java. lang. annotation. Repeatable annotation

//만약 위 코드가 정상 동작한다면,
//아래 코드에서 annotation은 @ExceptionTest(ArithmeticException.class)인지 @ExceptionTest(NullPointerException.class)인지 알 수 없음.
ExceptionTest annotation = m.getAnnotation(ExceptionTest.class);

 

하지만, 위와 같이 여러 번 다는 것을 가능하게 해주는 메타 애너테이션이 @Repeatable이다.

단, 주의점(지켜야 할 점)이 있다.

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

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

3. 컨테이너 애너테이션 타입에는 적절한 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() {
    System.out.println("Sample2.doublyBad");
    List<String> list = new ArrayList<>();
    list.addAll(5, null);
}

 

먼저, @Repeatable이 달린 애너테이션이 여러 개 달리면 실제로, 하나 달았을 때랑 구별하기 위해서 컨테이너가 달림.

즉, 위 코드는 사실상 아래 처럼, 컨테이너로 감싸진다. 컴파일 에러 안나는 코드.

@ExceptionTestContainer({
        @ExceptionTest(IndexOutOfBoundsException.class),
        @ExceptionTest(NullPointerException.class)
})
public static void doublyBad() {
    System.out.println("Sample2.doublyBad");
    List<String> list = new ArrayList<>();
    list.addAll(5, null);
}

 

그리고, 하나만 달리면, 그대로 아래처럼 컴파일 됨.

@ExceptionTest(NullPointerException.class)
public static void doublyBad() {
    System.out.println("Sample2.doublyBad");
    List<String> list = new ArrayList<>();
    list.addAll(5, null);
}

 

여기서 알 수 있는 건, 실제 사용하는 쪽에서 매번 Reflection API를 이용해 조회하는 방식이 달라질 수 있다는 것이다.

getAnnotationsByType()은 이 둘을 구분하지 않아서 모두 가져오고 배열을 반환하지만, isAnnotationPresent()는 이 둘을 구분하기에 사용하는 쪽에서 약간의 코드 추가가 필연적이다.

if (m.isAnnotationPresent(ExceptionTest.class) || m.isAnnotationPresent(ExceptionTestContainer.class)) {}
//이런 식으로 컨테이너도 조건문에 포함시켜야 한다.

 

책에서는 반복 가능 애너테이션을 통해 가독성을 높이는 장점이 있다고 말한다.

하지만, 바로 위 코드처럼 처리하는 부분에서 코드 양이 늘어나며, 이는 복잡성을 증가시켜 에러가 날 확률을 높인다는 단점이 있다.

 

나는 개인적으로 컨테이너 애너테이션을 별도로 만들어야 되는 것과, 처리 코드가 복잡해진다는 단점이 장점보다 커보이기 때문에, 배열을 매개변수로 받는 애너테이션을 선호할 듯 하다.

사실상 가독성도 큰 차이가 없다고 느껴진다. 

//@Repeatable
@ExceptionTest(IndexOutOfBoundsException.class)
@ExceptionTest(NullPointerException.class)

//배열 매개변수
@ExceptionTest({
        IndexOutOfBoundsException.class,
        NullPointerException.class
        })

 

 

결론, 애너테이션은 컴파일 시점에 오타나 선언 위치 등을 잡아주기 때문에, 애너테이션으로 할 수 있는 일을 명명 패턴으로 처리할 이유가 없다는 것.

자바 프로그래머라면 예외 없이 자바가 제공하는 애너테이션 타입들은 사용해야 한다.(@Override, @SuppressWarnings 등) 

IDE나 정적 분석 도구가 제공하는 애너테이션(SonarQube에서 제공하는 애너테이션 등)을 사용하면 해당 도구가 제공하는 진단 정보의 품질을 높여줄 것이지만, 표준이 아니므로 도구를 바꾸거가 표준이 만들어지면 수정 작업을 거쳐야 할 것이다.

BELATED ARTICLES

more