명명 패턴보다 Annotation을 사용하라
명명 패턴
• 원래는 도구나 프레임워크가 특별히 다뤄야 할 프로그램 요소에는 명명 패턴을 적용해왔다.
◦ 예) JUnit은 ver.3 까지 테스트 메소드 이름은 test로 시작해야 했다.
명명 패턴의 단점
• 오타가 나면 메소드가 무시된다.
◦ 예) 테스트 메소드 앞에 test 를 붙여야 하는데 tast 를 붙이면 해당 테스트 메소드는 무시된다.
◦ 개발자는 테스트 메소드가 무시된 것을 테스트 메소드가 통과한 것으로 오해할 수 있다.
• (JUnit의 경우) 개발자가 뭐 앞에 test 만 붙이면 다 되는줄 안다.
◦ 메소드가 아닌 클래스 앞에 Test 를 붙여놓고, 해당 클래스에 정의된 메소드들이 테스트 메소드로 실행되길 바란다면?
◦ JUnit은 클래스 이름에는 관심이 없으며, 경고 메세지조차 출력하지 않는다.
◦ 개발자는 역시나 메소드들이 모두 무시된 것을, 테스트 메소드가 모두 통과한 것으로 오해할 수 있다.
• 매개변수를 사용할 수 없다.
◦ 특정 예외를 던져야만 성공하는 테스트가 있는 경우
◦ 기대하는 예외 타입을 테스트에 알려줘야 하지만 방법이 없다.
단점을 어떻게 해결해야 하나?
• Annotation을 사용하면 명명 패턴이 가지는 위의 문제들을 해결할 수 있다.
Annotation
마커 어노테이션 (Marker Annotation)
• 아무 매개변수 없이 단순히 달아주기만 하는 어노테이션
• 어노테이션에 오타를 내거나, 지정된 위치(메소드 위 등) 외 잘못된 위치에 어노테이션을 다는 경우 컴파일 오류가 발생하기 때문에
기존 명명 패턴에서 발생하던 문제를 해결할 수 있다.
• 테스트 메소드용 어노테이션을 직접 정의해보자.
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface Test { ... }
• @Retention(RetentionPolicy.RUNTIME)
◦ @Test 가 런타임에도 유지되어야 한다는 표시. 생략하면 테스트 도구는 @Test 를 인식할 수 없다
• @Target(ElementType.METHOD)
◦ @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 클래스에 직접적인 영향을 주는 것은 아니다.
• 그저 이 Annotation 에 관심있는 프로그램에게 추가 정보를 제공할 뿐
• 즉, @Test 의 역할 = Sample 클래스의 의미는 그대로 둔 채, 외부에서 이 Annotation 을 이용하여 추가적인 처리를 할 수 있도록 해줌
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)) { // @Test 가 달려있는지 확인
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);
}
}
• command line 에서 클래스 이름을 받아 해당 클래스에 존재하는 메소드들을 전부 돌면서 @Test Annotation이 달려있는지를
확인한다.
• @Test Annotation이 달려있는 메소드인 경우 해당 메소드를 실행시키며, 실행된 메소드에서 예외가 발생되면 해당 예외를
InvocationTargetException 으로 받아 반환하고, InvocationTargetException 외의 예외가 발생하는 경우 이는 @Test 를
잘못 사용한 것이므로 이를 출력하여 알려준다.
매개변수를 가지는 어노테이션
• 특정 예외를 던져야만 성공하는 테스트 메소드용 어노테이션을 정의해보자.
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface ExceptionTest {
Class<? extends Throwable> value();
}
• 위 어노테이션의 매개변수 타입은 Class<? extends Throwable> 이다.
• 와일드카드 → Throwable을 확장한 클래스 객체 의미
◦ 즉 모든 Exception 을 매개변수로 받을 수 있다.
public class Sample2 {
@ExceptionTest(ArithmeticException.class) // 분모가 0일때 발생하는 에러
public static void m1() { // 성공 (ArithmeticException 예외발생)
int i = 0;
i = i / i;
}
@ExceptionTest(ArithmeticException.class)
public static void m2() { // 실패 (ArithmeticException 말고 다른 예외발생)
int[] a = new int[0];
int i = a[1]; // NullPointerException
}
@ExceptionTest(ArithmeticException.class)
public static void m3() { } // 실패 (예외가 발생하지 않았음)
}
• 새로 만든 어노테이션을 메소드에 달아주었다.
• 모든 메소드는 ArithmeticException 예외를 반환해야 한다.
if (m.isAnnotationPresent(ExceptionTest.class)) {
try {
m.invoke(null);
System.out.printf("테스트 %s 실패: 예외를 던지지 않음%n", m);
}
catch (InvocationTargetException wrappedEx) {
Throwable exc = wrappedEx.getCause(); // exc = 실제 발생한 예외
Class<? extends Throwable> excType = m.getAnnotation(ExceptionTest.class).value(); // excType = 어노테이션에 명시된 예외
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);
}
}
• 어노테이션 매개변수 값을 추출하여 테스트 메소드가 반환한 예외와 어노테이션에서 매개변수로 명시된 예외가 동일한지를 비교해준다.
• 명시된 예외가 아닌 다른 예외가 반환되는 경우 : 실패로 처리
• 아무런 예외가 반환되지 않는 경우 : 실패로 처리
• InvocationTargetException 이외의 예외가 발생하는 경우 : 어노테이션을 잘못 사용함
배열 매개변수를 가지는 어노테이션
• 예외를 여러개 명시한 다음 그 중 하나만 발생해도 OK 인 로직을 구현하려면 어떻게 해야할까?
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface ExceptionTest {
Class<? extends Exception>[] value();
}
@ExceptionTest({IndexOutOfBoundsException.class, NullPointerException.class})
public static void doublyBad() {
List<String> list = new ArrayList<>();
list.addAll(5, null); // list의 5번 인덱스부터 null 에 들어있는 값을 add 해라
}
• 매개변수를 여러개 넘길 때 { } 로 감싸주기만 하면 된다.
if (m.isAnnotationPresent(ExceptionTest.class)) {
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문을 돌면서 검사
for (Class<? extends Throwable> excType : excTypes) {
if (excType.isInstance(exc)) { passed++; break; }
}
if (passed == oldPassed) System.out.printf("테스트 %s 실패: %s %n", m, exc);
}
}
배열 매개변수 대신 @Repeatable 을 사용하는 방법
• 어노테이션 선언시 @Repeatable을 메타 어노테이션으로 달아주면 동일한 어노테이션을 여러번 사용할 수 있다.
• 단, 어노테이션 선언 방법이 조금 달라진다.
◦ 예를 들어 아래와 같이 어노테이션 A 에 @Repeatable 을 달아서 정의하려 한다고 하자.
@Repeatable
public @interface A { ... }
◦ 먼저 어노테이션 A 를 반환하는 컨테이너 어노테이션을 정의해야 한다.
◦ 그 다음 컨테이너 어노테이션 객체를 A 의 매개변수로 넣어줘야 한다.
- 이 때 컨테이너 어노테이션 은 A 타입 배열을 반환하는 value 메소드 를 가지고 있어야 한다.
- 또한 컨테이너 어노테이션 에는 적절한 @Retension 과 @Target 이 명시되어야 한다.
- 이걸 지키지 않으면 컴파일 되지 않는다.
// 1. @Repeatable을 달고 있는 Annotation
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
@Repeatable(ExceptionTestContainer.class)
public @interface ExceptionTest {
Class<? extends Throwable> value();
}
// 2. 위 어노테이션 객체 배열을 반환하는 '컨테이너 어노테이션'
@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<>();
list.addAll(5, null);
}
• 가독성이 조금 더 좋아진다!
• 어노테이션을 한번만 달면 단일 어노테이션 타입이 적용되지만, 이렇게 여러번 달면 컨테이너 어노테이션 타입이 적용된다.
• 즉 위와 같은 경우 아래와 같은 T/F 값이 반환된다.
doublyBad.isAnnotationPresent(ExceptionTest.class) // false
doublyBad.isAnnotationPresent(ExceptionTestContainer.class) // true
• 즉 어노테이션이 몇 번 달려 있는지 상관없이, @ExceptionTest 를 달고 있는 메소드를 다 골라보려면
두가지 상황을 모두(|| 사용) 고려해 줘야 한다.
if (m.isAnnotationPresent(ExceptionTest.class) ||
m.isAnnotationPresent(ExceptionTestContainer.class)) {
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);
}
}
• 즉 @Repeatable을 사용하면 가독성은 좋아지지만, 어노테이션을 처리하는 부분에서 코드 처리가 복잡해질 수 있다.
정리
• 어노테이션으로 처리할 수 있는 상황이라면 명명 패턴을 사용하지 말자.
• 사실 도구 제작자를 제외하고는 일반 프로그래머가 어노테이션을 직접 정의할 일은 잘 없다!
• 하지만 어노테이션을 사용하는 사람이라면 그 작동 원리를 알아둘 필요가 있다!
'Java' 카테고리의 다른 글
[Effective Java] ITEM49 : 매개변수가 유효한지 검사하라 (0) | 2022.08.06 |
---|---|
[Effective Java] ITEM44 : 표준 함수형 인터페이스를 사용하라 (feat.람다) (0) | 2022.08.06 |
[Effective Java] ITEM34 : int 상수 대신 열거 타입을 사용하라 (0) | 2022.08.06 |
[Effective Java] ITEM29 : 이왕이면 제네릭 타입으로 만들라 (0) | 2022.07.31 |
[Effective Java] ITEM24 : 멤버 클래스는 되도록 static으로 만들라 (0) | 2022.07.31 |